taskplane 0.29.2 → 0.30.1

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 (43) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/dashboard/public/app.js +124 -15
  5. package/dashboard/public/style.css +83 -2
  6. package/extensions/reviewer-extension.ts +17 -11
  7. package/extensions/taskplane/abort.ts +50 -18
  8. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  9. package/extensions/taskplane/agent-host.ts +224 -97
  10. package/extensions/taskplane/cleanup.ts +71 -42
  11. package/extensions/taskplane/config-loader.ts +142 -58
  12. package/extensions/taskplane/config-schema.ts +6 -13
  13. package/extensions/taskplane/config.ts +10 -2
  14. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  15. package/extensions/taskplane/diagnostics.ts +13 -13
  16. package/extensions/taskplane/discovery.ts +78 -63
  17. package/extensions/taskplane/engine-worker.ts +53 -46
  18. package/extensions/taskplane/engine.ts +1760 -602
  19. package/extensions/taskplane/execution.ts +469 -207
  20. package/extensions/taskplane/extension.ts +1073 -598
  21. package/extensions/taskplane/formatting.ts +136 -124
  22. package/extensions/taskplane/git.ts +0 -2
  23. package/extensions/taskplane/lane-runner.ts +652 -319
  24. package/extensions/taskplane/mailbox.ts +57 -49
  25. package/extensions/taskplane/merge.ts +662 -383
  26. package/extensions/taskplane/messages.ts +109 -51
  27. package/extensions/taskplane/migrations.ts +1 -1
  28. package/extensions/taskplane/path-resolver.ts +8 -9
  29. package/extensions/taskplane/persistence.ts +425 -262
  30. package/extensions/taskplane/process-registry.ts +36 -7
  31. package/extensions/taskplane/quality-gate.ts +107 -55
  32. package/extensions/taskplane/resume.ts +832 -280
  33. package/extensions/taskplane/sessions.ts +1 -1
  34. package/extensions/taskplane/settings-tui.ts +505 -164
  35. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  36. package/extensions/taskplane/supervisor.ts +477 -270
  37. package/extensions/taskplane/task-executor-core.ts +178 -53
  38. package/extensions/taskplane/types.ts +209 -108
  39. package/extensions/taskplane/verification.ts +27 -22
  40. package/extensions/taskplane/waves.ts +59 -43
  41. package/extensions/taskplane/workspace.ts +14 -12
  42. package/extensions/taskplane/worktree.ts +218 -196
  43. package/package.json +14 -2
@@ -2,16 +2,61 @@
2
2
  * Lane execution, monitoring, wave execution loop
3
3
  * @module orch/execution
4
4
  */
5
- import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
5
+ import {
6
+ readFileSync,
7
+ existsSync,
8
+ statSync,
9
+ unlinkSync,
10
+ mkdirSync,
11
+ writeFileSync,
12
+ copyFileSync,
13
+ } from "fs";
6
14
  import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises";
7
15
  import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter } from "path";
8
16
  import { userInfo } from "os";
9
17
 
10
- import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
11
- import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, RuntimeLaneSnapshot, SupervisorAlertCallback } from "./types.ts";
18
+ import {
19
+ DONE_GRACE_MS,
20
+ EXECUTION_POLL_INTERVAL_MS,
21
+ ExecutionError,
22
+ SESSION_SPAWN_RETRY_MAX,
23
+ } from "./types.ts";
24
+ import type {
25
+ AllocatedLane,
26
+ AllocatedTask,
27
+ DependencyGraph,
28
+ LaneExecutionResult,
29
+ LaneMonitorSnapshot,
30
+ LaneTaskOutcome,
31
+ LaneTaskStatus,
32
+ MonitorState,
33
+ MtimeTracker,
34
+ OrchestratorConfig,
35
+ ParsedTask,
36
+ TaskMonitorSnapshot,
37
+ WaveExecutionResult,
38
+ WorkspaceConfig,
39
+ ExecutionUnit,
40
+ PacketPaths,
41
+ RuntimeAgentId,
42
+ RuntimeAgentRole,
43
+ RuntimeLaneSnapshot,
44
+ RuntimeRegistry,
45
+ SupervisorAlertCallback,
46
+ } from "./types.ts";
12
47
  import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
13
48
  import type { TaskExitDiagnostic } from "./diagnostics.ts";
14
- import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot, writeLaneSnapshot } from "./process-registry.ts";
49
+ import {
50
+ readRegistrySnapshot,
51
+ readLaneSnapshot,
52
+ isTerminalStatus,
53
+ isProcessAlive,
54
+ detectOrphans,
55
+ markOrphansCrashed,
56
+ buildRegistrySnapshot,
57
+ writeRegistrySnapshot,
58
+ writeLaneSnapshot,
59
+ } from "./process-registry.ts";
15
60
  import { allocateLanes } from "./waves.ts";
16
61
  import { resolveOperatorId } from "./naming.ts";
17
62
  import { runGit, runGitWithEnv } from "./git.ts";
@@ -51,7 +96,15 @@ export function execLog(
51
96
  laneId: string,
52
97
  taskId: string,
53
98
  message: string,
54
- extra?: Record<string, string | number | boolean>,
99
+ // TP-195: widened from `Record<string, string|number|boolean>` to
100
+ // `Record<string, unknown>` so callers can pass structured values
101
+ // (string[] arrays, repo objects, etc.) without TS errors. Runtime
102
+ // stringification via `${v}` is unchanged: primitives render as today,
103
+ // arrays render with comma separators (existing behavior), objects
104
+ // render as `[object Object]` (already today's behavior — see
105
+ // historic execLog calls in engine.ts/resume.ts that have always been
106
+ // passing structured payloads). No runtime change.
107
+ extra?: Record<string, unknown>,
55
108
  ): void {
56
109
  const prefix = `[orch] ${laneId}/${taskId}`;
57
110
  if (extra) {
@@ -74,7 +127,11 @@ export function execLog(
74
127
  * @returns true if agent is alive
75
128
  * @since TP-112
76
129
  */
77
- export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend, laneNumber?: number): boolean {
130
+ export function isV2AgentAlive(
131
+ agentIdOrSessionName: string,
132
+ _runtimeBackend?: RuntimeBackend,
133
+ laneNumber?: number,
134
+ ): boolean {
78
135
  // Read the registry from the global state root.
79
136
  // Since this is a pure liveness check, we scan for matching agentId
80
137
  // patterns: direct match, or lane-session + "-worker" suffix.
@@ -85,15 +142,24 @@ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: R
85
142
  if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) return true;
86
143
  // Try worker suffix (monitor uses lane session name, registry uses agentId)
87
144
  const workerManifest = agents[`${agentIdOrSessionName}-worker`];
88
- if (workerManifest && !isTerminalStatus(workerManifest.status) && isProcessAlive(workerManifest.pid)) return true;
145
+ if (
146
+ workerManifest &&
147
+ !isTerminalStatus(workerManifest.status) &&
148
+ isProcessAlive(workerManifest.pid)
149
+ )
150
+ return true;
89
151
  // TP-148: In workspace mode, laneSessionId includes repoId and uses a local
90
152
  // lane number (e.g., "orch-henry-api-lane-1") while the V2 registry uses
91
153
  // global lane numbers without repoId (e.g., "orch-henry-lane-3-worker").
92
154
  // Fall back to scanning the registry by global lane number when provided.
93
155
  if (laneNumber != null) {
94
156
  for (const agent of Object.values(agents)) {
95
- if (agent.laneNumber === laneNumber && agent.role === "worker" &&
96
- !isTerminalStatus(agent.status) && isProcessAlive(agent.pid)) {
157
+ if (
158
+ agent.laneNumber === laneNumber &&
159
+ agent.role === "worker" &&
160
+ !isTerminalStatus(agent.status) &&
161
+ isProcessAlive(agent.pid)
162
+ ) {
97
163
  return true;
98
164
  }
99
165
  }
@@ -102,14 +168,14 @@ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: R
102
168
  }
103
169
 
104
170
  /** Cached registry for V2 liveness checks within a monitor cycle. @since TP-112 */
105
- let _v2LivenessRegistryCache: import("./process-registry.ts").RuntimeRegistry | null = null;
171
+ let _v2LivenessRegistryCache: RuntimeRegistry | null = null;
106
172
 
107
173
  /**
108
174
  * Set the V2 liveness registry cache for the current monitor cycle.
109
175
  * Called at the start of each monitor poll to avoid re-reading the file per-task.
110
176
  * @since TP-112
111
177
  */
112
- export function setV2LivenessRegistryCache(registry: import("./process-registry.ts").RuntimeRegistry | null): void {
178
+ export function setV2LivenessRegistryCache(registry: RuntimeRegistry | null): void {
113
179
  _v2LivenessRegistryCache = registry;
114
180
  }
115
181
 
@@ -125,11 +191,11 @@ export function killV2LaneAgents(
125
191
  sessionName: string,
126
192
  options?: { stateRoot?: string; batchId?: string; logContext?: string; laneNumber?: number },
127
193
  ): void {
128
- const registry = _v2LivenessRegistryCache ?? (
129
- options?.stateRoot && options?.batchId
194
+ const registry =
195
+ _v2LivenessRegistryCache ??
196
+ (options?.stateRoot && options?.batchId
130
197
  ? readRegistrySnapshot(options.stateRoot, options.batchId)
131
- : null
132
- );
198
+ : null);
133
199
  if (!registry) return;
134
200
 
135
201
  const agents = registry.agents;
@@ -138,25 +204,38 @@ export function killV2LaneAgents(
138
204
  for (const suffix of ["-worker", "-reviewer", ""]) {
139
205
  const key = `${sessionName}${suffix}`;
140
206
  const manifest = agents[key];
141
- if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid) && !killedPids.has(manifest.pid)) {
207
+ if (
208
+ manifest &&
209
+ !isTerminalStatus(manifest.status) &&
210
+ isProcessAlive(manifest.pid) &&
211
+ !killedPids.has(manifest.pid)
212
+ ) {
142
213
  try {
143
214
  process.kill(manifest.pid, "SIGTERM");
144
215
  killedPids.add(manifest.pid);
145
216
  execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`);
146
- } catch { /* already dead */ }
217
+ } catch {
218
+ /* already dead */
219
+ }
147
220
  }
148
221
  }
149
222
  // TP-148: Workspace-mode fallback — match by global lane number when
150
223
  // session name lookup misses (repoId/local-vs-global lane mismatch).
151
224
  if (options?.laneNumber != null) {
152
225
  for (const agent of Object.values(agents)) {
153
- if (agent.laneNumber === options.laneNumber &&
154
- !isTerminalStatus(agent.status) && isProcessAlive(agent.pid) && !killedPids.has(agent.pid)) {
226
+ if (
227
+ agent.laneNumber === options.laneNumber &&
228
+ !isTerminalStatus(agent.status) &&
229
+ isProcessAlive(agent.pid) &&
230
+ !killedPids.has(agent.pid)
231
+ ) {
155
232
  try {
156
233
  process.kill(agent.pid, "SIGTERM");
157
234
  killedPids.add(agent.pid);
158
235
  execLog(logContext, agent.agentId, `killed V2 agent by lane number (PID ${agent.pid})`);
159
- } catch { /* already dead */ }
236
+ } catch {
237
+ /* already dead */
238
+ }
160
239
  }
161
240
  }
162
241
  }
@@ -164,7 +243,6 @@ export function killV2LaneAgents(
164
243
 
165
244
  // ── Async File/Status Helpers (TP-070) ───────────────────────────────
166
245
 
167
-
168
246
  /**
169
247
  * Async version of readTaskStatusTail — reads STATUS.md tail without
170
248
  * blocking the event loop.
@@ -215,10 +293,7 @@ function laneSessionIdOf(lane: Pick<AllocatedLane, "laneSessionId">): string {
215
293
  * Logs are written under the lane worktree to keep per-lane execution
216
294
  * artifacts colocated with task state and available after failures.
217
295
  */
218
- export function resolveLaneLogPath(
219
- lane: AllocatedLane,
220
- task: AllocatedTask,
221
- ): string {
296
+ export function resolveLaneLogPath(lane: AllocatedLane, task: AllocatedTask): string {
222
297
  return join(lane.worktreePath, ".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`);
223
298
  }
224
299
 
@@ -227,10 +302,7 @@ export function resolveLaneLogPath(
227
302
  *
228
303
  * Relative paths avoid Windows drive-letter parsing issues in shell redirection.
229
304
  */
230
- export function resolveLaneLogRelativePath(
231
- lane: AllocatedLane,
232
- task: AllocatedTask,
233
- ): string {
305
+ export function resolveLaneLogRelativePath(lane: AllocatedLane, task: AllocatedTask): string {
234
306
  return join(".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`).replace(/\\/g, "/");
235
307
  }
236
308
 
@@ -449,7 +521,6 @@ export function resolveTaskDonePath(
449
521
  return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode).donePath;
450
522
  }
451
523
 
452
-
453
524
  /*
454
525
  * Removed in TP-120 while decommissioning the legacy session backend.
455
526
  *
@@ -466,7 +537,11 @@ export async function pollUntilTaskComplete(
466
537
  _pauseSignal: { paused: boolean },
467
538
  _isWorkspaceMode?: boolean,
468
539
  ): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
469
- return { status: "failed", exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner", doneFileFound: false };
540
+ return {
541
+ status: "failed",
542
+ exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner",
543
+ doneFileFound: false,
544
+ };
470
545
  }
471
546
 
472
547
  // ── Post-Task Commit ─────────────────────────────────────────────────
@@ -485,11 +560,7 @@ export async function pollUntilTaskComplete(
485
560
  * @param task - The task that just completed
486
561
  * @param laneId - Lane identifier for logging
487
562
  */
488
- function commitTaskArtifacts(
489
- lane: AllocatedLane,
490
- task: AllocatedTask,
491
- laneId: string,
492
- ): void {
563
+ function commitTaskArtifacts(lane: AllocatedLane, task: AllocatedTask, laneId: string): void {
493
564
  const worktreePath = lane.worktreePath;
494
565
 
495
566
  // Check if there are any uncommitted changes in the worktree
@@ -502,7 +573,11 @@ function commitTaskArtifacts(
502
573
  // Stage all changes in the worktree
503
574
  const addResult = runGit(["add", "-A"], worktreePath);
504
575
  if (!addResult.ok) {
505
- execLog(laneId, task.taskId, `post-task stage failed (non-fatal): ${addResult.stderr.slice(0, 200)}`);
576
+ execLog(
577
+ laneId,
578
+ task.taskId,
579
+ `post-task stage failed (non-fatal): ${addResult.stderr.slice(0, 200)}`,
580
+ );
506
581
  return;
507
582
  }
508
583
 
@@ -514,7 +589,11 @@ function commitTaskArtifacts(
514
589
  if (!commitResult.ok) {
515
590
  // "nothing to commit" is not an error — worker may have already committed
516
591
  if (!commitResult.stderr.includes("nothing to commit")) {
517
- execLog(laneId, task.taskId, `post-task commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`);
592
+ execLog(
593
+ laneId,
594
+ task.taskId,
595
+ `post-task commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`,
596
+ );
518
597
  }
519
598
  return;
520
599
  }
@@ -524,9 +603,6 @@ function commitTaskArtifacts(
524
603
  });
525
604
  }
526
605
 
527
-
528
-
529
-
530
606
  // ── STATUS.md Parsing for Worktree ───────────────────────────────────
531
607
 
532
608
  /**
@@ -583,7 +659,10 @@ export function parseWorktreeStatusMd(
583
659
  content = readFileSync(statusPath, "utf-8");
584
660
  mtime = statSync(statusPath).mtimeMs;
585
661
  } catch (err: unknown) {
586
- return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` };
662
+ return {
663
+ parsed: null,
664
+ error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}`,
665
+ };
587
666
  }
588
667
 
589
668
  // Parse using same regex patterns as task-runner's parseStatusMd
@@ -607,7 +686,7 @@ export function parseWorktreeStatusMd(
607
686
  const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
608
687
  if (stepMatch) {
609
688
  if (currentStep) {
610
- const totalChecked = currentStep.checkboxes.filter(c => c).length;
689
+ const totalChecked = currentStep.checkboxes.filter((c) => c).length;
611
690
  steps.push({
612
691
  number: currentStep.number,
613
692
  name: currentStep.name,
@@ -641,7 +720,7 @@ export function parseWorktreeStatusMd(
641
720
  }
642
721
  }
643
722
  if (currentStep) {
644
- const totalChecked = currentStep.checkboxes.filter(c => c).length;
723
+ const totalChecked = currentStep.checkboxes.filter((c) => c).length;
645
724
  steps.push({
646
725
  number: currentStep.number,
647
726
  name: currentStep.name,
@@ -708,7 +787,10 @@ async function parseStatusMdContent(
708
787
  content = await fsReadFile(statusPath, "utf-8");
709
788
  mtime = (await fsStat(statusPath)).mtimeMs;
710
789
  } catch (err: unknown) {
711
- return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` };
790
+ return {
791
+ parsed: null,
792
+ error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}`,
793
+ };
712
794
  }
713
795
 
714
796
  // Parse logic is identical to the sync version
@@ -732,7 +814,7 @@ async function parseStatusMdContent(
732
814
  const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
733
815
  if (stepMatch) {
734
816
  if (currentStep) {
735
- const totalChecked = currentStep.checkboxes.filter(c => c).length;
817
+ const totalChecked = currentStep.checkboxes.filter((c) => c).length;
736
818
  steps.push({
737
819
  number: currentStep.number,
738
820
  name: currentStep.name,
@@ -766,7 +848,7 @@ async function parseStatusMdContent(
766
848
  }
767
849
  }
768
850
  if (currentStep) {
769
- const totalChecked = currentStep.checkboxes.filter(c => c).length;
851
+ const totalChecked = currentStep.checkboxes.filter((c) => c).length;
770
852
  steps.push({
771
853
  number: currentStep.number,
772
854
  name: currentStep.name,
@@ -782,7 +864,6 @@ async function parseStatusMdContent(
782
864
  };
783
865
  }
784
866
 
785
-
786
867
  // ── State Resolution ─────────────────────────────────────────────────
787
868
 
788
869
  /**
@@ -804,6 +885,13 @@ async function parseStatusMdContent(
804
885
  * @param tracker - Mtime tracker for stall detection
805
886
  * @param stallTimeoutMs - Stall timeout in milliseconds
806
887
  * @param now - Current timestamp (epoch ms) for deterministic testing
888
+ * @param multiSegmentContext - Optional segment-authority context (TP-196 / #462).
889
+ * When provided AND `isFinalSegment === false`,
890
+ * `.DONE` is treated as a non-authoritative signal
891
+ * (Priority 1 is skipped). This guards against a
892
+ * stale or premature `.DONE` from a non-final
893
+ * segment short-circuiting the task to succeeded
894
+ * before the remaining segments have run.
807
895
  */
808
896
  export async function resolveTaskMonitorState(
809
897
  taskId: string,
@@ -815,6 +903,7 @@ export async function resolveTaskMonitorState(
815
903
  now: number,
816
904
  runtimeBackend?: RuntimeBackend,
817
905
  v2Context?: { stateRoot: string; batchId: string; laneNumber: number },
906
+ multiSegmentContext?: { isFinalSegment: boolean; segmentId: string },
818
907
  ): Promise<TaskMonitorSnapshot> {
819
908
  // TP-115/TP-127: Backend-aware liveness check.
820
909
  // V2: read the lane snapshot file written by lane-runner every second.
@@ -829,7 +918,7 @@ export async function resolveTaskMonitorState(
829
918
  // Snapshot not written yet OR snapshot still points to a prior task.
830
919
  // Assume alive initially, but if stale for >30s consult the registry
831
920
  // to avoid indefinite false "running" if the lane-runner died.
832
- const staleMs = snap?.updatedAt ? (now - snap.updatedAt) : 0;
921
+ const staleMs = snap?.updatedAt ? now - snap.updatedAt : 0;
833
922
  const trackerAgeMs = now - tracker.firstObservedAt;
834
923
  if (staleMs > 30_000) {
835
924
  // Snapshot hasn't been updated for 30s+ — check registry as fallback.
@@ -875,7 +964,7 @@ export async function resolveTaskMonitorState(
875
964
  const trackerAgeMs = now - tracker.firstObservedAt;
876
965
  if (
877
966
  snap.updatedAt &&
878
- (now - snap.updatedAt) > stallTimeoutMs / 2 &&
967
+ now - snap.updatedAt > stallTimeoutMs / 2 &&
879
968
  trackerAgeMs >= 60_000 &&
880
969
  !isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber)
881
970
  ) {
@@ -918,13 +1007,13 @@ export async function resolveTaskMonitorState(
918
1007
  }
919
1008
 
920
1009
  // Find the current step (first in-progress, or first not-started after last complete)
921
- const inProgress = steps.find(s => s.status === "in-progress");
1010
+ const inProgress = steps.find((s) => s.status === "in-progress");
922
1011
  if (inProgress) {
923
1012
  currentStepName = inProgress.name;
924
1013
  currentStepNumber = inProgress.number;
925
1014
  } else {
926
1015
  // Find first not-started step
927
- const notStarted = steps.find(s => s.status === "not-started");
1016
+ const notStarted = steps.find((s) => s.status === "not-started");
928
1017
  if (notStarted) {
929
1018
  currentStepName = notStarted.name;
930
1019
  currentStepNumber = notStarted.number;
@@ -954,7 +1043,27 @@ export async function resolveTaskMonitorState(
954
1043
  }
955
1044
 
956
1045
  // ── Priority 1: .DONE file found → succeeded ────────────────
957
- if (doneFileFound) {
1046
+ // TP-196 / #462: Monitor guard for multi-segment tasks. When the caller
1047
+ // has provided a segment-authority context AND tells us the active segment
1048
+ // is NOT the final segment in the task plan, `.DONE` MUST NOT be accepted
1049
+ // as authoritative — a non-final segment's worker should never have
1050
+ // produced one. We log a WARN and fall through to the lower priorities
1051
+ // (which keep the task in a non-terminal state so the engine can recover).
1052
+ const doneAcceptedAsAuthority =
1053
+ doneFileFound && !(multiSegmentContext && multiSegmentContext.isFinalSegment === false);
1054
+ if (doneFileFound && !doneAcceptedAsAuthority) {
1055
+ execLog(
1056
+ "monitor",
1057
+ taskId,
1058
+ `WARN: .DONE present for non-final segment '${multiSegmentContext?.segmentId}' — ignoring (#462 guard)`,
1059
+ {
1060
+ session: sessionName,
1061
+ segmentId: multiSegmentContext?.segmentId,
1062
+ donePath,
1063
+ },
1064
+ );
1065
+ }
1066
+ if (doneAcceptedAsAuthority) {
958
1067
  return {
959
1068
  taskId,
960
1069
  status: "succeeded",
@@ -979,7 +1088,7 @@ export async function resolveTaskMonitorState(
979
1088
  sessionAlive &&
980
1089
  tracker.statusFileSeenOnce &&
981
1090
  tracker.stallTimerStart !== null &&
982
- (now - tracker.stallTimerStart) >= stallTimeoutMs
1091
+ now - tracker.stallTimerStart >= stallTimeoutMs
983
1092
  ) {
984
1093
  const stallMinutes = Math.round((now - tracker.stallTimerStart) / 60_000);
985
1094
  const stallReason = `STATUS.md unchanged for ${stallMinutes} minutes (threshold: ${Math.round(stallTimeoutMs / 60_000)} min)`;
@@ -1052,7 +1161,6 @@ export async function resolveTaskMonitorState(
1052
1161
  };
1053
1162
  }
1054
1163
 
1055
-
1056
1164
  // ── Core Monitor Loop ────────────────────────────────────────────────
1057
1165
 
1058
1166
  /**
@@ -1136,10 +1244,15 @@ export async function monitorLanes(
1136
1244
  // Build the total task count
1137
1245
  const tasksTotal = lanes.reduce((sum, lane) => sum + lane.tasks.length, 0);
1138
1246
 
1139
- execLog("monitor", "ALL", `starting monitoring for ${lanes.length} lane(s), ${tasksTotal} task(s)`, {
1140
- pollIntervalMs,
1141
- stallTimeoutMin: Math.round(stallTimeoutMs / 60_000),
1142
- });
1247
+ execLog(
1248
+ "monitor",
1249
+ "ALL",
1250
+ `starting monitoring for ${lanes.length} lane(s), ${tasksTotal} task(s)`,
1251
+ {
1252
+ pollIntervalMs,
1253
+ stallTimeoutMin: Math.round(stallTimeoutMs / 60_000),
1254
+ },
1255
+ );
1143
1256
 
1144
1257
  while (true) {
1145
1258
  const now = Date.now();
@@ -1230,6 +1343,19 @@ export async function monitorLanes(
1230
1343
  const statusPath = unit.packet.statusPath;
1231
1344
  const statusResult = await parseStatusMdAtPath(statusPath);
1232
1345
 
1346
+ // TP-196 / #462: Build multi-segment authority context so
1347
+ // `.DONE` from a non-final segment is not accepted as terminal.
1348
+ const taskSegmentIds = task.task.segmentIds ?? [];
1349
+ const taskActiveSegmentId = task.task.activeSegmentId ?? null;
1350
+ let multiSegmentContext: { isFinalSegment: boolean; segmentId: string } | undefined;
1351
+ if (taskSegmentIds.length > 1 && taskActiveSegmentId) {
1352
+ const finalSegmentId = taskSegmentIds[taskSegmentIds.length - 1];
1353
+ multiSegmentContext = {
1354
+ isFinalSegment: taskActiveSegmentId === finalSegmentId,
1355
+ segmentId: taskActiveSegmentId,
1356
+ };
1357
+ }
1358
+
1233
1359
  const snapshot = await resolveTaskMonitorState(
1234
1360
  task.taskId,
1235
1361
  donePath,
@@ -1239,17 +1365,24 @@ export async function monitorLanes(
1239
1365
  stallTimeoutMs,
1240
1366
  now,
1241
1367
  runtimeBackend,
1242
- (runtimeBackend === "v2" && batchId) ? {
1243
- stateRoot: stateRootForRegistry ?? repoRoot,
1244
- batchId,
1245
- laneNumber: lane.laneNumber,
1246
- } : undefined,
1368
+ runtimeBackend === "v2" && batchId
1369
+ ? {
1370
+ stateRoot: stateRootForRegistry ?? repoRoot,
1371
+ batchId,
1372
+ laneNumber: lane.laneNumber,
1373
+ }
1374
+ : undefined,
1375
+ multiSegmentContext,
1247
1376
  );
1248
1377
 
1249
1378
  currentTaskSnapshot = snapshot;
1250
1379
 
1251
1380
  // Check if this task just became terminal
1252
- if (snapshot.status === "succeeded" || snapshot.status === "failed" || snapshot.status === "stalled") {
1381
+ if (
1382
+ snapshot.status === "succeeded" ||
1383
+ snapshot.status === "failed" ||
1384
+ snapshot.status === "stalled"
1385
+ ) {
1253
1386
  terminalTasks.set(task.taskId, snapshot);
1254
1387
  if (snapshot.status === "succeeded") {
1255
1388
  completedTasks.push(task.taskId);
@@ -1322,8 +1455,12 @@ export async function monitorLanes(
1322
1455
  // Log summary only on state changes (lane completes or fails) — not every poll
1323
1456
  const currentStateKey = `${totalDone}/${totalFailed}`;
1324
1457
  if (currentStateKey !== lastMonitorStateKey) {
1325
- const activeLanes = laneSnapshots.filter(l => l.currentTaskId !== null);
1326
- execLog("monitor", "ALL", `poll #${pollCount}: ${totalDone}/${tasksTotal} done, ${totalFailed} failed, ${activeLanes.length} active lane(s)`);
1458
+ const activeLanes = laneSnapshots.filter((l) => l.currentTaskId !== null);
1459
+ execLog(
1460
+ "monitor",
1461
+ "ALL",
1462
+ `poll #${pollCount}: ${totalDone}/${tasksTotal} done, ${totalFailed} failed, ${activeLanes.length} active lane(s)`,
1463
+ );
1327
1464
  lastMonitorStateKey = currentStateKey;
1328
1465
  }
1329
1466
 
@@ -1340,12 +1477,12 @@ export async function monitorLanes(
1340
1477
  }
1341
1478
 
1342
1479
  // Wait for next poll cycle
1343
- await new Promise(r => setTimeout(r, pollIntervalMs));
1480
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1344
1481
  }
1345
1482
 
1346
1483
  // Reached here due to pause signal — return current state
1347
1484
  const now = Date.now();
1348
- const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({
1485
+ const laneSnapshots: LaneMonitorSnapshot[] = lanes.map((lane) => ({
1349
1486
  laneId: lane.laneId,
1350
1487
  laneNumber: lane.laneNumber,
1351
1488
  sessionName: laneSessionIdOf(lane),
@@ -1354,7 +1491,7 @@ export async function monitorLanes(
1354
1491
  currentTaskSnapshot: null,
1355
1492
  completedTasks: [],
1356
1493
  failedTasks: [],
1357
- remainingTasks: lane.tasks.map(t => t.taskId),
1494
+ remainingTasks: lane.tasks.map((t) => t.taskId),
1358
1495
  }));
1359
1496
 
1360
1497
  setV2LivenessRegistryCache(null);
@@ -1370,7 +1507,6 @@ export async function monitorLanes(
1370
1507
  };
1371
1508
  }
1372
1509
 
1373
-
1374
1510
  // ── Transitive Dependent Computation ─────────────────────────────────
1375
1511
 
1376
1512
  /**
@@ -1414,7 +1550,6 @@ export function computeTransitiveDependents(
1414
1550
  return blocked;
1415
1551
  }
1416
1552
 
1417
-
1418
1553
  // ── Pre-flight: Commit Untracked Task Files ─────────────────────────
1419
1554
 
1420
1555
  /**
@@ -1499,29 +1634,31 @@ export function ensureTaskFilesCommitted(
1499
1634
 
1500
1635
  try {
1501
1636
  // Read orch branch tree into temporary index
1502
- const readTreeRes = runGitWithEnv(
1503
- ["read-tree", orchTip],
1504
- repoRoot,
1505
- { GIT_INDEX_FILE: tmpIdx },
1506
- );
1637
+ const readTreeRes = runGitWithEnv(["read-tree", orchTip], repoRoot, { GIT_INDEX_FILE: tmpIdx });
1507
1638
  if (!readTreeRes.ok) {
1508
- execLog("wave", `W${waveIndex}`, `orch branch staging: read-tree failed, falling back to HEAD commit`, {
1509
- error: readTreeRes.stderr,
1510
- });
1639
+ execLog(
1640
+ "wave",
1641
+ `W${waveIndex}`,
1642
+ `orch branch staging: read-tree failed, falling back to HEAD commit`,
1643
+ {
1644
+ error: readTreeRes.stderr,
1645
+ },
1646
+ );
1511
1647
  // Fall through to legacy path
1512
1648
  } else {
1513
1649
  // Add task files to temporary index
1514
1650
  let addFailed = false;
1515
1651
  for (const folder of foldersToStage) {
1516
- const addRes = runGitWithEnv(
1517
- ["add", "--", folder],
1518
- repoRoot,
1519
- { GIT_INDEX_FILE: tmpIdx },
1520
- );
1652
+ const addRes = runGitWithEnv(["add", "--", folder], repoRoot, { GIT_INDEX_FILE: tmpIdx });
1521
1653
  if (!addRes.ok) {
1522
- execLog("wave", `W${waveIndex}`, `orch branch staging: git add failed for ${folder}, falling back`, {
1523
- error: addRes.stderr,
1524
- });
1654
+ execLog(
1655
+ "wave",
1656
+ `W${waveIndex}`,
1657
+ `orch branch staging: git add failed for ${folder}, falling back`,
1658
+ {
1659
+ error: addRes.stderr,
1660
+ },
1661
+ );
1525
1662
  addFailed = true;
1526
1663
  break;
1527
1664
  }
@@ -1529,15 +1666,11 @@ export function ensureTaskFilesCommitted(
1529
1666
 
1530
1667
  if (!addFailed) {
1531
1668
  // Write tree from temporary index
1532
- const writeTreeRes = runGitWithEnv(
1533
- ["write-tree"],
1534
- repoRoot,
1535
- { GIT_INDEX_FILE: tmpIdx },
1536
- );
1669
+ const writeTreeRes = runGitWithEnv(["write-tree"], repoRoot, { GIT_INDEX_FILE: tmpIdx });
1537
1670
 
1538
1671
  if (writeTreeRes.ok) {
1539
1672
  const tree = writeTreeRes.stdout.trim();
1540
- const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", ");
1673
+ const taskIds = foldersToStage.map((f) => f.split("/").pop() || f).join(", ");
1541
1674
  const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`;
1542
1675
 
1543
1676
  // Create commit directly on orch branch
@@ -1554,14 +1687,23 @@ export function ensureTaskFilesCommitted(
1554
1687
  );
1555
1688
 
1556
1689
  if (refUpdateRes.ok) {
1557
- execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) directly on orch branch`, {
1558
- orchBranch,
1559
- folders: foldersToStage,
1560
- from: orchTip.slice(0, 8),
1561
- to: newCommit.slice(0, 8),
1562
- });
1690
+ execLog(
1691
+ "wave",
1692
+ `W${waveIndex}`,
1693
+ `committed ${foldersToStage.length} task folder(s) directly on orch branch`,
1694
+ {
1695
+ orchBranch,
1696
+ folders: foldersToStage,
1697
+ from: orchTip.slice(0, 8),
1698
+ to: newCommit.slice(0, 8),
1699
+ },
1700
+ );
1563
1701
  // Clean up temp index and return — no need for legacy path
1564
- try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1702
+ try {
1703
+ unlinkSync(tmpIdx);
1704
+ } catch {
1705
+ /* best effort */
1706
+ }
1565
1707
  return;
1566
1708
  }
1567
1709
  execLog("wave", `W${waveIndex}`, `orch branch staging: ref update failed, falling back`, {
@@ -1580,12 +1722,21 @@ export function ensureTaskFilesCommitted(
1580
1722
  }
1581
1723
  }
1582
1724
  } catch (err: unknown) {
1583
- execLog("wave", `W${waveIndex}`, `orch branch staging: unexpected error, falling back to HEAD commit`, {
1584
- error: err instanceof Error ? err.message : String(err),
1585
- });
1725
+ execLog(
1726
+ "wave",
1727
+ `W${waveIndex}`,
1728
+ `orch branch staging: unexpected error, falling back to HEAD commit`,
1729
+ {
1730
+ error: err instanceof Error ? err.message : String(err),
1731
+ },
1732
+ );
1586
1733
  } finally {
1587
1734
  // Always clean up temp index
1588
- try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1735
+ try {
1736
+ unlinkSync(tmpIdx);
1737
+ } catch {
1738
+ /* best effort */
1739
+ }
1589
1740
  }
1590
1741
  }
1591
1742
  }
@@ -1609,7 +1760,7 @@ export function ensureTaskFilesCommitted(
1609
1760
  }
1610
1761
 
1611
1762
  // Commit
1612
- const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", ");
1763
+ const taskIds = foldersToStage.map((f) => f.split("/").pop() || f).join(", ");
1613
1764
  const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`;
1614
1765
  const commitResult = runGit(["commit", "-m", commitMsg], repoRoot);
1615
1766
  if (!commitResult.ok) {
@@ -1622,10 +1773,15 @@ export function ensureTaskFilesCommitted(
1622
1773
  );
1623
1774
  }
1624
1775
 
1625
- execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) to ensure worktree visibility`, {
1626
- folders: foldersToStage,
1627
- commit: commitResult.stdout.trim().split("\n")[0],
1628
- });
1776
+ execLog(
1777
+ "wave",
1778
+ `W${waveIndex}`,
1779
+ `committed ${foldersToStage.length} task folder(s) to ensure worktree visibility`,
1780
+ {
1781
+ folders: foldersToStage,
1782
+ commit: commitResult.stdout.trim().split("\n")[0],
1783
+ },
1784
+ );
1629
1785
 
1630
1786
  // Fast-forward (or merge) the orch branch to include the staging commit so
1631
1787
  // that worktrees—which branch from orchBranch—see the new task files and
@@ -1639,10 +1795,7 @@ export function ensureTaskFilesCommitted(
1639
1795
  const newHead = headRes.stdout.trim();
1640
1796
  const orchTip = orchTipRes.stdout.trim();
1641
1797
 
1642
- const ancestorCheck = runGit(
1643
- ["merge-base", "--is-ancestor", orchTip, newHead],
1644
- repoRoot,
1645
- );
1798
+ const ancestorCheck = runGit(["merge-base", "--is-ancestor", orchTip, newHead], repoRoot);
1646
1799
 
1647
1800
  if (ancestorCheck.ok) {
1648
1801
  const ffResult = runGit(
@@ -1662,10 +1815,7 @@ export function ensureTaskFilesCommitted(
1662
1815
  });
1663
1816
  }
1664
1817
  } else {
1665
- const mergeTreeRes = runGit(
1666
- ["merge-tree", "--write-tree", orchTip, newHead],
1667
- repoRoot,
1668
- );
1818
+ const mergeTreeRes = runGit(["merge-tree", "--write-tree", orchTip, newHead], repoRoot);
1669
1819
  if (mergeTreeRes.ok) {
1670
1820
  const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0];
1671
1821
  if (/^[0-9a-f]{40}$/i.test(mergedTree)) {
@@ -1692,10 +1842,15 @@ export function ensureTaskFilesCommitted(
1692
1842
  }
1693
1843
  }
1694
1844
  } catch (refErr: unknown) {
1695
- execLog("wave", `W${waveIndex}`, `warning: orch branch ref update threw unexpectedly (non-fatal)`, {
1696
- orchBranch,
1697
- error: refErr instanceof Error ? refErr.message : String(refErr),
1698
- });
1845
+ execLog(
1846
+ "wave",
1847
+ `W${waveIndex}`,
1848
+ `warning: orch branch ref update threw unexpectedly (non-fatal)`,
1849
+ {
1850
+ orchBranch,
1851
+ error: refErr instanceof Error ? refErr.message : String(refErr),
1852
+ },
1853
+ );
1699
1854
  }
1700
1855
  }
1701
1856
  }
@@ -1768,8 +1923,18 @@ export async function executeWave(
1768
1923
  runtimeBackend?: RuntimeBackend,
1769
1924
  onSupervisorAlert?: SupervisorAlertCallback,
1770
1925
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1771
- reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] },
1772
- workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
1926
+ reviewerConfig?: {
1927
+ model?: string;
1928
+ thinking?: string;
1929
+ tools?: string;
1930
+ excludeExtensions?: string[];
1931
+ },
1932
+ workerConfig?: {
1933
+ model?: string;
1934
+ thinking?: string;
1935
+ tools?: string;
1936
+ excludeExtensions?: string[];
1937
+ } | null,
1773
1938
  workerExcludeExtensions?: string[],
1774
1939
  onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
1775
1940
  onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
@@ -1814,7 +1979,15 @@ export async function executeWave(
1814
1979
  }
1815
1980
 
1816
1981
  // ── Stage 1: Allocate lanes ──────────────────────────────────
1817
- const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, orchBranch, workspaceConfig);
1982
+ const allocResult = allocateLanes(
1983
+ waveTasks,
1984
+ pending,
1985
+ config,
1986
+ repoRoot,
1987
+ batchId,
1988
+ orchBranch,
1989
+ workspaceConfig,
1990
+ );
1818
1991
 
1819
1992
  if (!allocResult.success) {
1820
1993
  const errMsg = allocResult.error?.message || "Unknown allocation failure";
@@ -1859,7 +2032,11 @@ export async function executeWave(
1859
2032
  const isWsMode = !!workspaceConfig;
1860
2033
  const backend: RuntimeBackend = "v2";
1861
2034
  if (runtimeBackend && runtimeBackend !== "v2") {
1862
- execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`);
2035
+ execLog(
2036
+ "wave",
2037
+ `W${waveIndex}`,
2038
+ `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`,
2039
+ );
1863
2040
  }
1864
2041
  execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
1865
2042
 
@@ -1870,19 +2047,39 @@ export async function executeWave(
1870
2047
  const snapshotStateRoot = resolveRuntimeStateRoot(repoRoot, wsRoot);
1871
2048
  for (const lane of lanes) {
1872
2049
  try {
1873
- const snapPath = join(snapshotStateRoot, ".pi", "runtime", batchId, "lanes", `lane-${lane.laneNumber}.json`);
2050
+ const snapPath = join(
2051
+ snapshotStateRoot,
2052
+ ".pi",
2053
+ "runtime",
2054
+ batchId,
2055
+ "lanes",
2056
+ `lane-${lane.laneNumber}.json`,
2057
+ );
1874
2058
  if (existsSync(snapPath)) unlinkSync(snapPath);
1875
- } catch { /* best effort */ }
2059
+ } catch {
2060
+ /* best effort */
2061
+ }
1876
2062
  }
1877
2063
 
1878
- const lanePromises = lanes.map(lane =>
1879
- executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
1880
- ORCH_BATCH_ID: batchId,
1881
- TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy,
1882
- ...buildWorkerEnv(workerConfig),
1883
- ...buildReviewerEnv(reviewerConfig),
1884
- ...buildWorkerExcludeEnv(workerExcludeExtensions),
1885
- }, onSupervisorAlert, onLaneTerminated, onLaneRespawned),
2064
+ const lanePromises = lanes.map((lane) =>
2065
+ executeLaneV2(
2066
+ lane,
2067
+ config,
2068
+ repoRoot,
2069
+ wavePauseSignal,
2070
+ wsRoot,
2071
+ isWsMode,
2072
+ {
2073
+ ORCH_BATCH_ID: batchId,
2074
+ TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy,
2075
+ ...buildWorkerEnv(workerConfig),
2076
+ ...buildReviewerEnv(reviewerConfig),
2077
+ ...buildWorkerExcludeEnv(workerExcludeExtensions),
2078
+ },
2079
+ onSupervisorAlert,
2080
+ onLaneTerminated,
2081
+ onLaneRespawned,
2082
+ ),
1886
2083
  );
1887
2084
 
1888
2085
  // Start monitoring as a sibling async loop
@@ -1929,7 +2126,7 @@ export async function executeWave(
1929
2126
  return {
1930
2127
  laneNumber: lanes[idx].laneNumber,
1931
2128
  laneId: lanes[idx].laneId,
1932
- tasks: lanes[idx].tasks.map(t => ({
2129
+ tasks: lanes[idx].tasks.map((t) => ({
1933
2130
  taskId: t.taskId,
1934
2131
  status: "failed" as LaneTaskStatus,
1935
2132
  startTime: null,
@@ -1947,8 +2144,8 @@ export async function executeWave(
1947
2144
 
1948
2145
  // For stop-wave: if any task failed, set pause to prevent next wave
1949
2146
  if (policy === "stop-wave") {
1950
- const hasFailure = laneResults.some(lr =>
1951
- lr.tasks.some(t => t.status === "failed" || t.status === "stalled"),
2147
+ const hasFailure = laneResults.some((lr) =>
2148
+ lr.tasks.some((t) => t.status === "failed" || t.status === "stalled"),
1952
2149
  );
1953
2150
  if (hasFailure) {
1954
2151
  wavePauseSignal.paused = true;
@@ -1991,21 +2188,24 @@ export async function executeWave(
1991
2188
  // Compute blocked tasks for future waves (skip-dependents policy)
1992
2189
  let blockedTaskIds: string[] = [];
1993
2190
  if (policy === "skip-dependents" && failedTaskIds.length > 0) {
1994
- const blocked = computeTransitiveDependents(
1995
- new Set(failedTaskIds),
1996
- dependencyGraph,
1997
- );
2191
+ const blocked = computeTransitiveDependents(new Set(failedTaskIds), dependencyGraph);
1998
2192
  blockedTaskIds = [...blocked].sort();
1999
2193
  if (blockedTaskIds.length > 0) {
2000
- execLog("wave", `W${waveIndex}`, `skip-dependents: ${blockedTaskIds.length} task(s) blocked for future waves`, {
2001
- blocked: blockedTaskIds.join(","),
2002
- });
2194
+ execLog(
2195
+ "wave",
2196
+ `W${waveIndex}`,
2197
+ `skip-dependents: ${blockedTaskIds.length} task(s) blocked for future waves`,
2198
+ {
2199
+ blocked: blockedTaskIds.join(","),
2200
+ },
2201
+ );
2003
2202
  }
2004
2203
  }
2005
2204
 
2006
2205
  // Determine overall wave status
2007
- const stoppedEarly = policy === "stop-all" && failedTaskIds.length > 0
2008
- || policy === "stop-wave" && failedTaskIds.length > 0;
2206
+ const stoppedEarly =
2207
+ (policy === "stop-all" && failedTaskIds.length > 0) ||
2208
+ (policy === "stop-wave" && failedTaskIds.length > 0);
2009
2209
 
2010
2210
  let overallStatus: WaveExecutionResult["overallStatus"];
2011
2211
  if (policy === "stop-all" && failedTaskIds.length > 0) {
@@ -2085,9 +2285,7 @@ export async function executeWithStopAll(
2085
2285
 
2086
2286
  // Check if any task failed
2087
2287
  if (!abortTriggered) {
2088
- const hasFailure = result.tasks.some(
2089
- t => t.status === "failed" || t.status === "stalled",
2090
- );
2288
+ const hasFailure = result.tasks.some((t) => t.status === "failed" || t.status === "stalled");
2091
2289
  if (hasFailure) {
2092
2290
  // First failure detected — trigger stop-all
2093
2291
  abortTriggered = true;
@@ -2095,7 +2293,7 @@ export async function executeWithStopAll(
2095
2293
 
2096
2294
  // Determine which task failed first for logging
2097
2295
  const firstFailed = result.tasks
2098
- .filter(t => t.status === "failed" || t.status === "stalled")
2296
+ .filter((t) => t.status === "failed" || t.status === "stalled")
2099
2297
  .sort((a, b) => {
2100
2298
  // Sort by startTime, then by taskId for deterministic tie-break
2101
2299
  const timeA = a.startTime || 0;
@@ -2104,9 +2302,14 @@ export async function executeWithStopAll(
2104
2302
  return a.taskId.localeCompare(b.taskId);
2105
2303
  })[0];
2106
2304
 
2107
- execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, {
2108
- session: laneSessionIdOf(lanes[idx]),
2109
- });
2305
+ execLog(
2306
+ "wave",
2307
+ `W${waveIndex}`,
2308
+ `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`,
2309
+ {
2310
+ session: laneSessionIdOf(lanes[idx]),
2311
+ },
2312
+ );
2110
2313
 
2111
2314
  // Kill ALL lane sessions immediately
2112
2315
  for (const lane of lanes) {
@@ -2122,7 +2325,11 @@ export async function executeWithStopAll(
2122
2325
  if (!abortTriggered) {
2123
2326
  abortTriggered = true;
2124
2327
  pauseSignal.paused = true;
2125
- execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
2328
+ execLog(
2329
+ "wave",
2330
+ `W${waveIndex}`,
2331
+ `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`,
2332
+ );
2126
2333
  for (const lane of lanes) {
2127
2334
  killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber });
2128
2335
  }
@@ -2132,7 +2339,7 @@ export async function executeWithStopAll(
2132
2339
  const failedResult: LaneExecutionResult = {
2133
2340
  laneNumber: lanes[idx].laneNumber,
2134
2341
  laneId: lanes[idx].laneId,
2135
- tasks: lanes[idx].tasks.map(t => ({
2342
+ tasks: lanes[idx].tasks.map((t) => ({
2136
2343
  taskId: t.taskId,
2137
2344
  status: "failed" as LaneTaskStatus,
2138
2345
  startTime: null,
@@ -2155,14 +2362,17 @@ export async function executeWithStopAll(
2155
2362
  await Promise.allSettled(wrappedPromises);
2156
2363
 
2157
2364
  // Fill in any null results (shouldn't happen, but defensive)
2158
- return results.map((r, idx) => r || {
2159
- laneNumber: lanes[idx].laneNumber,
2160
- laneId: lanes[idx].laneId,
2161
- tasks: [],
2162
- overallStatus: "failed" as const,
2163
- startTime: Date.now(),
2164
- endTime: Date.now(),
2165
- });
2365
+ return results.map(
2366
+ (r, idx) =>
2367
+ r || {
2368
+ laneNumber: lanes[idx].laneNumber,
2369
+ laneId: lanes[idx].laneId,
2370
+ tasks: [],
2371
+ overallStatus: "failed" as const,
2372
+ startTime: Date.now(),
2373
+ endTime: Date.now(),
2374
+ },
2375
+ );
2166
2376
  }
2167
2377
 
2168
2378
  // ── Runtime V2 Bridge Helpers (TP-102) ─────────────────────────────────────
@@ -2223,8 +2433,8 @@ export function buildExecutionUnit(
2223
2433
  throw new ExecutionError(
2224
2434
  "EXEC_MISSING_TASK_FOLDER",
2225
2435
  `Cannot build execution unit for task ${task.taskId}: taskFolder is ${taskFolder === "" ? "empty" : "undefined"}. ` +
2226
- `This typically means the task's persisted record was not enriched with discovery data. ` +
2227
- `Re-run discovery or check that the task exists in the task area.`,
2436
+ `This typically means the task's persisted record was not enriched with discovery data. ` +
2437
+ `Re-run discovery or check that the task exists in the task area.`,
2228
2438
  "execution",
2229
2439
  task.taskId,
2230
2440
  );
@@ -2248,18 +2458,17 @@ export function buildExecutionUnit(
2248
2458
  // the execution repo (cross-repo segment). When they're the same repo,
2249
2459
  // resolve packet paths inside the worktree so .DONE, STATUS.md etc. are
2250
2460
  // written to the worktree (not the original repo outside the worktree).
2251
- const useAbsolutePacketPath = task.task.packetTaskPath
2252
- && packetHomeRepoId !== executionRepoId;
2461
+ const useAbsolutePacketPath = task.task.packetTaskPath && packetHomeRepoId !== executionRepoId;
2253
2462
 
2254
2463
  const packet = useAbsolutePacketPath
2255
2464
  ? resolvePacketPaths(task.task.packetTaskPath!)
2256
2465
  : {
2257
- promptPath: resolved.taskFolderResolved + "/PROMPT.md",
2258
- statusPath: resolved.statusPath,
2259
- donePath: resolved.donePath,
2260
- reviewsDir: resolved.taskFolderResolved + "/.reviews",
2261
- taskFolder: resolved.taskFolderResolved,
2262
- };
2466
+ promptPath: resolved.taskFolderResolved + "/PROMPT.md",
2467
+ statusPath: resolved.statusPath,
2468
+ donePath: resolved.donePath,
2469
+ reviewsDir: resolved.taskFolderResolved + "/.reviews",
2470
+ taskFolder: resolved.taskFolderResolved,
2471
+ };
2263
2472
 
2264
2473
  return {
2265
2474
  id,
@@ -2339,7 +2548,9 @@ function parseAgentFile(filePath: string): { fm: Record<string, string>; body: s
2339
2548
  if (m) fm[m[1]] = m[2].trim();
2340
2549
  }
2341
2550
  return { fm, body: raw.slice(fmEnd + 3).trim() };
2342
- } catch { return null; }
2551
+ } catch {
2552
+ return null;
2553
+ }
2343
2554
  }
2344
2555
 
2345
2556
  /**
@@ -2357,7 +2568,9 @@ function loadBaseAgentPrompt(agentName: string): string {
2357
2568
  const def = parseAgentFile(resolved);
2358
2569
  if (def?.body) return def.body;
2359
2570
  }
2360
- } catch { /* fall through */ }
2571
+ } catch {
2572
+ /* fall through */
2573
+ }
2361
2574
  return "";
2362
2575
  }
2363
2576
 
@@ -2433,11 +2646,11 @@ function resolveAgentPointerRoot(): string | null {
2433
2646
  * @returns Composed agent definition, or null if no base and no local file found
2434
2647
  * @since TP-161
2435
2648
  */
2436
- export function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
2437
- const localPaths = [
2438
- join(cwd, ".pi", "agents", `${name}.md`),
2439
- join(cwd, "agents", `${name}.md`),
2440
- ];
2649
+ export function loadAgentDef(
2650
+ cwd: string,
2651
+ name: string,
2652
+ ): { systemPrompt: string; tools: string; model: string } | null {
2653
+ const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
2441
2654
 
2442
2655
  // In workspace mode, add pointer-resolved agent root as fallback
2443
2656
  const agentRoot = resolveAgentPointerRoot();
@@ -2452,7 +2665,9 @@ export function loadAgentDef(cwd: string, name: string): { systemPrompt: string;
2452
2665
  if (existsSync(basePath)) {
2453
2666
  baseDef = parseAgentFile(basePath);
2454
2667
  }
2455
- } catch { /* fall through */ }
2668
+ } catch {
2669
+ /* fall through */
2670
+ }
2456
2671
 
2457
2672
  // Load local override (first found wins)
2458
2673
  let localDef: { fm: Record<string, string>; body: string } | null = null;
@@ -2487,10 +2702,7 @@ export function loadAgentDef(cwd: string, name: string): { systemPrompt: string;
2487
2702
  return { systemPrompt: composedPrompt.trim(), tools, model };
2488
2703
  }
2489
2704
 
2490
- export function resolveRuntimeStateRoot(
2491
- repoRoot: string,
2492
- workspaceRoot?: string,
2493
- ): string {
2705
+ export function resolveRuntimeStateRoot(repoRoot: string, workspaceRoot?: string): string {
2494
2706
  return workspaceRoot ?? repoRoot;
2495
2707
  }
2496
2708
 
@@ -2532,13 +2744,21 @@ function parseJsonArrayEnv(value?: string): string[] {
2532
2744
  if (!value) return [];
2533
2745
  try {
2534
2746
  const parsed = JSON.parse(value);
2535
- if (Array.isArray(parsed)) return parsed.filter((v: unknown): v is string => typeof v === "string");
2536
- } catch { /* ignore malformed */ }
2747
+ if (Array.isArray(parsed))
2748
+ return parsed.filter((v: unknown): v is string => typeof v === "string");
2749
+ } catch {
2750
+ /* ignore malformed */
2751
+ }
2537
2752
  return [];
2538
2753
  }
2539
2754
 
2540
2755
  export function buildReviewerEnv(
2541
- reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
2756
+ reviewerConfig?: {
2757
+ model?: string;
2758
+ thinking?: string;
2759
+ tools?: string;
2760
+ excludeExtensions?: string[];
2761
+ } | null,
2542
2762
  ): Record<string, string> {
2543
2763
  const env: Record<string, string> = {};
2544
2764
  if (reviewerConfig?.model) env.TASKPLANE_REVIEWER_MODEL = reviewerConfig.model;
@@ -2560,7 +2780,12 @@ export function buildReviewerEnv(
2560
2780
  * @since TP-181
2561
2781
  */
2562
2782
  export function buildWorkerEnv(
2563
- workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
2783
+ workerConfig?: {
2784
+ model?: string;
2785
+ thinking?: string;
2786
+ tools?: string;
2787
+ excludeExtensions?: string[];
2788
+ } | null,
2564
2789
  ): Record<string, string> {
2565
2790
  const env: Record<string, string> = {};
2566
2791
  if (workerConfig?.model) env.TASKPLANE_WORKER_MODEL = workerConfig.model;
@@ -2620,7 +2845,8 @@ export async function executeLaneV2(
2620
2845
  // The base template (templates/agents/task-worker.md) contains critical behavioral
2621
2846
  // rules: checkpoint discipline, STATUS.md resume algorithm, review_step instructions.
2622
2847
  // The local file (.pi/agents/task-worker.md) adds project-specific guidance.
2623
- let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2848
+ let workerSystemPrompt =
2849
+ "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2624
2850
  let workerSegmentPrompt = "";
2625
2851
  try {
2626
2852
  const basePrompt = loadBaseAgentPrompt("task-worker");
@@ -2635,7 +2861,9 @@ export async function executeLaneV2(
2635
2861
  // Load segment-scoped prompt overlay (appended when isSegmentScoped)
2636
2862
  const segPrompt = loadBaseAgentPrompt("task-worker-segment");
2637
2863
  if (segPrompt) workerSegmentPrompt = segPrompt;
2638
- } catch { /* use default */ }
2864
+ } catch {
2865
+ /* use default */
2866
+ }
2639
2867
 
2640
2868
  execLog(laneId, "LANE", `starting Runtime V2 execution of ${lane.tasks.length} task(s)`, {
2641
2869
  worktree: lane.worktreePath,
@@ -2647,16 +2875,26 @@ export async function executeLaneV2(
2647
2875
  // this lane number is lifted before new alerts begin to flow.
2648
2876
  if (onLaneRespawned) {
2649
2877
  try {
2650
- onLaneRespawned(lane.laneNumber, buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"), batchId);
2878
+ onLaneRespawned(
2879
+ lane.laneNumber,
2880
+ buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
2881
+ batchId,
2882
+ );
2651
2883
  } catch (err) {
2652
- execLog(laneId, "LANE", `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`);
2884
+ execLog(
2885
+ laneId,
2886
+ "LANE",
2887
+ `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`,
2888
+ );
2653
2889
  }
2654
2890
  }
2655
2891
 
2656
2892
  for (const task of lane.tasks) {
2657
2893
  const taskSegmentId = task.task.activeSegmentId ?? null;
2658
2894
  if (shouldSkipRemaining || pauseSignal.paused) {
2659
- const reason = pauseSignal.paused ? "Skipped due to pause signal" : "Skipped due to prior task failure in lane";
2895
+ const reason = pauseSignal.paused
2896
+ ? "Skipped due to pause signal"
2897
+ : "Skipped due to prior task failure in lane";
2660
2898
  outcomes.push({
2661
2899
  taskId: task.taskId,
2662
2900
  status: "skipped",
@@ -2674,10 +2912,12 @@ export async function executeLaneV2(
2674
2912
  // Build execution unit
2675
2913
  const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode);
2676
2914
 
2677
- const rawAutonomy = String(extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY ?? "autonomous").toLowerCase();
2915
+ const rawAutonomy = String(
2916
+ extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY ?? "autonomous",
2917
+ ).toLowerCase();
2678
2918
  const supervisorAutonomy: LaneRunnerConfig["supervisorAutonomy"] =
2679
- (rawAutonomy === "interactive" || rawAutonomy === "supervised" || rawAutonomy === "autonomous")
2680
- ? rawAutonomy as LaneRunnerConfig["supervisorAutonomy"]
2919
+ rawAutonomy === "interactive" || rawAutonomy === "supervised" || rawAutonomy === "autonomous"
2920
+ ? (rawAutonomy as LaneRunnerConfig["supervisorAutonomy"])
2681
2921
  : "autonomous";
2682
2922
 
2683
2923
  const laneRunnerConfig: LaneRunnerConfig = {
@@ -2701,12 +2941,26 @@ export async function executeLaneV2(
2701
2941
  reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",
2702
2942
  // TP-180: Extension exclusion lists from config
2703
2943
  workerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS),
2704
- reviewerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS),
2944
+ reviewerExcludeExtensions: parseJsonArrayEnv(
2945
+ extraEnvVars?.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS,
2946
+ ),
2705
2947
  supervisorAutonomy,
2706
- projectName: config.project?.name || "project",
2948
+ // TP-195: replaced `config.project?.name` (no `project` field on
2949
+ // `OrchestratorConfig`; always undefined) with the env-var read
2950
+ // already used elsewhere in the codebase (lane-runner.ts:668 sets
2951
+ // `TASKPLANE_PROJECT_NAME` from the same source). When the env
2952
+ // var is unset, falls through to the same `"project"` literal as
2953
+ // before — behavior-neutral.
2954
+ projectName: extraEnvVars?.TASKPLANE_PROJECT_NAME || "project",
2707
2955
  maxIterations: 20,
2708
2956
  noProgressLimit: 3,
2709
- maxWorkerMinutes: config.failure?.maxWorkerMinutes || 120,
2957
+ // TP-195: read the canonical `max_worker_minutes` field (snake_case
2958
+ // per `OrchestratorConfig.failure` in types.ts). The previous code
2959
+ // read a non-existent `maxWorkerMinutes` camelCase alias — always
2960
+ // undefined — silently ignoring any operator-set value. Honoring
2961
+ // the config is the intended behavior; default of 120 preserved
2962
+ // when the field is unset.
2963
+ maxWorkerMinutes: config.failure?.max_worker_minutes || 120,
2710
2964
  warnPercent: 85,
2711
2965
  killPercent: 95,
2712
2966
  onSupervisorAlert,
@@ -2811,13 +3065,22 @@ export async function executeLaneV2(
2811
3065
  progress: null,
2812
3066
  updatedAt: Date.now(),
2813
3067
  };
2814
- writeLaneSnapshot(stateRoot, batchId, lane.laneNumber, spawnFailureSnapshot as unknown as Record<string, unknown>);
3068
+ writeLaneSnapshot(
3069
+ stateRoot,
3070
+ batchId,
3071
+ lane.laneNumber,
3072
+ spawnFailureSnapshot as unknown as Record<string, unknown>,
3073
+ );
2815
3074
  } catch (snapErr) {
2816
3075
  // Best effort — if the snapshot write fails, the monitor's
2817
3076
  // 30s-staleness fallback (snap with old updatedAt) eventually
2818
3077
  // kicks in via the registry liveness check. Log so this is
2819
3078
  // visible in operator diagnostics, but do NOT throw.
2820
- execLog(laneId, task.taskId, `spawn-failure snapshot write failed (non-fatal): ${snapErr instanceof Error ? snapErr.message : String(snapErr)}`);
3079
+ execLog(
3080
+ laneId,
3081
+ task.taskId,
3082
+ `spawn-failure snapshot write failed (non-fatal): ${snapErr instanceof Error ? snapErr.message : String(snapErr)}`,
3083
+ );
2821
3084
  }
2822
3085
 
2823
3086
  shouldSkipRemaining = true;
@@ -2825,8 +3088,8 @@ export async function executeLaneV2(
2825
3088
  }
2826
3089
 
2827
3090
  const endTime = Date.now();
2828
- const succeeded = outcomes.every(o => o.status === "succeeded");
2829
- const failed = outcomes.some(o => o.status === "failed" || o.status === "stalled");
3091
+ const succeeded = outcomes.every((o) => o.status === "succeeded");
3092
+ const failed = outcomes.some((o) => o.status === "failed" || o.status === "stalled");
2830
3093
 
2831
3094
  return {
2832
3095
  laneNumber: lane.laneNumber,
@@ -2839,4 +3102,3 @@ export async function executeLaneV2(
2839
3102
  }
2840
3103
 
2841
3104
  // ── /orch Command — Full Execution (Step 5) ─────────────────────────
2842
-