taskplane 0.28.8 → 0.29.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `getVersion` — capture a CLI's version string with stdout-precedence,
3
+ * stderr-fallback, and null-on-failure semantics.
4
+ *
5
+ * Extracted from the inline `getVersion()` in `bin/taskplane.mjs` so it
6
+ * can be unit-tested without subprocessing the whole CLI.
7
+ *
8
+ * Behavior:
9
+ * - Spawns `${cmd} ${flag}` with shell:true and stdio:['ignore','pipe','pipe']
10
+ * - Returns null if `spawnSync` itself throws (e.g., command not found)
11
+ * - Returns null if the subprocess errored OR exited with non-zero status
12
+ * (matches the prior `execSync`-throws-on-failure contract)
13
+ * - On success, returns stdout if non-empty, else stderr (some CLIs
14
+ * notably `pi` print version output to stderr)
15
+ * - Returns null if both streams are empty
16
+ *
17
+ * @since TP-189-C (extracted) / TP-185 follow-up (original fix scope)
18
+ *
19
+ * @param {string} cmd — command name (or already-formed token sequence)
20
+ * @param {string} [flag="--version"] — flag appended to cmd
21
+ * @returns {string | null} trimmed version string, or null on any failure
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+
26
+ export function getVersion(cmd, flag = "--version") {
27
+ let result;
28
+ try {
29
+ // shell:true matches the prior execSync behavior — accepts a
30
+ // space-joined command string and resolves via PATH lookup.
31
+ result = spawnSync(`${cmd} ${flag}`, [], {
32
+ shell: true,
33
+ encoding: "utf-8",
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ });
36
+ } catch {
37
+ return null;
38
+ }
39
+ // Match prior contract: any non-success path → null.
40
+ // `execSync` previously threw on either spawn failure or non-zero exit,
41
+ // and the caller's catch returned null. Replicate that here so a CLI
42
+ // that exits 1 with shell error text in stderr (e.g., "command not
43
+ // found") does NOT surface as a fake version string.
44
+ if (!result || result.error || result.status !== 0) return null;
45
+ const stdout = (result.stdout ?? "").toString().trim();
46
+ const stderr = (result.stderr ?? "").toString().trim();
47
+ if (stdout) return stdout;
48
+ if (stderr) return stderr;
49
+ return null;
50
+ }
package/bin/taskplane.mjs CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  ALL_GITIGNORE_PATTERNS,
37
37
  patternToRegex,
38
38
  } from "./gitignore-patterns.mjs";
39
+ import { getVersion } from "./get-version.mjs";
39
40
 
40
41
  // ─── Paths ──────────────────────────────────────────────────────────────────
41
42
 
@@ -127,14 +128,10 @@ function commandExists(cmd) {
127
128
  }
128
129
  }
129
130
 
130
- /** Get command version string. */
131
- function getVersion(cmd, flag = "--version") {
132
- try {
133
- return execSync(`${cmd} ${flag}`, { stdio: "pipe" }).toString().trim();
134
- } catch {
135
- return null;
136
- }
137
- }
131
+ // `getVersion` lives in `./get-version.mjs` so it can be unit-tested
132
+ // without subprocessing the full CLI. Imported above. (TP-189-C / TP-185
133
+ // follow-up: capture both stdout and stderr because `pi --version`
134
+ // prints to stderr; null on failure preserves the original contract.)
138
135
 
139
136
  /**
140
137
  * Parse the tabular output from `pi --list-models` into structured model rows.
@@ -146,6 +146,12 @@ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string
146
146
  * section. All-checkboxes-checked is also NOT a trigger — it is the normal
147
147
  * pre-code-review state.
148
148
  *
149
+ * Fenced code blocks (delimited by ``` or ~~~) inside a step's body are
150
+ * skipped during the scan (TP-189-A3). This avoids a false-positive
151
+ * refusal when a step documents the literal `**Status:** ✅ Complete`
152
+ * pattern as part of its own instructions — a legitimate authoring case
153
+ * that doesn't represent an actual completion claim.
154
+ *
149
155
  * Designed to fail-open: any I/O error or a missing step heading returns
150
156
  * `false` (the review proceeds). The prompt-side Recovery Recipe is the
151
157
  * primary defense; this guard is a hard backstop, not a gatekeeper.
@@ -165,15 +171,64 @@ export function isStepMarkedComplete(statusPath: string, stepNum: number): boole
165
171
  const lines = content.split(/\r?\n/);
166
172
  const stepHeadingRe = new RegExp(`^###\\s+Step\\s+${stepNum}\\b`);
167
173
  const nextStepHeadingRe = /^###\s+Step\s+\d+\b/;
168
-
174
+ // TP-189-A3: track fenced-code-block state per CommonMark semantics.
175
+ // A fence opens with 3+ backticks OR 3+ tildes optionally followed by
176
+ // an info string (e.g., ```javascript). A fence CLOSES only when a
177
+ // matching delimiter (same char, length >= opener length) is seen on
178
+ // a line by itself — the closer line MUST NOT contain trailing
179
+ // non-whitespace text. This distinction matters: ```javascript
180
+ // inside an open fence is content, not a closer; mistreating it as a
181
+ // closer would let `**Status:** ✅ Complete` later in the same code
182
+ // block trip the guard. Tracking opener char + length also avoids
183
+ // premature close on `~~~` inside a backtick fence (or vice versa).
184
+ const openerRe = /^\s*(`{3,}|~{3,})(.*)$/;
169
185
  let inSection = false;
186
+ let fenceOpener: { char: string; length: number } | null = null;
170
187
  for (const line of lines) {
171
188
  if (!inSection) {
172
189
  if (stepHeadingRe.test(line)) inSection = true;
173
190
  continue;
174
191
  }
175
- // Stop scanning at the next step heading.
176
- if (nextStepHeadingRe.test(line)) break;
192
+ // Step boundaries are recognized only OUTSIDE a fenced block.
193
+ // (A `### Step N:` line inside a code-fence sample is content,
194
+ // not a real heading.)
195
+ if (fenceOpener === null && nextStepHeadingRe.test(line)) break;
196
+ // Detect fence delimiter lines.
197
+ const fenceMatch = line.match(openerRe);
198
+ if (fenceMatch) {
199
+ const delim = fenceMatch[1];
200
+ const trailing = fenceMatch[2] ?? "";
201
+ const char = delim[0]; // "`" or "~"
202
+ const length = delim.length;
203
+ if (fenceOpener === null) {
204
+ // Opening: any trailing text is the info string — allowed.
205
+ // CommonMark forbids backticks in a backtick info string,
206
+ // but rejecting that case here only risks false negatives
207
+ // (i.e., not opening a fence we should have); the worst-
208
+ // case impact is a real Status line being inspected as if
209
+ // outside a fence — which is the safe default.
210
+ fenceOpener = { char, length };
211
+ continue;
212
+ }
213
+ // Already inside a fence — a line counts as a closer ONLY if:
214
+ // 1. delimiter char matches the opener,
215
+ // 2. delimiter length >= opener length,
216
+ // 3. nothing follows the delimiter except whitespace.
217
+ const trailingIsWhitespace = /^\s*$/.test(trailing);
218
+ if (
219
+ char === fenceOpener.char &&
220
+ length >= fenceOpener.length &&
221
+ trailingIsWhitespace
222
+ ) {
223
+ fenceOpener = null;
224
+ continue;
225
+ }
226
+ // Else: this line is content INSIDE the open fence (e.g.,
227
+ // ```javascript inside a 4-backtick fence, or a non-matching
228
+ // tilde delimiter). Fall through to the inFence skip below.
229
+ }
230
+ // Skip lines inside an open fenced code block.
231
+ if (fenceOpener !== null) continue;
177
232
  // Match a literal status line within this step's section.
178
233
  // Examples that should match:
179
234
  // **Status:** ✅ Complete
@@ -87,23 +87,17 @@ export const ENGINE_BRIDGE_TOOLS = [
87
87
  "request_segment_expansion",
88
88
  ] as const;
89
89
 
90
- /**
91
- * Default user-tools portion of the worker `--tools` allowlist. This is the
92
- * fallback used when neither `taskRunner.worker.tools` config nor the
93
- * `TASKPLANE_WORKER_TOOLS` env var supplies a value. Engine bridge tools
94
- * (`ENGINE_BRIDGE_TOOLS`) are appended on top by
95
- * `buildWorkerToolsAllowlist()` at the spawn site — they are NOT part of
96
- * this default and should not be added by callers.
97
- *
98
- * NOTE: This literal is duplicated in `config-schema.ts` (defaults block)
99
- * and `types.ts` (defaults block) as well. Those modules intentionally
100
- * keep the literal to avoid pulling agent-host's heavy imports (child
101
- * process, fs) into pure schema/type files. If you change the default
102
- * here, update those copies too.
103
- *
104
- * @since TP-184
105
- */
106
- export const DEFAULT_WORKER_USER_TOOLS = "read,write,edit,bash,grep,find,ls";
90
+ // TP-189 (Cluster B): `DEFAULT_WORKER_USER_TOOLS` now lives in the
91
+ // import-free `./tool-allowlist-constants.ts` module so that pure-data
92
+ // layers (`config-schema.ts`, `types.ts`) can import it without pulling
93
+ // agent-host's heavy `child_process`/`fs` imports into the schema/type
94
+ // graph. We re-export here so existing internal imports (e.g.,
95
+ // `execution.ts`, `worker-tools-allowlist.test.ts`) continue to work
96
+ // without churn.
97
+ //
98
+ // @since TP-184 (constant introduced) / TP-189 (moved to constants module)
99
+ export { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
100
+ import { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
107
101
 
108
102
  /**
109
103
  * Build the final worker `--tools` allowlist string by combining the
@@ -37,6 +37,12 @@
37
37
  * @module config/schema
38
38
  */
39
39
 
40
+ // TP-189 (Cluster B): single source of truth for the worker user-tools
41
+ // default literal. This is a deliberately import-free module so we can
42
+ // import it here without pulling `agent-host.ts`'s `child_process`/`fs`
43
+ // imports into the schema layer.
44
+ import { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
45
+
40
46
  // ── Config Version ───────────────────────────────────────────────────
41
47
 
42
48
  /**
@@ -594,14 +600,11 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
594
600
  testing: { commands: {} },
595
601
  standards: { docs: [], rules: [] },
596
602
  standardsOverrides: {},
597
- // NOTE (TP-184): The user-tools default literal here mirrors
598
- // `DEFAULT_WORKER_USER_TOOLS` in `agent-host.ts`. We keep the literal
599
- // instead of importing the constant because this file is currently
600
- // import-free (pure schema/defaults) and importing from agent-host.ts
601
- // would pull child_process/fs into the schema layer. If you change the
602
- // default, update both copies. Engine bridge tools are appended at the
603
- // lane-runner spawn site by `buildWorkerToolsAllowlist()`, not here.
604
- worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "", excludeExtensions: [] },
603
+ // TP-189 (Cluster B): user-tools default sourced from
604
+ // `tool-allowlist-constants.ts` (single source of truth). Engine
605
+ // bridge tools are appended at the lane-runner spawn site by
606
+ // `buildWorkerToolsAllowlist()`, not here.
607
+ worker: { model: "", tools: DEFAULT_WORKER_USER_TOOLS, thinking: "", excludeExtensions: [] },
605
608
  reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on", excludeExtensions: [] },
606
609
  context: {
607
610
  workerContextWindow: 0,
@@ -653,10 +656,12 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
653
656
  },
654
657
  merge: {
655
658
  model: "",
656
- // NOTE (TP-184): Mirrors `DEFAULT_WORKER_USER_TOOLS`. Merge agent does
657
- // not run through `buildWorkerToolsAllowlist()` (no bridge-tool needs)
658
- // so this literal is independent of the worker allowlist plumbing.
659
- tools: "read,write,edit,bash,grep,find,ls",
659
+ // TP-189 (Cluster B): merge default mirrors the worker user-tools
660
+ // constant. The merge agent does NOT run through
661
+ // `buildWorkerToolsAllowlist()` (no bridge-tool needs), so this
662
+ // reference is purely for default-value parity — not a hard
663
+ // coupling to the worker allowlist plumbing.
664
+ tools: DEFAULT_WORKER_USER_TOOLS,
660
665
  thinking: "off",
661
666
  verify: [],
662
667
  order: "fewest-files-first",
@@ -40,6 +40,17 @@ export type WorkerToMainMessage =
40
40
  | { type: "monitor-update"; state: MonitorState }
41
41
  | { type: "engine-event"; event: EngineEvent }
42
42
  | { type: "supervisor-alert"; alert: SupervisorAlert }
43
+ /**
44
+ * TP-187 (#538): Lane has reached a terminal state. The supervisor process
45
+ * uses this to mark the lane terminated and filter any subsequently-arriving
46
+ * (zombie) alerts whose `context.laneNumber`/`context.agentId` matches.
47
+ */
48
+ | { type: "lane-terminated"; info: import("./types.ts").LaneTerminatedInfo }
49
+ /**
50
+ * TP-187 (#538): Lane number has been re-allocated to a fresh task. The
51
+ * supervisor lifts the suppression so subsequent alerts pass through.
52
+ */
53
+ | { type: "lane-respawned"; laneNumber: number; agentId: string; batchId: string }
43
54
  | { type: "state-sync"; state: SerializedBatchState }
44
55
  | { type: "complete"; state: SerializedBatchState }
45
56
  | { type: "error"; message: string; stack?: string; source?: WorkerErrorSource };
@@ -325,6 +336,18 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
325
336
  send({ type: "supervisor-alert", alert });
326
337
  };
327
338
 
339
+ // TP-187 (#538): Lane termination callback — forwards lane-terminated to
340
+ // the supervisor process so it can suppress in-flight zombie alerts.
341
+ const onLaneTerminated = (info: import("./types.ts").LaneTerminatedInfo) => {
342
+ send({ type: "lane-terminated", info });
343
+ };
344
+
345
+ // TP-187 (#538): Lane respawn callback — forwards lane-respawned to
346
+ // the supervisor process so it can lift suppression for re-allocated lanes.
347
+ const onLaneRespawned = (laneNumber: number, agentId: string, batchId: string) => {
348
+ send({ type: "lane-respawned", laneNumber, agentId, batchId });
349
+ };
350
+
328
351
  // ── Execute engine ───────────────────────────────────────────
329
352
  const enginePromise = data.mode === "resume"
330
353
  ? resumeOrchBatch(
@@ -340,6 +363,8 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
340
363
  data.force ?? false,
341
364
  onSupervisorAlert,
342
365
  data.supervisorAutonomy ?? "autonomous",
366
+ onLaneTerminated,
367
+ onLaneRespawned,
343
368
  )
344
369
  : executeOrchBatch(
345
370
  data.args ?? "",
@@ -355,6 +380,8 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
355
380
  onEngineEvent,
356
381
  onSupervisorAlert,
357
382
  data.supervisorAutonomy ?? "autonomous",
383
+ onLaneTerminated,
384
+ onLaneRespawned,
358
385
  );
359
386
 
360
387
  enginePromise
@@ -17,8 +17,9 @@ import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolic
17
17
  import type { CleanupGateRepoFailure } from "./messages.ts";
18
18
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
19
19
  import { resolveOperatorId } from "./naming.ts";
20
- import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
20
+ import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, saveBatchMetaRuntimeArtifact, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
21
21
  import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsProcessAlive } from "./process-registry.ts";
22
+ import { drainAgentOutbox } from "./mailbox.ts";
22
23
  import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
23
24
  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
25
  import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
@@ -1781,6 +1782,8 @@ async function attemptStaleWorktreeRecovery(
1781
1782
  onSupervisorAlert?: SupervisorAlertCallback,
1782
1783
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1783
1784
  runnerConfig?: TaskRunnerConfig,
1785
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
1786
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
1784
1787
  ): Promise<WaveExecutionResult | null> {
1785
1788
  // Only attempt recovery for ALLOC_WORKTREE_FAILED
1786
1789
  if (!waveResult.allocationError || waveResult.allocationError.code !== "ALLOC_WORKTREE_FAILED") {
@@ -1896,6 +1899,8 @@ async function attemptStaleWorktreeRecovery(
1896
1899
  excludeExtensions: runnerConfig.worker.excludeExtensions ?? [],
1897
1900
  } : undefined,
1898
1901
  runnerConfig?.workerExcludeExtensions ?? [],
1902
+ onLaneTerminated,
1903
+ onLaneRespawned,
1899
1904
  );
1900
1905
 
1901
1906
  return retryResult;
@@ -1973,6 +1978,18 @@ export async function executeOrchBatch(
1973
1978
  onEngineEvent?: EngineEventCallback | null,
1974
1979
  onSupervisorAlert?: SupervisorAlertCallback | null,
1975
1980
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1981
+ /**
1982
+ * TP-187 (#538): Optional callback fired when a lane reaches a terminal
1983
+ * state. The supervisor process forwards this over IPC and uses it to
1984
+ * suppress zombie alerts queued for the now-dead lane.
1985
+ */
1986
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback | null,
1987
+ /**
1988
+ * TP-187 (#538): Optional callback fired when a lane is freshly
1989
+ * (re-)allocated to a task. The supervisor process uses it to lift any
1990
+ * zombie-alert suppression carried over from a prior wave.
1991
+ */
1992
+ onLaneRespawned?: ((laneNumber: number, agentId: string, batchId: string) => void) | null,
1976
1993
  ): Promise<void> {
1977
1994
  const repoRoot = cwd;
1978
1995
  // State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
@@ -1999,6 +2016,23 @@ export async function executeOrchBatch(
1999
2016
  }
2000
2017
  };
2001
2018
 
2019
+ // ── TP-187 (#538): Lane termination forwarding helper ──────
2020
+ // Forwards lane-terminated events through the same callback chain so the
2021
+ // supervisor process can suppress zombie alerts queued for a dead lane.
2022
+ const emitLaneTerminated = (info: import("./types.ts").LaneTerminatedInfo): void => {
2023
+ if (onLaneTerminated) {
2024
+ try {
2025
+ onLaneTerminated(info);
2026
+ } catch (err: unknown) {
2027
+ const msg = err instanceof Error ? err.message : String(err);
2028
+ execLog("batch", batchState.batchId, `lane-terminated callback failed: ${msg}`, {
2029
+ laneNumber: info.laneNumber,
2030
+ reason: info.reason,
2031
+ });
2032
+ }
2033
+ }
2034
+ };
2035
+
2002
2036
  // ── TP-040 R002: Terminal event emission helper ──────────────
2003
2037
  // Routes all early-return and terminal paths through consistent event
2004
2038
  // emission so external consumers always receive a deterministic terminal
@@ -2312,6 +2346,22 @@ export async function executeOrchBatch(
2312
2346
  // ── TS-009: Persist state on batch start (after wave computation) ──
2313
2347
  persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
2314
2348
 
2349
+ // ── TP-187 (#539): Persist batch-meta runtime artifact ──────────────
2350
+ // Captures the wave plan and core scalars to a runtime-side file that
2351
+ // survives `orch_abort()` (which deletes `.pi/batch-state.json`). Used by
2352
+ // `orch_resume(force=true)` to deterministically reconstruct state when
2353
+ // the main batch-state file is gone. Best-effort write: failures log only.
2354
+ saveBatchMetaRuntimeArtifact(stateRoot, {
2355
+ schemaVersion: 1,
2356
+ batchId: batchState.batchId,
2357
+ wavePlan: wavePlan.map(wave => [...wave]),
2358
+ baseBranch: batchState.baseBranch,
2359
+ orchBranch: batchState.orchBranch,
2360
+ mode: workspaceConfig ? "workspace" : "repo",
2361
+ startedAt: batchState.startedAt,
2362
+ totalWaves: wavePlan.length,
2363
+ });
2364
+
2315
2365
  // ── TP-105: Runtime V2 backend selection ────────────────────
2316
2366
  // Use Runtime V2 (no-TMUX lane-runner) when ALL conditions are met:
2317
2367
  // 1. Exactly one task in the batch
@@ -2507,6 +2557,8 @@ export async function executeOrchBatch(
2507
2557
  excludeExtensions: runnerConfig.worker.excludeExtensions ?? [],
2508
2558
  } : undefined,
2509
2559
  runnerConfig?.workerExcludeExtensions ?? [],
2560
+ emitLaneTerminated,
2561
+ onLaneRespawned ?? undefined,
2510
2562
  );
2511
2563
 
2512
2564
  // ── TP-039: Tier 0 — Stale worktree recovery ────────────
@@ -2530,6 +2582,8 @@ export async function executeOrchBatch(
2530
2582
  emitAlert,
2531
2583
  supervisorAutonomy,
2532
2584
  runnerConfig,
2585
+ emitLaneTerminated,
2586
+ onLaneRespawned ?? undefined,
2533
2587
  );
2534
2588
  if (retryResult) {
2535
2589
  const staleRecovered = !retryResult.allocationError;
@@ -3058,6 +3112,29 @@ export async function executeOrchBatch(
3058
3112
  batchProgress: buildBatchProgressSnapshot(batchState),
3059
3113
  },
3060
3114
  });
3115
+
3116
+ // TP-187 (#538): Hard-fail termination — synchronously drain the
3117
+ // agent's outbox so stale escalations/replies don't get re-discovered
3118
+ // later, then emit lane-terminated so the supervisor process
3119
+ // suppresses any in-transit zombie alerts targeting this lane/agent.
3120
+ if (laneForTask) {
3121
+ const hardFailAgentId = outcome?.sessionName && outcome.sessionName.length > 0
3122
+ ? outcome.sessionName
3123
+ : `${laneForTask.laneSessionId}-worker`;
3124
+ try {
3125
+ const drained = drainAgentOutbox(stateRoot, batchState.batchId, hardFailAgentId);
3126
+ if (drained > 0) {
3127
+ execLog("batch", batchState.batchId, `hard-fail outbox drain: ${drained} entr${drained === 1 ? "y" : "ies"} for ${hardFailAgentId}`);
3128
+ }
3129
+ } catch { /* best effort — do not block termination */ }
3130
+ emitLaneTerminated({
3131
+ laneNumber: laneForTask.laneNumber,
3132
+ agentId: hardFailAgentId,
3133
+ batchId: batchState.batchId,
3134
+ terminatedAt: Date.now(),
3135
+ reason: "hard-fail",
3136
+ });
3137
+ }
3061
3138
  }
3062
3139
 
3063
3140
  // ── TS-009: Persist state after wave execution ──
@@ -1757,6 +1757,8 @@ export async function executeWave(
1757
1757
  reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] },
1758
1758
  workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
1759
1759
  workerExcludeExtensions?: string[],
1760
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
1761
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
1760
1762
  ): Promise<WaveExecutionResult> {
1761
1763
  const startedAt = Date.now();
1762
1764
  const policy = config.failure.on_task_failure;
@@ -1866,7 +1868,7 @@ export async function executeWave(
1866
1868
  ...buildWorkerEnv(workerConfig),
1867
1869
  ...buildReviewerEnv(reviewerConfig),
1868
1870
  ...buildWorkerExcludeEnv(workerExcludeExtensions),
1869
- }, onSupervisorAlert),
1871
+ }, onSupervisorAlert, onLaneTerminated, onLaneRespawned),
1870
1872
  );
1871
1873
 
1872
1874
  // Start monitoring as a sibling async loop
@@ -2577,6 +2579,14 @@ export async function executeLaneV2(
2577
2579
  isWorkspaceMode?: boolean,
2578
2580
  extraEnvVars?: Record<string, string>,
2579
2581
  onSupervisorAlert?: SupervisorAlertCallback,
2582
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
2583
+ /**
2584
+ * TP-187 (#538): Optional callback fired BEFORE the first task of this
2585
+ * lane begins. The supervisor process uses it to lift any zombie-alert
2586
+ * suppression that was applied when this lane number was previously
2587
+ * terminated (e.g., in a prior wave).
2588
+ */
2589
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
2580
2590
  ): Promise<LaneExecutionResult> {
2581
2591
  const laneId = lane.laneId;
2582
2592
  const laneStartTime = Date.now();
@@ -2618,6 +2628,17 @@ export async function executeLaneV2(
2618
2628
  agentPrefix: agentIdPrefix,
2619
2629
  });
2620
2630
 
2631
+ // TP-187 (#538): Lane is freshly starting — emit lane-respawned so any
2632
+ // zombie-alert suppression carried over from a prior wave's termination of
2633
+ // this lane number is lifted before new alerts begin to flow.
2634
+ if (onLaneRespawned) {
2635
+ try {
2636
+ onLaneRespawned(lane.laneNumber, buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"), batchId);
2637
+ } catch (err) {
2638
+ execLog(laneId, "LANE", `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`);
2639
+ }
2640
+ }
2641
+
2621
2642
  for (const task of lane.tasks) {
2622
2643
  const taskSegmentId = task.task.activeSegmentId ?? null;
2623
2644
  if (shouldSkipRemaining || pauseSignal.paused) {
@@ -2675,6 +2696,7 @@ export async function executeLaneV2(
2675
2696
  warnPercent: 85,
2676
2697
  killPercent: 95,
2677
2698
  onSupervisorAlert,
2699
+ onLaneTerminated,
2678
2700
  };
2679
2701
 
2680
2702
  try {