taskplane 0.28.7 → 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.
@@ -42,6 +42,7 @@ import {
42
42
  checkRateLimit,
43
43
  recordSend,
44
44
  appendMailboxAuditEvent,
45
+ drainAgentOutbox,
45
46
  } from "./mailbox.ts";
46
47
  import {
47
48
  readRegistrySnapshot,
@@ -1016,6 +1017,13 @@ export function startBatchInWorker(
1016
1017
  onMonitorUpdate?: (state: import("./types.ts").MonitorState) => void,
1017
1018
  onTerminal?: () => void,
1018
1019
  onSupervisorAlert?: (alert: import("./types.ts").SupervisorAlert) => void,
1020
+ /**
1021
+ * TP-187 (#538): Lane-terminated and lane-respawned IPC events. The
1022
+ * supervisor process tracks terminated lanes/agents and uses this to
1023
+ * suppress zombie alerts from already-dead lanes.
1024
+ */
1025
+ onLaneTerminated?: (info: import("./types.ts").LaneTerminatedInfo) => void,
1026
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
1019
1027
  ): ChildProcess | null {
1020
1028
  const workerPath = resolveEngineWorkerPath();
1021
1029
 
@@ -1053,6 +1061,8 @@ export function startBatchInWorker(
1053
1061
  wkData.force ?? false,
1054
1062
  onSupervisorAlert ?? null,
1055
1063
  wkData.supervisorAutonomy ?? "autonomous",
1064
+ null, // onLaneTerminated — main-thread fallback path; alerts are local-only
1065
+ null, // onLaneRespawned — main-thread fallback path; suppression maps stay clear
1056
1066
  )
1057
1067
  : () => executeOrchBatch(
1058
1068
  wkData.args ?? "",
@@ -1068,6 +1078,8 @@ export function startBatchInWorker(
1068
1078
  null, // onEngineEvent
1069
1079
  onSupervisorAlert ?? null,
1070
1080
  wkData.supervisorAutonomy ?? "autonomous",
1081
+ null, // onLaneTerminated — main-thread fallback path
1082
+ null, // onLaneRespawned — main-thread fallback path
1071
1083
  );
1072
1084
  startBatchAsync(fallbackFn, batchState, ctx, updateWidget, onTerminal);
1073
1085
  return null;
@@ -1170,6 +1182,15 @@ export function startBatchInWorker(
1170
1182
  onSupervisorAlert?.(msg.alert);
1171
1183
  break;
1172
1184
 
1185
+ // TP-187 (#538): Lane termination handling
1186
+ case "lane-terminated":
1187
+ onLaneTerminated?.(msg.info);
1188
+ break;
1189
+
1190
+ case "lane-respawned":
1191
+ onLaneRespawned?.(msg.laneNumber, msg.agentId, msg.batchId);
1192
+ break;
1193
+
1173
1194
  case "state-sync":
1174
1195
  applySerializedState(batchState, msg.state);
1175
1196
  rotateStderrLogToBatch(msg.state.batchId);
@@ -1659,6 +1680,82 @@ export default function (pi: ExtensionAPI) {
1659
1680
  let supervisorState = freshSupervisorState();
1660
1681
  let supervisorConfig: SupervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
1661
1682
 
1683
+ // TP-187 (#538): Zombie-alert filter state
1684
+ // Lane numbers and agent IDs that have reached a terminal state (no-progress
1685
+ // kill, hard-fail, or supervisor-takeover). Supervisor-alert IPC messages
1686
+ // whose context targets a terminated lane/agent are dropped before they
1687
+ // reach pi.sendUserMessage so the operator does not see zombie alerts.
1688
+ //
1689
+ // Lifecycle (Step 1 design):
1690
+ // - Lane reaches terminal state -> add to maps (value = epoch ms)
1691
+ // - Lane re-spawned for fresh task -> remove from maps
1692
+ // - orch_resume() called -> clear both maps
1693
+ // - New batchId observed -> clear both maps
1694
+ // - supervisor_takeover() invoked -> mark all known active lanes/agents
1695
+ const terminatedLanes = new Map<number, number>();
1696
+ const terminatedAgents = new Map<string, number>();
1697
+
1698
+ const clearTerminationFilter = (reason: string): void => {
1699
+ if (terminatedLanes.size === 0 && terminatedAgents.size === 0) return;
1700
+ process.stderr.write(
1701
+ `[taskplane:zombie-filter] cleared termination filter (reason: ${reason}, ` +
1702
+ `lanes=${terminatedLanes.size}, agents=${terminatedAgents.size})\n`,
1703
+ );
1704
+ terminatedLanes.clear();
1705
+ terminatedAgents.clear();
1706
+ };
1707
+
1708
+ /**
1709
+ * TP-187 (#538) — sage post-integration follow-up: gate lane-terminated /
1710
+ * lane-respawned IPC on the current batchId so a stale message from a prior
1711
+ * batch (engine-worker process not yet shut down, or out-of-order delivery)
1712
+ * cannot taint the supervisor's terminated-lane filter for the live batch.
1713
+ * Returns true when the IPC's batchId matches the current batch (or when
1714
+ * the supervisor has not yet seen any state-sync, in which case we accept
1715
+ * the IPC — first batch, no risk of staleness).
1716
+ */
1717
+ const ipcBatchIdMatches = (incomingBatchId: string | undefined): boolean => {
1718
+ // FIX (#559) + sage post-mortem: use `orchBatchState.batchId`, NOT
1719
+ // `batchState.batchId` and NOT `supervisorState.batchId`.
1720
+ //
1721
+ // `batchState` was the original (crashing) reference — NOT bound in
1722
+ // this closure. Other regions of extension.ts legitimately bind a
1723
+ // different `batchState` via destructuring inside their own functions,
1724
+ // but those bindings are not visible here.
1725
+ //
1726
+ // `supervisorState.batchId` (the first attempted fix) is bound but is
1727
+ // only populated when `activateSupervisor()` runs — supervisor activation
1728
+ // is a separate event triggered by alerts/intercepts, not by every batch.
1729
+ // For batches where the supervisor never activates, that field stays
1730
+ // empty for the entire batch and the gate never fires (everything passes
1731
+ // the empty-string accept-all branch), defeating the zombie-alert filter.
1732
+ //
1733
+ // `orchBatchState.batchId` is the canonical live runtime batch ID for
1734
+ // the extension closure: declared on line 1669, populated by the same
1735
+ // state-sync IPC that the supervisor reads from, and reliably present
1736
+ // from the moment the engine-worker emits its first state-sync frame
1737
+ // onward. The only window where it is `""` is the legitimate gap
1738
+ // between batch launch and first state-sync — pre-planning, before any
1739
+ // terminated-lane IPC could fire.
1740
+ const currentBatchId = orchBatchState.batchId;
1741
+ if (!currentBatchId) return true; // no live batch yet — accept
1742
+ if (!incomingBatchId) return true; // legacy IPC without batchId — accept (back-compat)
1743
+ return incomingBatchId === currentBatchId;
1744
+ };
1745
+
1746
+ /**
1747
+ * TP-187 (#538): True iff this alert targets a lane or agent that has
1748
+ * already been marked terminal. Used by the supervisor-alert IPC handler
1749
+ * to drop zombie alerts before they reach pi.sendUserMessage.
1750
+ */
1751
+ const isAlertSuppressed = (alert: import("./types.ts").SupervisorAlert): boolean => {
1752
+ const ctx = alert.context;
1753
+ if (!ctx) return false;
1754
+ if (typeof ctx.laneNumber === "number" && terminatedLanes.has(ctx.laneNumber)) return true;
1755
+ if (typeof ctx.agentId === "string" && ctx.agentId && terminatedAgents.has(ctx.agentId)) return true;
1756
+ return false;
1757
+ };
1758
+
1662
1759
  // Register supervisor prompt hook: while active, injects supervisor
1663
1760
  // system prompt on every LLM turn. No-op when supervisor is inactive.
1664
1761
  registerSupervisorPromptHook(pi, supervisorState);
@@ -2095,6 +2192,9 @@ export default function (pi: ExtensionAPI) {
2095
2192
  orchBatchState = freshOrchBatchState();
2096
2193
  latestMonitorState = null;
2097
2194
 
2195
+ // TP-187 (#538): Clear zombie-alert filter for the new batch.
2196
+ clearTerminationFilter("new_batch_started");
2197
+
2098
2198
  orchBatchState.phase = "launching";
2099
2199
  orchBatchState.startedAt = Date.now();
2100
2200
  updateOrchWidget();
@@ -2205,8 +2305,44 @@ export default function (pi: ExtensionAPI) {
2205
2305
  // ── TP-076: Supervisor alert handler — injects alerts as user messages ──
2206
2306
  (alert) => {
2207
2307
  if (!supervisorState.active) return; // Don't send orphaned messages
2308
+ // TP-187 (#538): Drop zombie alerts for already-terminated lanes/agents.
2309
+ if (isAlertSuppressed(alert)) {
2310
+ process.stderr.write(
2311
+ `[taskplane:zombie-filter] dropped alert (category=${alert.category}, ` +
2312
+ `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`,
2313
+ );
2314
+ return;
2315
+ }
2208
2316
  pi.sendUserMessage(alert.summary, { deliverAs: "followUp" });
2209
2317
  },
2318
+ // TP-187 (#538): Lane-terminated handler.
2319
+ (info) => {
2320
+ if (!ipcBatchIdMatches(info.batchId)) {
2321
+ process.stderr.write(
2322
+ `[taskplane:zombie-filter] ignored stale lane-terminated IPC ` +
2323
+ `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`,
2324
+ );
2325
+ return;
2326
+ }
2327
+ terminatedLanes.set(info.laneNumber, info.terminatedAt);
2328
+ if (info.agentId) terminatedAgents.set(info.agentId, info.terminatedAt);
2329
+ process.stderr.write(
2330
+ `[taskplane:zombie-filter] lane ${info.laneNumber} (${info.agentId}) terminated ` +
2331
+ `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`,
2332
+ );
2333
+ },
2334
+ // TP-187 (#538): Lane-respawned handler.
2335
+ (laneNumber, agentId, incomingBatchId) => {
2336
+ if (!ipcBatchIdMatches(incomingBatchId)) {
2337
+ process.stderr.write(
2338
+ `[taskplane:zombie-filter] ignored stale lane-respawned IPC ` +
2339
+ `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`,
2340
+ );
2341
+ return;
2342
+ }
2343
+ terminatedLanes.delete(laneNumber);
2344
+ if (agentId) terminatedAgents.delete(agentId);
2345
+ },
2210
2346
  );
2211
2347
 
2212
2348
  // Activate supervisor agent
@@ -2439,6 +2575,9 @@ export default function (pi: ExtensionAPI) {
2439
2575
  orchBatchState = freshOrchBatchState();
2440
2576
  latestMonitorState = null;
2441
2577
 
2578
+ // TP-187 (#538): Clear zombie-alert filter so post-resume alerts pass through.
2579
+ clearTerminationFilter("orch_resume_called");
2580
+
2442
2581
  orchBatchState.phase = "launching";
2443
2582
  orchBatchState.startedAt = Date.now();
2444
2583
  updateOrchWidget();
@@ -2542,8 +2681,44 @@ export default function (pi: ExtensionAPI) {
2542
2681
  // ── TP-076: Supervisor alert handler — injects alerts as user messages ──
2543
2682
  (alert) => {
2544
2683
  if (!supervisorState.active) return; // Don't send orphaned messages
2684
+ // TP-187 (#538): Drop zombie alerts for already-terminated lanes/agents.
2685
+ if (isAlertSuppressed(alert)) {
2686
+ process.stderr.write(
2687
+ `[taskplane:zombie-filter] dropped alert (category=${alert.category}, ` +
2688
+ `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`,
2689
+ );
2690
+ return;
2691
+ }
2545
2692
  pi.sendUserMessage(alert.summary, { deliverAs: "followUp" });
2546
2693
  },
2694
+ // TP-187 (#538): Lane-terminated handler.
2695
+ (info) => {
2696
+ if (!ipcBatchIdMatches(info.batchId)) {
2697
+ process.stderr.write(
2698
+ `[taskplane:zombie-filter] ignored stale lane-terminated IPC ` +
2699
+ `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`,
2700
+ );
2701
+ return;
2702
+ }
2703
+ terminatedLanes.set(info.laneNumber, info.terminatedAt);
2704
+ if (info.agentId) terminatedAgents.set(info.agentId, info.terminatedAt);
2705
+ process.stderr.write(
2706
+ `[taskplane:zombie-filter] lane ${info.laneNumber} (${info.agentId}) terminated ` +
2707
+ `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`,
2708
+ );
2709
+ },
2710
+ // TP-187 (#538): Lane-respawned handler.
2711
+ (laneNumber, agentId, incomingBatchId) => {
2712
+ if (!ipcBatchIdMatches(incomingBatchId)) {
2713
+ process.stderr.write(
2714
+ `[taskplane:zombie-filter] ignored stale lane-respawned IPC ` +
2715
+ `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`,
2716
+ );
2717
+ return;
2718
+ }
2719
+ terminatedLanes.delete(laneNumber);
2720
+ if (agentId) terminatedAgents.delete(agentId);
2721
+ },
2547
2722
  );
2548
2723
 
2549
2724
  // Activate supervisor agent on resume
@@ -2676,6 +2851,91 @@ export default function (pi: ExtensionAPI) {
2676
2851
 
2677
2852
  // ── TP-077: Supervisor Recovery Tools ────────────────────────────
2678
2853
 
2854
+ /**
2855
+ * Core logic for `supervisor_takeover(reason)`. Pauses the running wave,
2856
+ * drains all per-agent on-disk outboxes for the current batch, and marks
2857
+ * every active lane as terminated so any in-transit zombie alerts are
2858
+ * suppressed. Distinct from `orch_abort`:
2859
+ * - `orch_abort` kills sessions and deletes batch state (destructive).
2860
+ * - `supervisor_takeover` pauses + drains + parks; worktrees, branches,
2861
+ * state, and sessions all remain so the operator can recover manually.
2862
+ *
2863
+ * @since TP-187 (#538)
2864
+ */
2865
+ function doSupervisorTakeover(reason: string): string {
2866
+ const messages: string[] = [];
2867
+ const trimmedReason = (reason ?? "").trim() || "(no reason provided)";
2868
+ messages.push(`🛡️ Supervisor takeover requested: ${trimmedReason}`);
2869
+
2870
+ // 1. Pause the wave (mirror orch_pause logic but tolerate non-active phases).
2871
+ const pausablePhases = new Set(["launching", "executing", "merging", "planning"]);
2872
+ if (pausablePhases.has(orchBatchState.phase)) {
2873
+ orchBatchState.pauseSignal.paused = true;
2874
+ activeWorker?.send({ type: "pause" });
2875
+ messages.push(` ✓ Wave paused (batch ${orchBatchState.batchId})`);
2876
+ } else {
2877
+ messages.push(` — Batch phase is \`${orchBatchState.phase}\`; no active wave to pause`);
2878
+ }
2879
+
2880
+ // 2. Drain on-disk outboxes for every known agent in the current batch.
2881
+ const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot;
2882
+ let drainedAgents = 0;
2883
+ let drainedMessages = 0;
2884
+ if (stateRoot && orchBatchState.batchId) {
2885
+ try {
2886
+ const agentIds = discoverMailboxAgentIds(stateRoot, orchBatchState.batchId);
2887
+ for (const agentId of agentIds) {
2888
+ try {
2889
+ const n = drainAgentOutbox(stateRoot, orchBatchState.batchId, agentId);
2890
+ if (n > 0) {
2891
+ drainedAgents++;
2892
+ drainedMessages += n;
2893
+ }
2894
+ } catch { /* per-agent drain best-effort */ }
2895
+ }
2896
+ messages.push(
2897
+ ` ✓ Drained on-disk outboxes (${drainedMessages} message(s) across ${drainedAgents} agent(s))`,
2898
+ );
2899
+ } catch (err) {
2900
+ messages.push(
2901
+ ` ⚠ Drain failed: ${err instanceof Error ? err.message : String(err)}`,
2902
+ );
2903
+ }
2904
+ } else {
2905
+ messages.push(" — No active batch state; outbox drain skipped");
2906
+ }
2907
+
2908
+ // 3. Mark all currently-known active lanes/agents as terminated so any
2909
+ // in-transit zombie alerts get filtered. The maps are kept until the next
2910
+ // `orch_resume` (or new batch) per the Step 1 lifecycle.
2911
+ const takeoverTs = Date.now();
2912
+ let markedLanes = 0;
2913
+ for (const lane of orchBatchState.currentLanes ?? []) {
2914
+ terminatedLanes.set(lane.laneNumber, takeoverTs);
2915
+ if (lane.laneSessionId) {
2916
+ terminatedAgents.set(lane.laneSessionId, takeoverTs);
2917
+ terminatedAgents.set(`${lane.laneSessionId}-worker`, takeoverTs);
2918
+ terminatedAgents.set(`${lane.laneSessionId}-reviewer`, takeoverTs);
2919
+ }
2920
+ markedLanes++;
2921
+ }
2922
+ messages.push(
2923
+ ` ✓ Suppressed alerts for ${markedLanes} lane(s) (lifted on next \`orch_resume\`)`,
2924
+ );
2925
+
2926
+ // 4. Worktrees, branches, state, sessions are intentionally NOT touched.
2927
+ messages.push(" ✓ Worktrees, branches, batch state, and sessions preserved");
2928
+
2929
+ messages.push("");
2930
+ messages.push("Recommended next steps:");
2931
+ messages.push(" • `orch_status()` to inspect current state");
2932
+ messages.push(" • `orch_resume(force=true)` to re-engage the batch (clears alert suppression)");
2933
+ messages.push(" • `orch_abort()` if escalation to destructive shutdown is required");
2934
+
2935
+ updateOrchWidget();
2936
+ return messages.join("\n");
2937
+ }
2938
+
2679
2939
  /**
2680
2940
  * Core logic for orch_retry_task. Resets a failed task to pending for re-execution.
2681
2941
  *
@@ -3801,6 +4061,49 @@ export default function (pi: ExtensionAPI) {
3801
4061
  },
3802
4062
  });
3803
4063
 
4064
+ // TP-187 (#538): supervisor_takeover — pause + drain + park (non-destructive).
4065
+ pi.registerTool({
4066
+ name: "supervisor_takeover",
4067
+ label: "Supervisor Takeover",
4068
+ description:
4069
+ "Take manual control of a misbehaving batch without destroying state. " +
4070
+ "Pauses the running wave, drains all per-agent on-disk outboxes, and " +
4071
+ "suppresses any in-transit alerts from already-running lanes so they " +
4072
+ "do not land in your queue as zombie alerts. Worktrees, branches, " +
4073
+ "batch state, and sessions are all preserved — distinct from " +
4074
+ "`orch_abort` which kills sessions and deletes state. Use " +
4075
+ "`orch_resume(force=true)` afterward to re-engage the batch (the " +
4076
+ "alert suppression is lifted automatically on resume).",
4077
+ promptSnippet: "supervisor_takeover(reason) — pause + drain + park for manual recovery",
4078
+ promptGuidelines: [
4079
+ "Call supervisor_takeover when the batch is producing alert spam, " +
4080
+ "hitting a death-spiral pattern, or you need to investigate without " +
4081
+ "continuing execution.",
4082
+ "This is the non-destructive escape hatch. Prefer this over orch_abort " +
4083
+ "when you may want to resume the same batch later.",
4084
+ "Always include a clear `reason` describing what triggered the takeover " +
4085
+ "— it is logged for audit.",
4086
+ "After takeover, call orch_status() to inspect, then either " +
4087
+ "orch_resume(force=true) to continue or orch_abort() to escalate.",
4088
+ ],
4089
+ parameters: Type.Object({
4090
+ reason: Type.String({
4091
+ description: "Why takeover is being requested (logged for audit; required).",
4092
+ }),
4093
+ }),
4094
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
4095
+ try {
4096
+ const result = doSupervisorTakeover(params.reason ?? "");
4097
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
4098
+ } catch (err) {
4099
+ return {
4100
+ content: [{ type: "text" as const, text: `Error during supervisor takeover: ${err instanceof Error ? err.message : String(err)}` }],
4101
+ details: undefined,
4102
+ };
4103
+ }
4104
+ },
4105
+ });
4106
+
3804
4107
  pi.registerTool({
3805
4108
  name: "orch_integrate",
3806
4109
  label: "Integrate Batch",
@@ -53,6 +53,7 @@ import {
53
53
  sessionInboxDir,
54
54
  ackOutboxMessage,
55
55
  appendMailboxAuditEvent,
56
+ drainAgentOutbox,
56
57
  } from "./mailbox.ts";
57
58
 
58
59
  import {
@@ -245,6 +246,14 @@ export interface LaneRunnerConfig {
245
246
  killPercent: number;
246
247
  /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
247
248
  onSupervisorAlert?: SupervisorAlertCallback;
249
+ /**
250
+ * Optional callback fired when the lane reaches a terminal state (no-progress
251
+ * kill or hard-fail). The supervisor process uses this to suppress any
252
+ * subsequent zombie alerts queued for the now-dead lane.
253
+ *
254
+ * @since TP-187 (#538)
255
+ */
256
+ onLaneTerminated?: (info: import("./types.ts").LaneTerminatedInfo) => void;
248
257
  }
249
258
 
250
259
  /**
@@ -656,8 +665,41 @@ export async function executeTaskV2(
656
665
  }
657
666
  } catch { /* If we can't read STATUS.md, proceed with escalation */ }
658
667
 
659
- // No visible progress — compose escalation message
660
- const truncatedMsg = assistantMessage.slice(0, 500);
668
+ // No visible progress — compose escalation message.
669
+ // TP-187 (#540): when the worker exits silently, fall back to the most
670
+ // recent `assistant_message` event in events.jsonl so the supervisor
671
+ // has SOMETHING to act on instead of `Worker said: ""`.
672
+ let workerSaid = (assistantMessage ?? "").trim();
673
+ let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = "current-turn";
674
+ if (!workerSaid) {
675
+ workerSaidSource = "empty-sentinel";
676
+ try {
677
+ const raw = readFileSync(eventsPath, "utf-8");
678
+ const lines = raw.split("\n");
679
+ // Walk backward to find the most recent assistant_message with non-empty text.
680
+ for (let i = lines.length - 1; i >= 0; i--) {
681
+ const line = lines[i].trim();
682
+ if (!line) continue;
683
+ try {
684
+ const evt = JSON.parse(line) as Record<string, unknown>;
685
+ if (evt.type === "assistant_message") {
686
+ const payload = evt.payload as Record<string, unknown> | undefined;
687
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
688
+ if (text) {
689
+ workerSaid = text;
690
+ workerSaidSource = "events-jsonl-fallback";
691
+ break;
692
+ }
693
+ }
694
+ } catch { /* skip malformed line */ }
695
+ }
696
+ } catch { /* events.jsonl unreadable; sentinel will be used */ }
697
+ }
698
+ if (!workerSaid) {
699
+ workerSaid = "(no assistant message captured — worker exited without producing visible output)";
700
+ workerSaidSource = "empty-sentinel";
701
+ }
702
+ const truncatedMsg = workerSaid.slice(0, 500);
661
703
  const uncheckedItems: string[] = [];
662
704
  try {
663
705
  const statusContent = readFileSync(statusPath, "utf-8");
@@ -693,7 +735,12 @@ export async function executeTaskV2(
693
735
  ` Current step: ${currentStepInfo}\n` +
694
736
  ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
695
737
  ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
696
- ` Worker said: "${truncatedMsg}"\n` +
738
+ ` Worker said: "${truncatedMsg}"` +
739
+ (workerSaidSource === "events-jsonl-fallback"
740
+ ? ` (fallback: most-recent assistant_message from events.jsonl)\n`
741
+ : workerSaidSource === "empty-sentinel"
742
+ ? ` (no assistant message captured this iteration)\n`
743
+ : "\n") +
697
744
  `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
698
745
  ` or reply "skip" / "let it fail" to close the session.`,
699
746
  context: {
@@ -978,6 +1025,30 @@ export async function executeTaskV2(
978
1025
  `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
979
1026
  if (noProgressCount >= config.noProgressLimit) {
980
1027
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
1028
+ // TP-187 (#538): synchronous outbox drain at lane-termination decision
1029
+ // point. Purges any pending escalations/replies/segment-expansions the
1030
+ // worker emitted just before termination so they are not later re-
1031
+ // discovered and re-forwarded as zombie supervisor alerts.
1032
+ try {
1033
+ const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
1034
+ if (drained > 0) {
1035
+ logExecution(statusPath, "Outbox drained",
1036
+ `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`);
1037
+ }
1038
+ } catch { /* best effort — do not block termination */ }
1039
+ // TP-187 (#538): notify the supervisor process so it can suppress any
1040
+ // further alerts queued for this lane (zombie-alert filter).
1041
+ if (config.onLaneTerminated) {
1042
+ try {
1043
+ config.onLaneTerminated({
1044
+ laneNumber: config.laneNumber,
1045
+ agentId: workerAgentId,
1046
+ batchId: config.batchId,
1047
+ terminatedAt: Date.now(),
1048
+ reason: "no-progress-kill",
1049
+ });
1050
+ } catch { /* best effort */ }
1051
+ }
981
1052
  return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
982
1053
  `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
983
1054
  }
@@ -538,6 +538,89 @@ export function ackOutboxMessage(
538
538
  }
539
539
  }
540
540
 
541
+ /**
542
+ * Drain (purge to processed/) all pending outbox messages for an agent.
543
+ *
544
+ * Used at lane-termination decision points to ensure stale escalations or
545
+ * replies that the worker emitted just before termination don't get later
546
+ * re-discovered and re-forwarded as zombie supervisor alerts. The drain
547
+ * mirrors {@link ackOutboxMessage} — each pending `*.msg.json` file is
548
+ * moved to `outbox/processed/` so it remains in the durable history (for
549
+ * `read_agent_replies`) but is no longer pending.
550
+ *
551
+ * Best-effort: any per-file failure is logged but does not abort the drain.
552
+ * Returns the number of messages successfully drained.
553
+ *
554
+ * Also drains any non-message pending files in the outbox (e.g.,
555
+ * `segment-expansion-*.json` requests) by renaming them to a `.drained`
556
+ * sibling so the engine's discovery scans don't re-pick them up.
557
+ *
558
+ * @since TP-187 (#538)
559
+ */
560
+ export function drainAgentOutbox(
561
+ stateRoot: string,
562
+ batchId: string,
563
+ agentId: string,
564
+ ): number {
565
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
566
+ if (!existsSync(outboxDir)) return 0;
567
+
568
+ let entries: string[] = [];
569
+ try {
570
+ entries = readdirSync(outboxDir);
571
+ } catch (err) {
572
+ process.stderr.write(
573
+ `[mailbox] WARNING: drainAgentOutbox failed to read ${outboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
574
+ );
575
+ return 0;
576
+ }
577
+
578
+ let drained = 0;
579
+ const processedDir = join(outboxDir, "processed");
580
+ let processedDirEnsured = false;
581
+
582
+ for (const entry of entries) {
583
+ // Skip the processed/ subdirectory itself and any in-flight temp writes.
584
+ if (entry === "processed" || entry.endsWith(".tmp")) continue;
585
+
586
+ const srcPath = join(outboxDir, entry);
587
+
588
+ if (entry.endsWith(".msg.json")) {
589
+ if (!processedDirEnsured) {
590
+ try { mkdirSync(processedDir, { recursive: true }); } catch { /* fall through to rename error handling */ }
591
+ processedDirEnsured = true;
592
+ }
593
+ const dstPath = join(processedDir, entry);
594
+ try {
595
+ renameSync(srcPath, dstPath);
596
+ drained++;
597
+ } catch (err: unknown) {
598
+ const code = (err as NodeJS.ErrnoException).code;
599
+ if (code === "ENOENT") continue; // already gone — race-safe
600
+ process.stderr.write(
601
+ `[mailbox] WARNING: drainAgentOutbox failed to rename ${entry}: ${err instanceof Error ? err.message : String(err)}\n`,
602
+ );
603
+ }
604
+ continue;
605
+ }
606
+
607
+ // Non-message pending files (e.g., segment-expansion-*.json). Rename in
608
+ // place to a `.drained` suffix so engine.ts discovery scans skip them.
609
+ try {
610
+ renameSync(srcPath, `${srcPath}.drained`);
611
+ drained++;
612
+ } catch (err: unknown) {
613
+ const code = (err as NodeJS.ErrnoException).code;
614
+ if (code === "ENOENT") continue;
615
+ process.stderr.write(
616
+ `[mailbox] WARNING: drainAgentOutbox failed to mark ${entry} drained: ${err instanceof Error ? err.message : String(err)}\n`,
617
+ );
618
+ }
619
+ }
620
+
621
+ return drained;
622
+ }
623
+
541
624
  /**
542
625
  * Discover all agent IDs that have mailbox directories for a batch.
543
626
  * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
@@ -104,6 +104,25 @@ export const ORCH_MESSAGES = {
104
104
  resumeNoState: () =>
105
105
  `❌ No batch to resume. No batch-state.json file found.\n` +
106
106
  ` Use /orch <areas|all> to start a new batch.`,
107
+
108
+ /**
109
+ * TP-187 (#539): Successful reconstruction from .pi/runtime/<batchId>/
110
+ * runtime artifacts during force-resume after `orch_abort()`.
111
+ */
112
+ resumeReconstructed: (batchId: string, selectionNote: string) =>
113
+ `🔨 Reconstructed batch ${batchId} from .pi/runtime/ artifacts (${selectionNote}).\n` +
114
+ ` Force-resume will proceed with a fresh wave-zero pass; the existing\n` +
115
+ ` reconciliation logic will re-detect succeeded tasks via .DONE markers.`,
116
+
117
+ /**
118
+ * TP-187 (#539): Fail-loud message when force-resume can't reconstruct
119
+ * after `orch_abort()` because required runtime artifacts are missing.
120
+ */
121
+ resumeNoStateAfterAbort: (missingArtifact: string, batchId: string | null) =>
122
+ `❌ Cannot resume after abort: ${missingArtifact}.\n` +
123
+ (batchId ? ` Last known batch: ${batchId}.\n` : "") +
124
+ ` To start fresh from the preserved worktree state, run\n` +
125
+ ` \`orch_start <PROMPT.md>\` (or \`/orch <areas|all>\`).`,
107
126
  resumeInvalidState: (error: string) =>
108
127
  `❌ Cannot resume: batch state file is invalid.\n` +
109
128
  ` Error: ${error}\n` +