taskplane 0.28.4 → 0.28.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,502 +1,502 @@
1
- /**
2
- * Abort logic (graceful and hard)
3
- * @module orch/abort
4
- */
5
- import { writeFileSync, existsSync } from "fs";
6
- import { join } from "path";
7
-
8
- import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
9
- import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
10
- import { deleteBatchState, persistRuntimeState } from "./persistence.ts";
11
- import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
12
-
13
- // ── Abort Pure Functions ─────────────────────────────────────────────
14
-
15
- /**
16
- * Select and enrich target sessions for abort.
17
- *
18
- * Filters sessions to only `orch-lane-*` and `orch-merge-*` patterns,
19
- * then enriches with task folder and worktree info from persisted or
20
- * runtime state.
21
- *
22
- * Pure function: no side effects.
23
- *
24
- * @param allSessionNames - All TMUX session names matching the prefix
25
- * @param persistedState - Loaded persisted state (null if unavailable)
26
- * @param runtimeLanes - Current in-memory lanes (from orchBatchState)
27
- * @param repoRoot - Repository root path for task folder resolution
28
- * @returns Filtered and enriched target sessions
29
- */
30
- export function selectAbortTargetSessions(
31
- allSessionNames: string[],
32
- persistedState: PersistedBatchState | null,
33
- runtimeLanes: AllocatedLane[],
34
- repoRoot: string,
35
- prefix: string = "orch",
36
- ): AbortTargetSession[] {
37
- // Filter to only lane and merge sessions for the exact orchestrator prefix.
38
- // Handles both repo-mode (`<prefix>-lane-<N>`) and workspace-mode
39
- // (`<prefix>-<repoId>-lane-<N>`) session name formats.
40
- const targetNames = allSessionNames.filter(name => {
41
- const prefixWithDash = `${prefix}-`;
42
- if (!name.startsWith(prefixWithDash)) return false;
43
- const suffix = name.slice(prefixWithDash.length);
44
- // Repo mode: suffix starts with "lane-" or "merge-"
45
- if (suffix.startsWith("lane-") || suffix.startsWith("merge-")) return true;
46
- // Workspace mode: suffix is "<repoId>-lane-<N>" — contains "-lane-"
47
- // Match any suffix that contains "-lane-" or "-merge-" followed by a number
48
- if (/\-lane-\d/.test(suffix) || /\-merge-\d/.test(suffix)) return true;
49
- return false;
50
- });
51
-
52
- // Build lookup from persisted lane records for workspace-aware laneId resolution.
53
- // Keyed by lane session ID for direct session-to-lane mapping.
54
- const persistedLaneLookup = new Map<string, PersistedLaneRecord>();
55
- if (persistedState?.lanes) {
56
- for (const lane of persistedState.lanes) {
57
- persistedLaneLookup.set(lane.laneSessionId, lane);
58
- }
59
- }
60
-
61
- // Build lookup from persisted state task records
62
- const persistedLookup = new Map<string, { laneId: string; taskId: string; taskFolder: string }>();
63
- if (persistedState) {
64
- for (const task of persistedState.tasks) {
65
- if (task.sessionName) {
66
- // Source laneId from persisted lane records (workspace-aware)
67
- // rather than reconstructing as `lane-${laneNumber}` which
68
- // drops the repo dimension in workspace mode.
69
- const laneRecord = persistedLaneLookup.get(task.sessionName);
70
- const laneId = laneRecord?.laneId ?? `lane-${task.laneNumber}`;
71
- persistedLookup.set(task.sessionName, {
72
- laneId,
73
- taskId: task.taskId,
74
- taskFolder: task.taskFolder,
75
- });
76
- }
77
- }
78
- }
79
-
80
- // Build lookup from runtime lanes
81
- const runtimeLookup = new Map<string, { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }>();
82
- for (const lane of runtimeLanes) {
83
- const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null;
84
- runtimeLookup.set(lane.laneSessionId, {
85
- laneId: lane.laneId,
86
- taskId: currentTask?.taskId || null,
87
- worktreePath: lane.worktreePath,
88
- // TP-169: Guard against null task stubs from reconstructAllocatedLanes
89
- taskFolder: currentTask?.task?.taskFolder || null,
90
- });
91
- }
92
-
93
- return targetNames.map(sessionName => {
94
- const runtime = runtimeLookup.get(sessionName);
95
- const persisted = persistedLookup.get(sessionName);
96
-
97
- const laneId = runtime?.laneId || persisted?.laneId || "unknown";
98
- const taskId = runtime?.taskId || persisted?.taskId || null;
99
- const worktreePath = runtime?.worktreePath || null;
100
- const taskFolder = runtime?.taskFolder || persisted?.taskFolder || null;
101
-
102
- // Resolve task folder path using the canonical resolver.
103
- // For repo-contained tasks: translates to worktree-relative path.
104
- // For external tasks: uses the absolute canonical path directly.
105
- let taskFolderInWorktree: string | null = null;
106
- if (taskFolder && worktreePath && repoRoot) {
107
- const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
108
- taskFolderInWorktree = resolved.taskFolderResolved;
109
- }
110
-
111
- return {
112
- sessionName,
113
- laneId,
114
- taskId,
115
- taskFolderInWorktree,
116
- worktreePath,
117
- };
118
- });
119
- }
120
-
121
- /**
122
- * Plan the ordered list of abort actions based on mode.
123
- *
124
- * Pure function: no side effects.
125
- *
126
- * @param mode - Abort mode (graceful or hard)
127
- * @param gracePeriodMs - Grace period in ms (graceful only, default 60000)
128
- * @param pollIntervalMs - Poll interval in ms (graceful only, default 2000)
129
- * @returns Ordered list of abort action steps
130
- */
131
- export function planAbortActions(
132
- mode: AbortMode,
133
- gracePeriodMs: number = 60_000,
134
- pollIntervalMs: number = 2_000,
135
- ): AbortActionStep[] {
136
- if (mode === "hard") {
137
- return [{ type: "kill-all" }];
138
- }
139
- return [
140
- { type: "write-wrapup" },
141
- { type: "poll-wait", gracePeriodMs, pollIntervalMs },
142
- { type: "kill-remaining" },
143
- ];
144
- }
145
-
146
- /**
147
- * Discover abort target session names from Runtime V2 state sources.
148
- *
149
- * Sources (deduped):
150
- * - in-memory runtime lanes (`batchState.currentLanes`)
151
- * - persisted lane records (`persistedState.lanes`)
152
- * - persisted task records (`persistedState.tasks[].sessionName`)
153
- */
154
- export function discoverAbortSessionNames(
155
- prefix: string,
156
- persistedState: PersistedBatchState | null,
157
- runtimeLanes: AllocatedLane[],
158
- ): string[] {
159
- const names = new Set<string>();
160
- const prefixWithDash = `${prefix}-`;
161
- const add = (name: string | null | undefined) => {
162
- if (!name) return;
163
- const trimmed = name.trim();
164
- if (!trimmed || !trimmed.startsWith(prefixWithDash)) return;
165
- names.add(trimmed);
166
- };
167
-
168
- for (const lane of runtimeLanes) {
169
- add(lane.laneSessionId);
170
- }
171
-
172
- if (persistedState?.lanes) {
173
- for (const lane of persistedState.lanes) {
174
- add(lane.laneSessionId);
175
- }
176
- }
177
-
178
- if (persistedState?.tasks) {
179
- for (const task of persistedState.tasks) {
180
- add(task.sessionName);
181
- }
182
- }
183
-
184
- return [...names];
185
- }
186
-
187
-
188
- // ── Abort Orchestration Functions ────────────────────────────────────
189
-
190
- /**
191
- * Write wrap-up signal files to each lane's task folder.
192
- *
193
- * Writes `.task-wrap-up` signal file to each lane's task folder.
194
- * Continues on partial failure — aggregates errors per lane.
195
- *
196
- * @param targets - Target sessions with resolved task folders
197
- * @returns Updated target results with wrapUpWritten/wrapUpError
198
- */
199
- export function writeWrapUpFiles(
200
- targets: AbortTargetSession[],
201
- ): Array<{ sessionName: string; written: boolean; error: string | null }> {
202
- const timestamp = new Date().toISOString();
203
- const content = `Abort requested at ${timestamp}`;
204
- const results: Array<{ sessionName: string; written: boolean; error: string | null }> = [];
205
-
206
- for (const target of targets) {
207
- if (!target.taskFolderInWorktree) {
208
- // Skip child sessions (workers, reviewers) — only main lane sessions have task folders
209
- // Also skip merge sessions (no task folder)
210
- if (target.sessionName.endsWith("-worker") || target.sessionName.endsWith("-reviewer") || target.sessionName.includes("merge")) {
211
- results.push({ sessionName: target.sessionName, written: false, error: null });
212
- } else {
213
- results.push({ sessionName: target.sessionName, written: false, error: "No task folder resolved" });
214
- }
215
- continue;
216
- }
217
-
218
- try {
219
- const primaryPath = join(target.taskFolderInWorktree, ".task-wrap-up");
220
-
221
- // Ensure directory exists
222
- if (!existsSync(target.taskFolderInWorktree)) {
223
- results.push({ sessionName: target.sessionName, written: false, error: `Task folder does not exist: ${target.taskFolderInWorktree}` });
224
- continue;
225
- }
226
-
227
- writeFileSync(primaryPath, content, "utf-8");
228
- results.push({ sessionName: target.sessionName, written: true, error: null });
229
- } catch (err) {
230
- results.push({
231
- sessionName: target.sessionName,
232
- written: false,
233
- error: err instanceof Error ? err.message : String(err),
234
- });
235
- }
236
- }
237
-
238
- return results;
239
- }
240
-
241
- /**
242
- * Wait for graceful shutdown window to elapse.
243
- *
244
- * Runtime V2 no longer relies on TMUX session liveness as an abort signal.
245
- * We keep this grace window so workers can observe `.task-wrap-up` and exit
246
- * naturally before forced cleanup.
247
- *
248
- * @param sessionNames - Session names being tracked for abort
249
- * @param gracePeriodMs - Maximum time to wait
250
- * @param pollIntervalMs - Polling cadence for the grace wait loop
251
- * @returns Object with exited and remaining session names
252
- */
253
- export async function waitForSessionExit(
254
- sessionNames: string[],
255
- gracePeriodMs: number,
256
- pollIntervalMs: number,
257
- ): Promise<{ exited: string[]; remaining: string[] }> {
258
- if (sessionNames.length === 0 || gracePeriodMs <= 0) {
259
- return { exited: [], remaining: [...sessionNames] };
260
- }
261
-
262
- const deadline = Date.now() + gracePeriodMs;
263
- while (Date.now() < deadline) {
264
- const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now()));
265
- await new Promise(r => setTimeout(r, sleepMs));
266
- }
267
-
268
- return { exited: [], remaining: [...sessionNames] };
269
- }
270
-
271
- /**
272
- * Kill orchestrator Runtime V2 agents.
273
- *
274
- * Kills lane worker/reviewer agents and merge agents by process handle.
275
- * Session names are normalized to base lane/merge IDs so child suffixes do
276
- * not trigger duplicate cleanup attempts.
277
- *
278
- * @param sessionNames - Session names to kill
279
- * @returns Per-session kill results
280
- */
281
- export function killOrchSessions(
282
- sessionNames: string[],
283
- options?: { stateRoot?: string; batchId?: string },
284
- ): Array<{ sessionName: string; killed: boolean; error: string | null }> {
285
- const results: Array<{ sessionName: string; killed: boolean; error: string | null }> = [];
286
- const killedBaseSessions = new Set<string>();
287
-
288
- for (const name of sessionNames) {
289
- const baseSessionName = name.replace(/-(worker|reviewer)$/, "");
290
- if (!killedBaseSessions.has(baseSessionName)) {
291
- killV2LaneAgents(baseSessionName, {
292
- stateRoot: options?.stateRoot,
293
- batchId: options?.batchId,
294
- logContext: "abort",
295
- });
296
- killMergeAgentV2(baseSessionName);
297
- killedBaseSessions.add(baseSessionName);
298
- }
299
-
300
- results.push({
301
- sessionName: name,
302
- killed: true,
303
- error: null,
304
- });
305
- }
306
-
307
- return results;
308
- }
309
-
310
- /**
311
- * Execute a full abort operation.
312
- *
313
- * Phase/state transition ordering:
314
- * 1. Set phase to "stopped"
315
- * 2. Persist runtime state (so state file reflects stopped phase)
316
- * 3. Select target sessions
317
- * 4. Execute mode-specific flow (graceful or hard)
318
- * 5. Delete batch state file
319
- * 6. Return AbortResult
320
- *
321
- * Non-goal: does NOT delete worktrees/branches (preserved for inspection).
322
- *
323
- * @param mode - Abort mode (graceful or hard)
324
- * @param prefix - orchestrator session prefix (e.g., "orch")
325
- * @param repoRoot - Repository root path
326
- * @param batchState - Current batch runtime state (mutated: phase set to stopped)
327
- * @param persistedState - Loaded persisted state (for session enrichment)
328
- * @param gracePeriodMs - Grace period for graceful abort (default 60000)
329
- * @param pollIntervalMs - Poll interval for graceful abort (default 2000)
330
- * @returns AbortResult with per-lane details
331
- */
332
- export async function executeAbort(
333
- mode: AbortMode,
334
- prefix: string,
335
- repoRoot: string,
336
- batchState: OrchBatchRuntimeState,
337
- persistedState: PersistedBatchState | null,
338
- gracePeriodMs: number = 60_000,
339
- pollIntervalMs: number = 2_000,
340
- ): Promise<AbortResult> {
341
- const startTime = Date.now();
342
- const errors: Array<{ code: AbortErrorCode; message: string }> = [];
343
-
344
- // Step 1: Set phase to stopped
345
- batchState.phase = "stopped";
346
- batchState.endedAt = Date.now();
347
-
348
- // Step 2: Persist state (best-effort — abort must continue even if persist fails)
349
- try {
350
- persistRuntimeState(
351
- `abort-${mode}`,
352
- batchState,
353
- [], // wavePlan not needed for abort persistence
354
- batchState.currentLanes,
355
- [], // taskOutcomes not needed
356
- null, // discovery not needed
357
- repoRoot,
358
- );
359
- } catch (err) {
360
- execLog("abort", batchState.batchId, `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`);
361
- }
362
-
363
- // TP-108: Kill all V2 merge agents (process-owned, not TMUX)
364
- // This catches V2 merge agents that have no TMUX session.
365
- const v2MergeKilled = killAllMergeAgentsV2();
366
- if (v2MergeKilled > 0) {
367
- execLog("abort", batchState.batchId, `killed ${v2MergeKilled} V2 merge agent(s)`);
368
- }
369
-
370
- // Step 3: Discover target sessions from Runtime V2 state sources.
371
- const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes);
372
- if (allSessionNames.length === 0) {
373
- execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`);
374
- }
375
-
376
- // Step 4: Select and enrich target sessions
377
- const targets = selectAbortTargetSessions(
378
- allSessionNames,
379
- persistedState,
380
- batchState.currentLanes,
381
- repoRoot,
382
- prefix,
383
- );
384
-
385
- const laneResults: AbortLaneResult[] = [];
386
- let gracefulExits = 0;
387
- let wrapUpFailures = 0;
388
-
389
- if (mode === "graceful") {
390
- // Step 5a: Write wrap-up files
391
- const wrapUpResults = writeWrapUpFiles(targets);
392
- for (const wr of wrapUpResults) {
393
- if (wr.error) wrapUpFailures++;
394
- }
395
- if (wrapUpFailures > 0) {
396
- errors.push({
397
- code: "ABORT_WRAPUP_WRITE_FAILED",
398
- message: `Failed to write wrap-up files for ${wrapUpFailures} session(s)`,
399
- });
400
- }
401
-
402
- // Step 5b: Wait for sessions to exit
403
- const allTargetNames = targets.map(t => t.sessionName);
404
- const waitResult = await waitForSessionExit(allTargetNames, gracePeriodMs, pollIntervalMs);
405
- gracefulExits = waitResult.exited.length;
406
-
407
- // Step 5c: Force-kill remaining sessions
408
- const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
409
- if (waitResult.remaining.length > 0) {
410
- const killResults = killOrchSessions(waitResult.remaining, {
411
- stateRoot: repoRoot,
412
- batchId: batchState.batchId,
413
- });
414
- for (const kr of killResults) {
415
- killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
416
- }
417
- const killFailures = killResults.filter(kr => !kr.killed);
418
- if (killFailures.length > 0) {
419
- errors.push({
420
- code: "ABORT_KILL_FAILED",
421
- message: `Failed to kill ${killFailures.length} session(s)`,
422
- });
423
- }
424
- }
425
-
426
- // Build lane results
427
- const exitedSet = new Set(waitResult.exited);
428
- for (const target of targets) {
429
- const wrapUp = wrapUpResults.find(wr => wr.sessionName === target.sessionName);
430
- const wasGraceful = exitedSet.has(target.sessionName);
431
- const killResult = killResultBySession.get(target.sessionName);
432
- const sessionKilled = wasGraceful || killResult?.killed === true;
433
- laneResults.push({
434
- sessionName: target.sessionName,
435
- laneId: target.laneId,
436
- taskId: target.taskId,
437
- taskFolderInWorktree: target.taskFolderInWorktree,
438
- wrapUpWritten: wrapUp?.written || false,
439
- wrapUpError: wrapUp?.error || null,
440
- sessionKilled,
441
- exitedGracefully: wasGraceful,
442
- });
443
- }
444
- } else {
445
- // Hard mode: kill all immediately
446
- const allTargetNames = targets.map(t => t.sessionName);
447
- const killResults = killOrchSessions(allTargetNames, {
448
- stateRoot: repoRoot,
449
- batchId: batchState.batchId,
450
- });
451
- const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
452
- for (const kr of killResults) {
453
- killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
454
- }
455
- const killFailures = killResults.filter(kr => !kr.killed);
456
- if (killFailures.length > 0) {
457
- errors.push({
458
- code: "ABORT_KILL_FAILED",
459
- message: `Failed to kill ${killFailures.length} session(s)`,
460
- });
461
- }
462
-
463
- for (const target of targets) {
464
- const killResult = killResultBySession.get(target.sessionName);
465
- laneResults.push({
466
- sessionName: target.sessionName,
467
- laneId: target.laneId,
468
- taskId: target.taskId,
469
- taskFolderInWorktree: target.taskFolderInWorktree,
470
- wrapUpWritten: false,
471
- wrapUpError: null,
472
- sessionKilled: killResult?.killed === true,
473
- exitedGracefully: false,
474
- });
475
- }
476
- }
477
-
478
- // Step 6: Delete batch state file
479
- let stateDeleted = false;
480
- try {
481
- deleteBatchState(repoRoot);
482
- stateDeleted = true;
483
- } catch (err) {
484
- errors.push({
485
- code: "ABORT_STATE_DELETE_FAILED",
486
- message: err instanceof Error ? err.message : String(err),
487
- });
488
- }
489
-
490
- return {
491
- mode,
492
- sessionsFound: targets.length,
493
- sessionsKilled: laneResults.filter(lr => lr.sessionKilled).length,
494
- gracefulExits,
495
- laneResults,
496
- wrapUpFailures,
497
- stateDeleted,
498
- errors,
499
- durationMs: Date.now() - startTime,
500
- };
501
- }
502
-
1
+ /**
2
+ * Abort logic (graceful and hard)
3
+ * @module orch/abort
4
+ */
5
+ import { writeFileSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
9
+ import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
10
+ import { deleteBatchState, persistRuntimeState } from "./persistence.ts";
11
+ import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
12
+
13
+ // ── Abort Pure Functions ─────────────────────────────────────────────
14
+
15
+ /**
16
+ * Select and enrich target sessions for abort.
17
+ *
18
+ * Filters sessions to only `orch-lane-*` and `orch-merge-*` patterns,
19
+ * then enriches with task folder and worktree info from persisted or
20
+ * runtime state.
21
+ *
22
+ * Pure function: no side effects.
23
+ *
24
+ * @param allSessionNames - All TMUX session names matching the prefix
25
+ * @param persistedState - Loaded persisted state (null if unavailable)
26
+ * @param runtimeLanes - Current in-memory lanes (from orchBatchState)
27
+ * @param repoRoot - Repository root path for task folder resolution
28
+ * @returns Filtered and enriched target sessions
29
+ */
30
+ export function selectAbortTargetSessions(
31
+ allSessionNames: string[],
32
+ persistedState: PersistedBatchState | null,
33
+ runtimeLanes: AllocatedLane[],
34
+ repoRoot: string,
35
+ prefix: string = "orch",
36
+ ): AbortTargetSession[] {
37
+ // Filter to only lane and merge sessions for the exact orchestrator prefix.
38
+ // Handles both repo-mode (`<prefix>-lane-<N>`) and workspace-mode
39
+ // (`<prefix>-<repoId>-lane-<N>`) session name formats.
40
+ const targetNames = allSessionNames.filter(name => {
41
+ const prefixWithDash = `${prefix}-`;
42
+ if (!name.startsWith(prefixWithDash)) return false;
43
+ const suffix = name.slice(prefixWithDash.length);
44
+ // Repo mode: suffix starts with "lane-" or "merge-"
45
+ if (suffix.startsWith("lane-") || suffix.startsWith("merge-")) return true;
46
+ // Workspace mode: suffix is "<repoId>-lane-<N>" — contains "-lane-"
47
+ // Match any suffix that contains "-lane-" or "-merge-" followed by a number
48
+ if (/\-lane-\d/.test(suffix) || /\-merge-\d/.test(suffix)) return true;
49
+ return false;
50
+ });
51
+
52
+ // Build lookup from persisted lane records for workspace-aware laneId resolution.
53
+ // Keyed by lane session ID for direct session-to-lane mapping.
54
+ const persistedLaneLookup = new Map<string, PersistedLaneRecord>();
55
+ if (persistedState?.lanes) {
56
+ for (const lane of persistedState.lanes) {
57
+ persistedLaneLookup.set(lane.laneSessionId, lane);
58
+ }
59
+ }
60
+
61
+ // Build lookup from persisted state task records
62
+ const persistedLookup = new Map<string, { laneId: string; taskId: string; taskFolder: string }>();
63
+ if (persistedState) {
64
+ for (const task of persistedState.tasks) {
65
+ if (task.sessionName) {
66
+ // Source laneId from persisted lane records (workspace-aware)
67
+ // rather than reconstructing as `lane-${laneNumber}` which
68
+ // drops the repo dimension in workspace mode.
69
+ const laneRecord = persistedLaneLookup.get(task.sessionName);
70
+ const laneId = laneRecord?.laneId ?? `lane-${task.laneNumber}`;
71
+ persistedLookup.set(task.sessionName, {
72
+ laneId,
73
+ taskId: task.taskId,
74
+ taskFolder: task.taskFolder,
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ // Build lookup from runtime lanes
81
+ const runtimeLookup = new Map<string, { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }>();
82
+ for (const lane of runtimeLanes) {
83
+ const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null;
84
+ runtimeLookup.set(lane.laneSessionId, {
85
+ laneId: lane.laneId,
86
+ taskId: currentTask?.taskId || null,
87
+ worktreePath: lane.worktreePath,
88
+ // TP-169: Guard against null task stubs from reconstructAllocatedLanes
89
+ taskFolder: currentTask?.task?.taskFolder || null,
90
+ });
91
+ }
92
+
93
+ return targetNames.map(sessionName => {
94
+ const runtime = runtimeLookup.get(sessionName);
95
+ const persisted = persistedLookup.get(sessionName);
96
+
97
+ const laneId = runtime?.laneId || persisted?.laneId || "unknown";
98
+ const taskId = runtime?.taskId || persisted?.taskId || null;
99
+ const worktreePath = runtime?.worktreePath || null;
100
+ const taskFolder = runtime?.taskFolder || persisted?.taskFolder || null;
101
+
102
+ // Resolve task folder path using the canonical resolver.
103
+ // For repo-contained tasks: translates to worktree-relative path.
104
+ // For external tasks: uses the absolute canonical path directly.
105
+ let taskFolderInWorktree: string | null = null;
106
+ if (taskFolder && worktreePath && repoRoot) {
107
+ const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
108
+ taskFolderInWorktree = resolved.taskFolderResolved;
109
+ }
110
+
111
+ return {
112
+ sessionName,
113
+ laneId,
114
+ taskId,
115
+ taskFolderInWorktree,
116
+ worktreePath,
117
+ };
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Plan the ordered list of abort actions based on mode.
123
+ *
124
+ * Pure function: no side effects.
125
+ *
126
+ * @param mode - Abort mode (graceful or hard)
127
+ * @param gracePeriodMs - Grace period in ms (graceful only, default 60000)
128
+ * @param pollIntervalMs - Poll interval in ms (graceful only, default 2000)
129
+ * @returns Ordered list of abort action steps
130
+ */
131
+ export function planAbortActions(
132
+ mode: AbortMode,
133
+ gracePeriodMs: number = 60_000,
134
+ pollIntervalMs: number = 2_000,
135
+ ): AbortActionStep[] {
136
+ if (mode === "hard") {
137
+ return [{ type: "kill-all" }];
138
+ }
139
+ return [
140
+ { type: "write-wrapup" },
141
+ { type: "poll-wait", gracePeriodMs, pollIntervalMs },
142
+ { type: "kill-remaining" },
143
+ ];
144
+ }
145
+
146
+ /**
147
+ * Discover abort target session names from Runtime V2 state sources.
148
+ *
149
+ * Sources (deduped):
150
+ * - in-memory runtime lanes (`batchState.currentLanes`)
151
+ * - persisted lane records (`persistedState.lanes`)
152
+ * - persisted task records (`persistedState.tasks[].sessionName`)
153
+ */
154
+ export function discoverAbortSessionNames(
155
+ prefix: string,
156
+ persistedState: PersistedBatchState | null,
157
+ runtimeLanes: AllocatedLane[],
158
+ ): string[] {
159
+ const names = new Set<string>();
160
+ const prefixWithDash = `${prefix}-`;
161
+ const add = (name: string | null | undefined) => {
162
+ if (!name) return;
163
+ const trimmed = name.trim();
164
+ if (!trimmed || !trimmed.startsWith(prefixWithDash)) return;
165
+ names.add(trimmed);
166
+ };
167
+
168
+ for (const lane of runtimeLanes) {
169
+ add(lane.laneSessionId);
170
+ }
171
+
172
+ if (persistedState?.lanes) {
173
+ for (const lane of persistedState.lanes) {
174
+ add(lane.laneSessionId);
175
+ }
176
+ }
177
+
178
+ if (persistedState?.tasks) {
179
+ for (const task of persistedState.tasks) {
180
+ add(task.sessionName);
181
+ }
182
+ }
183
+
184
+ return [...names];
185
+ }
186
+
187
+
188
+ // ── Abort Orchestration Functions ────────────────────────────────────
189
+
190
+ /**
191
+ * Write wrap-up signal files to each lane's task folder.
192
+ *
193
+ * Writes `.task-wrap-up` signal file to each lane's task folder.
194
+ * Continues on partial failure — aggregates errors per lane.
195
+ *
196
+ * @param targets - Target sessions with resolved task folders
197
+ * @returns Updated target results with wrapUpWritten/wrapUpError
198
+ */
199
+ export function writeWrapUpFiles(
200
+ targets: AbortTargetSession[],
201
+ ): Array<{ sessionName: string; written: boolean; error: string | null }> {
202
+ const timestamp = new Date().toISOString();
203
+ const content = `Abort requested at ${timestamp}`;
204
+ const results: Array<{ sessionName: string; written: boolean; error: string | null }> = [];
205
+
206
+ for (const target of targets) {
207
+ if (!target.taskFolderInWorktree) {
208
+ // Skip child sessions (workers, reviewers) — only main lane sessions have task folders
209
+ // Also skip merge sessions (no task folder)
210
+ if (target.sessionName.endsWith("-worker") || target.sessionName.endsWith("-reviewer") || target.sessionName.includes("merge")) {
211
+ results.push({ sessionName: target.sessionName, written: false, error: null });
212
+ } else {
213
+ results.push({ sessionName: target.sessionName, written: false, error: "No task folder resolved" });
214
+ }
215
+ continue;
216
+ }
217
+
218
+ try {
219
+ const primaryPath = join(target.taskFolderInWorktree, ".task-wrap-up");
220
+
221
+ // Ensure directory exists
222
+ if (!existsSync(target.taskFolderInWorktree)) {
223
+ results.push({ sessionName: target.sessionName, written: false, error: `Task folder does not exist: ${target.taskFolderInWorktree}` });
224
+ continue;
225
+ }
226
+
227
+ writeFileSync(primaryPath, content, "utf-8");
228
+ results.push({ sessionName: target.sessionName, written: true, error: null });
229
+ } catch (err) {
230
+ results.push({
231
+ sessionName: target.sessionName,
232
+ written: false,
233
+ error: err instanceof Error ? err.message : String(err),
234
+ });
235
+ }
236
+ }
237
+
238
+ return results;
239
+ }
240
+
241
+ /**
242
+ * Wait for graceful shutdown window to elapse.
243
+ *
244
+ * Runtime V2 no longer relies on TMUX session liveness as an abort signal.
245
+ * We keep this grace window so workers can observe `.task-wrap-up` and exit
246
+ * naturally before forced cleanup.
247
+ *
248
+ * @param sessionNames - Session names being tracked for abort
249
+ * @param gracePeriodMs - Maximum time to wait
250
+ * @param pollIntervalMs - Polling cadence for the grace wait loop
251
+ * @returns Object with exited and remaining session names
252
+ */
253
+ export async function waitForSessionExit(
254
+ sessionNames: string[],
255
+ gracePeriodMs: number,
256
+ pollIntervalMs: number,
257
+ ): Promise<{ exited: string[]; remaining: string[] }> {
258
+ if (sessionNames.length === 0 || gracePeriodMs <= 0) {
259
+ return { exited: [], remaining: [...sessionNames] };
260
+ }
261
+
262
+ const deadline = Date.now() + gracePeriodMs;
263
+ while (Date.now() < deadline) {
264
+ const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now()));
265
+ await new Promise(r => setTimeout(r, sleepMs));
266
+ }
267
+
268
+ return { exited: [], remaining: [...sessionNames] };
269
+ }
270
+
271
+ /**
272
+ * Kill orchestrator Runtime V2 agents.
273
+ *
274
+ * Kills lane worker/reviewer agents and merge agents by process handle.
275
+ * Session names are normalized to base lane/merge IDs so child suffixes do
276
+ * not trigger duplicate cleanup attempts.
277
+ *
278
+ * @param sessionNames - Session names to kill
279
+ * @returns Per-session kill results
280
+ */
281
+ export function killOrchSessions(
282
+ sessionNames: string[],
283
+ options?: { stateRoot?: string; batchId?: string },
284
+ ): Array<{ sessionName: string; killed: boolean; error: string | null }> {
285
+ const results: Array<{ sessionName: string; killed: boolean; error: string | null }> = [];
286
+ const killedBaseSessions = new Set<string>();
287
+
288
+ for (const name of sessionNames) {
289
+ const baseSessionName = name.replace(/-(worker|reviewer)$/, "");
290
+ if (!killedBaseSessions.has(baseSessionName)) {
291
+ killV2LaneAgents(baseSessionName, {
292
+ stateRoot: options?.stateRoot,
293
+ batchId: options?.batchId,
294
+ logContext: "abort",
295
+ });
296
+ killMergeAgentV2(baseSessionName);
297
+ killedBaseSessions.add(baseSessionName);
298
+ }
299
+
300
+ results.push({
301
+ sessionName: name,
302
+ killed: true,
303
+ error: null,
304
+ });
305
+ }
306
+
307
+ return results;
308
+ }
309
+
310
+ /**
311
+ * Execute a full abort operation.
312
+ *
313
+ * Phase/state transition ordering:
314
+ * 1. Set phase to "stopped"
315
+ * 2. Persist runtime state (so state file reflects stopped phase)
316
+ * 3. Select target sessions
317
+ * 4. Execute mode-specific flow (graceful or hard)
318
+ * 5. Delete batch state file
319
+ * 6. Return AbortResult
320
+ *
321
+ * Non-goal: does NOT delete worktrees/branches (preserved for inspection).
322
+ *
323
+ * @param mode - Abort mode (graceful or hard)
324
+ * @param prefix - orchestrator session prefix (e.g., "orch")
325
+ * @param repoRoot - Repository root path
326
+ * @param batchState - Current batch runtime state (mutated: phase set to stopped)
327
+ * @param persistedState - Loaded persisted state (for session enrichment)
328
+ * @param gracePeriodMs - Grace period for graceful abort (default 60000)
329
+ * @param pollIntervalMs - Poll interval for graceful abort (default 2000)
330
+ * @returns AbortResult with per-lane details
331
+ */
332
+ export async function executeAbort(
333
+ mode: AbortMode,
334
+ prefix: string,
335
+ repoRoot: string,
336
+ batchState: OrchBatchRuntimeState,
337
+ persistedState: PersistedBatchState | null,
338
+ gracePeriodMs: number = 60_000,
339
+ pollIntervalMs: number = 2_000,
340
+ ): Promise<AbortResult> {
341
+ const startTime = Date.now();
342
+ const errors: Array<{ code: AbortErrorCode; message: string }> = [];
343
+
344
+ // Step 1: Set phase to stopped
345
+ batchState.phase = "stopped";
346
+ batchState.endedAt = Date.now();
347
+
348
+ // Step 2: Persist state (best-effort — abort must continue even if persist fails)
349
+ try {
350
+ persistRuntimeState(
351
+ `abort-${mode}`,
352
+ batchState,
353
+ [], // wavePlan not needed for abort persistence
354
+ batchState.currentLanes,
355
+ [], // taskOutcomes not needed
356
+ null, // discovery not needed
357
+ repoRoot,
358
+ );
359
+ } catch (err) {
360
+ execLog("abort", batchState.batchId, `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`);
361
+ }
362
+
363
+ // TP-108: Kill all V2 merge agents (process-owned, not TMUX)
364
+ // This catches V2 merge agents that have no TMUX session.
365
+ const v2MergeKilled = killAllMergeAgentsV2();
366
+ if (v2MergeKilled > 0) {
367
+ execLog("abort", batchState.batchId, `killed ${v2MergeKilled} V2 merge agent(s)`);
368
+ }
369
+
370
+ // Step 3: Discover target sessions from Runtime V2 state sources.
371
+ const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes);
372
+ if (allSessionNames.length === 0) {
373
+ execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`);
374
+ }
375
+
376
+ // Step 4: Select and enrich target sessions
377
+ const targets = selectAbortTargetSessions(
378
+ allSessionNames,
379
+ persistedState,
380
+ batchState.currentLanes,
381
+ repoRoot,
382
+ prefix,
383
+ );
384
+
385
+ const laneResults: AbortLaneResult[] = [];
386
+ let gracefulExits = 0;
387
+ let wrapUpFailures = 0;
388
+
389
+ if (mode === "graceful") {
390
+ // Step 5a: Write wrap-up files
391
+ const wrapUpResults = writeWrapUpFiles(targets);
392
+ for (const wr of wrapUpResults) {
393
+ if (wr.error) wrapUpFailures++;
394
+ }
395
+ if (wrapUpFailures > 0) {
396
+ errors.push({
397
+ code: "ABORT_WRAPUP_WRITE_FAILED",
398
+ message: `Failed to write wrap-up files for ${wrapUpFailures} session(s)`,
399
+ });
400
+ }
401
+
402
+ // Step 5b: Wait for sessions to exit
403
+ const allTargetNames = targets.map(t => t.sessionName);
404
+ const waitResult = await waitForSessionExit(allTargetNames, gracePeriodMs, pollIntervalMs);
405
+ gracefulExits = waitResult.exited.length;
406
+
407
+ // Step 5c: Force-kill remaining sessions
408
+ const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
409
+ if (waitResult.remaining.length > 0) {
410
+ const killResults = killOrchSessions(waitResult.remaining, {
411
+ stateRoot: repoRoot,
412
+ batchId: batchState.batchId,
413
+ });
414
+ for (const kr of killResults) {
415
+ killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
416
+ }
417
+ const killFailures = killResults.filter(kr => !kr.killed);
418
+ if (killFailures.length > 0) {
419
+ errors.push({
420
+ code: "ABORT_KILL_FAILED",
421
+ message: `Failed to kill ${killFailures.length} session(s)`,
422
+ });
423
+ }
424
+ }
425
+
426
+ // Build lane results
427
+ const exitedSet = new Set(waitResult.exited);
428
+ for (const target of targets) {
429
+ const wrapUp = wrapUpResults.find(wr => wr.sessionName === target.sessionName);
430
+ const wasGraceful = exitedSet.has(target.sessionName);
431
+ const killResult = killResultBySession.get(target.sessionName);
432
+ const sessionKilled = wasGraceful || killResult?.killed === true;
433
+ laneResults.push({
434
+ sessionName: target.sessionName,
435
+ laneId: target.laneId,
436
+ taskId: target.taskId,
437
+ taskFolderInWorktree: target.taskFolderInWorktree,
438
+ wrapUpWritten: wrapUp?.written || false,
439
+ wrapUpError: wrapUp?.error || null,
440
+ sessionKilled,
441
+ exitedGracefully: wasGraceful,
442
+ });
443
+ }
444
+ } else {
445
+ // Hard mode: kill all immediately
446
+ const allTargetNames = targets.map(t => t.sessionName);
447
+ const killResults = killOrchSessions(allTargetNames, {
448
+ stateRoot: repoRoot,
449
+ batchId: batchState.batchId,
450
+ });
451
+ const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
452
+ for (const kr of killResults) {
453
+ killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
454
+ }
455
+ const killFailures = killResults.filter(kr => !kr.killed);
456
+ if (killFailures.length > 0) {
457
+ errors.push({
458
+ code: "ABORT_KILL_FAILED",
459
+ message: `Failed to kill ${killFailures.length} session(s)`,
460
+ });
461
+ }
462
+
463
+ for (const target of targets) {
464
+ const killResult = killResultBySession.get(target.sessionName);
465
+ laneResults.push({
466
+ sessionName: target.sessionName,
467
+ laneId: target.laneId,
468
+ taskId: target.taskId,
469
+ taskFolderInWorktree: target.taskFolderInWorktree,
470
+ wrapUpWritten: false,
471
+ wrapUpError: null,
472
+ sessionKilled: killResult?.killed === true,
473
+ exitedGracefully: false,
474
+ });
475
+ }
476
+ }
477
+
478
+ // Step 6: Delete batch state file
479
+ let stateDeleted = false;
480
+ try {
481
+ deleteBatchState(repoRoot);
482
+ stateDeleted = true;
483
+ } catch (err) {
484
+ errors.push({
485
+ code: "ABORT_STATE_DELETE_FAILED",
486
+ message: err instanceof Error ? err.message : String(err),
487
+ });
488
+ }
489
+
490
+ return {
491
+ mode,
492
+ sessionsFound: targets.length,
493
+ sessionsKilled: laneResults.filter(lr => lr.sessionKilled).length,
494
+ gracefulExits,
495
+ laneResults,
496
+ wrapUpFailures,
497
+ stateDeleted,
498
+ errors,
499
+ durationMs: Date.now() - startTime,
500
+ };
501
+ }
502
+