taskplane 0.24.30 → 0.25.0

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.
@@ -1118,6 +1118,9 @@ function buildDashboardState() {
1118
1118
  // Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo).
1119
1119
  // Additive field — absent in v1 state files, frontend must default to "repo".
1120
1120
  mode: state.mode || "repo",
1121
+ // TP-148: Segment records for wave display context (v4+).
1122
+ // Each record has taskId, segmentId, repoId, status.
1123
+ segments: state.segments || [],
1121
1124
  },
1122
1125
  sessions,
1123
1126
  tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
@@ -16,6 +16,7 @@
16
16
  * @module orch/engine-worker
17
17
  */
18
18
  import type {
19
+ AllocatedLane,
19
20
  EngineEvent,
20
21
  MonitorState,
21
22
  OrchBatchPhase,
@@ -71,6 +72,8 @@ export interface SerializedBatchState {
71
72
  startedAt: number;
72
73
  endedAt: number | null;
73
74
  errors: string[];
75
+ /** Active lanes for the current wave (synced so /orch-sessions works). */
76
+ currentLanes: AllocatedLane[];
74
77
  }
75
78
 
76
79
  /**
@@ -164,6 +167,7 @@ function serializeBatchState(state: OrchBatchRuntimeState): SerializedBatchState
164
167
  startedAt: state.startedAt,
165
168
  endedAt: state.endedAt,
166
169
  errors: [...state.errors],
170
+ currentLanes: state.currentLanes,
167
171
  };
168
172
  }
169
173
 
@@ -192,6 +196,7 @@ export function applySerializedState(
192
196
  batchState.startedAt = serialized.startedAt;
193
197
  batchState.endedAt = serialized.endedAt;
194
198
  batchState.errors = [...serialized.errors];
199
+ batchState.currentLanes = serialized.currentLanes ?? [];
195
200
  }
196
201
 
197
202
  // ── Engine main (runs when launched as a forked child process) ───────
@@ -22,7 +22,7 @@ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsPro
22
22
  import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
23
23
  import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SegmentExpansionRequest, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
24
24
  import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
25
- import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
25
+ import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, preserveSkippedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
26
26
  import { runPreflightCleanup, formatPreflightCleanup } from "./cleanup.ts";
27
27
 
28
28
  // ── Tier 0: Automatic Recovery Helpers (TP-039) ─────────────────────
@@ -346,9 +346,28 @@ export function validateSegmentExpansionRequestAtBoundary(
346
346
  if (requestedRepoSet.size !== request.requestedRepoIds.length) {
347
347
  return "duplicate repoIds in requestedRepoIds";
348
348
  }
349
+
350
+ // TP-145: Build a set of known repo IDs that edge endpoints may reference.
351
+ // This includes all requestedRepoIds plus the anchor segment's repo and
352
+ // any already-completed segments' repos. Workers commonly reference the
353
+ // anchor repo in edges (e.g., { from: "shared-libs", to: "web-client" })
354
+ // which is valid — the dependency is implicit for after-current placement.
355
+ const knownEdgeRepoIds = new Set(requestedRepoSet);
356
+ const orderedSegments = segmentState.orderedSegments ?? [];
357
+ const anchorSegment = orderedSegments.find((seg) => seg.segmentId === segmentId);
358
+ if (anchorSegment) {
359
+ knownEdgeRepoIds.add(anchorSegment.repoId);
360
+ }
361
+ for (const seg of orderedSegments) {
362
+ const status = segmentState.statusBySegmentId?.get(seg.segmentId);
363
+ if (status === "succeeded" || status === "failed" || status === "skipped") {
364
+ knownEdgeRepoIds.add(seg.repoId);
365
+ }
366
+ }
367
+
349
368
  for (const edge of request.edges) {
350
- if (!requestedRepoSet.has(edge.from) || !requestedRepoSet.has(edge.to)) {
351
- return "edge references a repo outside requestedRepoIds";
369
+ if (!knownEdgeRepoIds.has(edge.from) || !knownEdgeRepoIds.has(edge.to)) {
370
+ return "edge references a repo outside requestedRepoIds and known segments";
352
371
  }
353
372
  }
354
373
 
@@ -2289,10 +2308,29 @@ export async function executeOrchBatch(
2289
2308
  ORCH_MESSAGES.orchWaveStart(waveIdx + 1, runtimeSegmentRounds.length, waveTasks.length, lanes.length),
2290
2309
  "info",
2291
2310
  );
2311
+ // TP-148: Build per-task segment context for the wave_start event
2312
+ const waveSegmentContext: Array<{ taskId: string; segmentIndex: number; totalSegments: number; repoId: string; segmentId: string }> = [];
2313
+ for (const taskId of waveTasks) {
2314
+ const segState = segmentStateByTask.get(taskId);
2315
+ if (segState && segState.orderedSegments.length > 1) {
2316
+ const idx = segState.nextSegmentIndex;
2317
+ const seg = segState.orderedSegments[idx];
2318
+ if (seg) {
2319
+ waveSegmentContext.push({
2320
+ taskId,
2321
+ segmentIndex: idx + 1,
2322
+ totalSegments: segState.orderedSegments.length,
2323
+ repoId: seg.repoId,
2324
+ segmentId: seg.segmentId,
2325
+ });
2326
+ }
2327
+ }
2328
+ }
2292
2329
  emitEvent(stateRoot, {
2293
2330
  ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase),
2294
2331
  taskIds: waveTasks,
2295
2332
  laneCount: lanes.length,
2333
+ ...(waveSegmentContext.length > 0 ? { segmentContext: waveSegmentContext } : {}),
2296
2334
  }, onEngineEvent);
2297
2335
  // TP-029: Track repos from newly allocated lanes for cleanup coverage
2298
2336
  for (const lane of lanes) {
@@ -2612,6 +2650,26 @@ export async function executeOrchBatch(
2612
2650
  batchState.orchBranch,
2613
2651
  );
2614
2652
  const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "succeeded");
2653
+
2654
+ // TP-145 hardening: if .DONE was prematurely created by the
2655
+ // completing segment (because it was the last segment at that
2656
+ // time), remove it now. The task is no longer complete — new
2657
+ // segments have been added and must execute first.
2658
+ // Only delete if segments were actually inserted (avoid
2659
+ // reopening a completed task on no-op mutations).
2660
+ const doneDir = task.packetTaskPath || task.taskFolder;
2661
+ if (doneDir && mutation.insertedSegmentIds.length > 0) {
2662
+ const donePath = join(doneDir, ".DONE");
2663
+ if (existsSync(donePath)) {
2664
+ try {
2665
+ unlinkSync(donePath);
2666
+ execLog("batch", batchState.batchId, "removed premature .DONE after segment expansion", {
2667
+ taskId, donePath, requestId,
2668
+ });
2669
+ } catch { /* non-fatal */ }
2670
+ }
2671
+ }
2672
+
2615
2673
  if (persistedInsertedSegments || recordedRequestId || mutation.insertedSegmentIds.length > 0) {
2616
2674
  persistRuntimeState("segment-expansion-approved", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
2617
2675
  }
@@ -2915,13 +2973,16 @@ export async function executeOrchBatch(
2915
2973
  // files (especially for level-0 / fast tasks). Check each merge-candidate
2916
2974
  // lane worktree and auto-commit any remaining changes so they're included
2917
2975
  // in the merge. Skips lanes with only failed/stalled tasks (no merge).
2976
+ // TP-147: Also auto-commit skipped-task lanes so partial progress
2977
+ // (STATUS.md updates, partial code) is preserved on their branch.
2918
2978
  for (const lane of waveResult.allocatedLanes) {
2919
2979
  if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue;
2920
- // Only check lanes that have at least one succeeded task (merge candidates)
2921
2980
  const laneOutcome = laneOutcomeByNumber.get(lane.laneNumber);
2922
2981
  if (!laneOutcome) continue;
2923
2982
  const hasSucceeded = laneOutcome.tasks.some(t => t.status === "succeeded");
2924
- if (!hasSucceeded) continue;
2983
+ const hasSkipped = laneOutcome.tasks.some(t => t.status === "skipped");
2984
+ // Auto-commit merge candidates (succeeded) and skipped-task lanes
2985
+ if (!hasSucceeded && !hasSkipped) continue;
2925
2986
  try {
2926
2987
  const addResult = runGit(["add", "-A"], lane.worktreePath);
2927
2988
  if (!addResult.ok) {
@@ -3465,6 +3526,34 @@ export async function executeOrchBatch(
3465
3526
  }
3466
3527
  // TP-028: Stamp task outcomes with partial progress data for persistence
3467
3528
  applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
3529
+
3530
+ // TP-147: Also preserve skipped task branches before inter-wave reset
3531
+ const skippedPpResult = preserveSkippedLaneProgress(
3532
+ latestAllocatedLanes,
3533
+ allTaskOutcomes,
3534
+ ppOpId,
3535
+ batchState.batchId,
3536
+ (repoId) => {
3537
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
3538
+ let targetBranch = batchState.orchBranch;
3539
+ if (repoId && perRepoRoot !== repoRoot) {
3540
+ try {
3541
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
3542
+ } catch { /* fall back to orchBranch */ }
3543
+ }
3544
+ return { repoRoot: perRepoRoot, targetBranch };
3545
+ },
3546
+ );
3547
+ // Merge unsafe branches from skipped tasks into the main set
3548
+ for (const branch of skippedPpResult.unsafeBranches) {
3549
+ ppUnsafeBranches.add(branch);
3550
+ }
3551
+ if (skippedPpResult.results.some(r => r.saved)) {
3552
+ execLog("batch", batchState.batchId,
3553
+ `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before inter-wave reset`);
3554
+ }
3555
+ // Stamp skipped task outcomes with partial progress data
3556
+ applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes);
3468
3557
  }
3469
3558
 
3470
3559
  // ── Post-merge: Reset worktrees for next wave ────────────
@@ -3811,6 +3900,30 @@ export async function executeOrchBatch(
3811
3900
  };
3812
3901
  });
3813
3902
 
3903
+ // TP-147: Ensure ALL tasks from the wave plan are represented in history.
3904
+ // Tasks that never got allocated (blocked by upstream failures, never started)
3905
+ // won't have entries in allTaskOutcomes. Add them with appropriate status.
3906
+ const coveredTaskIds = new Set(taskSummaries.map(t => t.taskId));
3907
+ for (let wi = 0; wi < wavePlan.length; wi++) {
3908
+ for (const taskId of wavePlan[wi]) {
3909
+ if (coveredTaskIds.has(taskId)) continue;
3910
+ // Determine the appropriate status for uncovered tasks
3911
+ const isBlocked = batchState.blockedTaskIds.has(taskId);
3912
+ const status: BatchTaskSummary["status"] = isBlocked ? "blocked" : "pending";
3913
+ taskSummaries.push({
3914
+ taskId,
3915
+ taskName: taskId,
3916
+ status,
3917
+ wave: wi + 1,
3918
+ lane: 0,
3919
+ durationMs: 0,
3920
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 },
3921
+ exitReason: isBlocked ? "Blocked by upstream failure" : null,
3922
+ });
3923
+ coveredTaskIds.add(taskId);
3924
+ }
3925
+ }
3926
+
3814
3927
  // Build per-wave summaries
3815
3928
  const waveSummaries: BatchWaveSummary[] = wavePlan.map((taskIds, wi) => {
3816
3929
  const waveTasks = taskSummaries.filter(t => t.wave === wi + 1);
@@ -3852,6 +3965,16 @@ export async function executeOrchBatch(
3852
3965
  ? "completed"
3853
3966
  : "aborted";
3854
3967
 
3968
+ // TP-147: Ensure totalTasks matches actual task array length.
3969
+ // Use taskSummaries.length as authoritative (includes gap-filled tasks)
3970
+ // and log a warning if it diverges from batchState.totalTasks.
3971
+ const actualTotalTasks = taskSummaries.length;
3972
+ if (actualTotalTasks !== batchState.totalTasks) {
3973
+ execLog("batch", batchState.batchId,
3974
+ `WARNING: totalTasks mismatch — batchState.totalTasks=${batchState.totalTasks}, ` +
3975
+ `taskSummaries.length=${actualTotalTasks}. Using taskSummaries.length for history.`);
3976
+ }
3977
+
3855
3978
  const summary: BatchHistorySummary = {
3856
3979
  batchId: batchState.batchId,
3857
3980
  status: historyStatus,
@@ -3859,7 +3982,7 @@ export async function executeOrchBatch(
3859
3982
  endedAt: Date.now(),
3860
3983
  durationMs: Date.now() - batchState.startedAt,
3861
3984
  totalWaves: wavePlan.length,
3862
- totalTasks: batchState.totalTasks,
3985
+ totalTasks: actualTotalTasks,
3863
3986
  succeededTasks: batchState.succeededTasks,
3864
3987
  failedTasks: batchState.failedTasks,
3865
3988
  skippedTasks: batchState.skippedTasks,
@@ -3983,6 +4106,37 @@ export async function executeOrchBatch(
3983
4106
  }
3984
4107
  // TP-028: Stamp task outcomes with partial progress data for persistence
3985
4108
  applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
4109
+
4110
+ // TP-147: Also preserve skipped task branches before terminal cleanup
4111
+ const skippedPpResult = preserveSkippedLaneProgress(
4112
+ latestAllocatedLanes,
4113
+ allTaskOutcomes,
4114
+ ppOpId,
4115
+ batchState.batchId,
4116
+ (repoId) => {
4117
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
4118
+ let targetBranch = batchState.orchBranch;
4119
+ if (repoId && perRepoRoot !== repoRoot) {
4120
+ try {
4121
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
4122
+ } catch { /* fall back to orchBranch */ }
4123
+ }
4124
+ return { repoRoot: perRepoRoot, targetBranch };
4125
+ },
4126
+ );
4127
+ if (skippedPpResult.results.some(r => r.saved)) {
4128
+ execLog("batch", batchState.batchId,
4129
+ `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before terminal cleanup`);
4130
+ }
4131
+ for (const r of skippedPpResult.results) {
4132
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
4133
+ execLog("batch", batchState.batchId,
4134
+ `WARNING: Failed to preserve partial progress for skipped task ${r.taskId} ` +
4135
+ `(${r.commitCount} commit(s) may become unreachable after cleanup)`,
4136
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
4137
+ }
4138
+ }
4139
+ applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes);
3986
4140
  }
3987
4141
 
3988
4142
  // TP-029: Clean up worktrees across ALL encountered repos (not just primary).
@@ -164,7 +164,7 @@ export function execLog(
164
164
  * @returns true if agent is alive
165
165
  * @since TP-112
166
166
  */
167
- export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend): boolean {
167
+ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend, laneNumber?: number): boolean {
168
168
  // Read the registry from the global state root.
169
169
  // Since this is a pure liveness check, we scan for matching agentId
170
170
  // patterns: direct match, or lane-session + "-worker" suffix.
@@ -176,6 +176,18 @@ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: R
176
176
  // Try worker suffix (monitor uses lane session name, registry uses agentId)
177
177
  const workerManifest = agents[`${agentIdOrSessionName}-worker`];
178
178
  if (workerManifest && !isTerminalStatus(workerManifest.status) && isProcessAlive(workerManifest.pid)) return true;
179
+ // TP-148: In workspace mode, laneSessionId includes repoId and uses a local
180
+ // lane number (e.g., "orch-henry-api-lane-1") while the V2 registry uses
181
+ // global lane numbers without repoId (e.g., "orch-henry-lane-3-worker").
182
+ // Fall back to scanning the registry by global lane number when provided.
183
+ if (laneNumber != null) {
184
+ for (const agent of Object.values(agents)) {
185
+ if (agent.laneNumber === laneNumber && agent.role === "worker" &&
186
+ !isTerminalStatus(agent.status) && isProcessAlive(agent.pid)) {
187
+ return true;
188
+ }
189
+ }
190
+ }
179
191
  return false;
180
192
  }
181
193
 
@@ -201,7 +213,7 @@ export function setV2LivenessRegistryCache(registry: import("./process-registry.
201
213
  */
202
214
  export function killV2LaneAgents(
203
215
  sessionName: string,
204
- options?: { stateRoot?: string; batchId?: string; logContext?: string },
216
+ options?: { stateRoot?: string; batchId?: string; logContext?: string; laneNumber?: number },
205
217
  ): void {
206
218
  const registry = _v2LivenessRegistryCache ?? (
207
219
  options?.stateRoot && options?.batchId
@@ -212,16 +224,32 @@ export function killV2LaneAgents(
212
224
 
213
225
  const agents = registry.agents;
214
226
  const logContext = options?.logContext ?? "monitor";
227
+ const killedPids = new Set<number>();
215
228
  for (const suffix of ["-worker", "-reviewer", ""]) {
216
229
  const key = `${sessionName}${suffix}`;
217
230
  const manifest = agents[key];
218
- if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
231
+ if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid) && !killedPids.has(manifest.pid)) {
219
232
  try {
220
233
  process.kill(manifest.pid, "SIGTERM");
234
+ killedPids.add(manifest.pid);
221
235
  execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`);
222
236
  } catch { /* already dead */ }
223
237
  }
224
238
  }
239
+ // TP-148: Workspace-mode fallback — match by global lane number when
240
+ // session name lookup misses (repoId/local-vs-global lane mismatch).
241
+ if (options?.laneNumber != null) {
242
+ for (const agent of Object.values(agents)) {
243
+ if (agent.laneNumber === options.laneNumber &&
244
+ !isTerminalStatus(agent.status) && isProcessAlive(agent.pid) && !killedPids.has(agent.pid)) {
245
+ try {
246
+ process.kill(agent.pid, "SIGTERM");
247
+ killedPids.add(agent.pid);
248
+ execLog(logContext, agent.agentId, `killed V2 agent by lane number (PID ${agent.pid})`);
249
+ } catch { /* already dead */ }
250
+ }
251
+ }
252
+ }
225
253
  }
226
254
 
227
255
  // ── Async File/Status Helpers (TP-070) ───────────────────────────────
@@ -877,7 +905,7 @@ export async function resolveTaskMonitorState(
877
905
  // New task, stale snapshot — give the worker startup grace period
878
906
  sessionAlive = true;
879
907
  } else {
880
- sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
908
+ sessionAlive = isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber);
881
909
  }
882
910
  } else {
883
911
  sessionAlive = true;
@@ -886,7 +914,7 @@ export async function resolveTaskMonitorState(
886
914
  sessionAlive = snap.status === "running";
887
915
  }
888
916
  } else {
889
- sessionAlive = isV2AgentAlive(sessionName, "v2");
917
+ sessionAlive = isV2AgentAlive(sessionName, "v2", v2Context?.laneNumber);
890
918
  }
891
919
  const doneFileFound = await fileExistsAsync(donePath);
892
920
 
@@ -984,7 +1012,7 @@ export async function resolveTaskMonitorState(
984
1012
  stallMinutes,
985
1013
  backend: runtimeBackend ?? "legacy",
986
1014
  });
987
- killV2LaneAgents(sessionName);
1015
+ killV2LaneAgents(sessionName, { laneNumber: v2Context?.laneNumber });
988
1016
 
989
1017
  return {
990
1018
  taskId,
@@ -1249,7 +1277,8 @@ export async function monitorLanes(
1249
1277
  }
1250
1278
 
1251
1279
  // TP-112: Backend-aware lane liveness for snapshot
1252
- const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
1280
+ // TP-148: Pass global laneNumber for workspace-mode fallback lookup
1281
+ const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2", lane.laneNumber);
1253
1282
 
1254
1283
  laneSnapshots.push({
1255
1284
  laneId: lane.laneId,
@@ -1874,7 +1903,7 @@ export async function executeWithStopAll(
1874
1903
 
1875
1904
  // Kill ALL lane sessions immediately
1876
1905
  for (const lane of lanes) {
1877
- killV2LaneAgents(laneSessionIdOf(lane));
1906
+ killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber });
1878
1907
  }
1879
1908
  }
1880
1909
  }
@@ -1888,7 +1917,7 @@ export async function executeWithStopAll(
1888
1917
  pauseSignal.paused = true;
1889
1918
  execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
1890
1919
  for (const lane of lanes) {
1891
- killV2LaneAgents(laneSessionIdOf(lane));
1920
+ killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber });
1892
1921
  }
1893
1922
  }
1894
1923
 
@@ -4720,7 +4720,39 @@ export default function (pi: ExtensionAPI) {
4720
4720
  if (!requireExecCtx(ctx)) return;
4721
4721
 
4722
4722
  try {
4723
- await openSettingsTui(ctx, execCtx!.workspaceRoot, execCtx!.pointer?.configRoot);
4723
+ // Capture the workspace root for reload consistency — use the same
4724
+ // root the settings TUI writes to, not ctx.cwd which may differ.
4725
+ const reloadCwd = execCtx!.workspaceRoot;
4726
+ await openSettingsTui(ctx, execCtx!.workspaceRoot, execCtx!.pointer?.configRoot, () => {
4727
+ // Reload live config from disk so changes take effect immediately
4728
+ // without requiring a session restart.
4729
+ // Build everything into temporaries first, then commit atomically
4730
+ // so a partial failure doesn't leave mixed-generation state.
4731
+ try {
4732
+ const freshCtx = buildExecutionContext(reloadCwd, loadOrchestratorConfig, loadTaskRunnerConfig);
4733
+ let freshSupervisor: SupervisorConfig;
4734
+ try {
4735
+ freshSupervisor = loadSupervisorConfig(
4736
+ freshCtx.repoRoot,
4737
+ freshCtx.pointer?.configRoot,
4738
+ );
4739
+ } catch {
4740
+ freshSupervisor = { ...DEFAULT_SUPERVISOR_CONFIG };
4741
+ }
4742
+ // Atomic commit — all or nothing
4743
+ execCtx = freshCtx;
4744
+ orchConfig = freshCtx.orchestratorConfig;
4745
+ runnerConfig = freshCtx.taskRunnerConfig;
4746
+ supervisorConfig = freshSupervisor;
4747
+ } catch {
4748
+ // Non-fatal — config was saved to disk but live reload failed.
4749
+ // Existing in-memory config is preserved unchanged.
4750
+ ctx.ui.notify(
4751
+ "⚠️ Saved to disk but live reload failed. Restart to apply.",
4752
+ "warn",
4753
+ );
4754
+ }
4755
+ });
4724
4756
  } catch (err: any) {
4725
4757
  ctx.ui.notify(`❌ Failed to load settings: ${err.message}`, "error");
4726
4758
  }
@@ -176,6 +176,19 @@ export async function executeTaskV2(
176
176
  updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
177
177
  logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
178
178
 
179
+ // Pre-segment guard: remove any stale .DONE from a prior segment or prior run.
180
+ // This closes the race window where the monitor sees .DONE before lane-runner
181
+ // can suppress it at segment end. For non-final segments, .DONE must not exist
182
+ // at any point during execution.
183
+ const isNonFinalAtStart = segmentId != null
184
+ && Array.isArray(unit.task.segmentIds)
185
+ && unit.task.segmentIds.length > 1
186
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
187
+ if (isNonFinalAtStart && existsSync(donePath)) {
188
+ try { unlinkSync(donePath); } catch { /* best effort */ }
189
+ logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
190
+ }
191
+
179
192
  // ── 2. Iteration loop ───────────────────────────────────────────
180
193
  let noProgressCount = 0;
181
194
  let totalIterations = 0;
@@ -528,7 +541,39 @@ export async function executeTaskV2(
528
541
  false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
529
542
  }
530
543
 
531
- // Create .DONE if not already present
544
+ // TP-145: Determine if this is a non-final segment of a multi-segment task.
545
+ // If more segments remain after this one, suppress .DONE creation so that
546
+ // the engine can advance the segment frontier and execute subsequent segments.
547
+ // .DONE must only exist when ALL segments of a multi-segment task are complete.
548
+ const isNonFinalSegment = segmentId != null
549
+ && Array.isArray(unit.task.segmentIds)
550
+ && unit.task.segmentIds.length > 1
551
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
552
+
553
+ if (isNonFinalSegment) {
554
+ // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
555
+ // The engine will advance the frontier and dispatch the next segment.
556
+ // Also delete any .DONE the worker may have created directly (workers have
557
+ // write access and sometimes create .DONE on their own, bypassing this gate).
558
+ if (existsSync(donePath)) {
559
+ let deleted = false;
560
+ try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
561
+ if (deleted) {
562
+ logExecution(statusPath, "Segment complete",
563
+ `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`);
564
+ } else {
565
+ logExecution(statusPath, "Segment complete",
566
+ `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`);
567
+ }
568
+ } else {
569
+ logExecution(statusPath, "Segment complete",
570
+ `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
571
+ }
572
+ return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
573
+ "Segment completed (non-final — .DONE suppressed)", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
574
+ }
575
+
576
+ // Create .DONE if not already present (final segment or single-segment/whole-task execution)
532
577
  if (!existsSync(donePath)) {
533
578
  writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
534
579
  }