taskplane 0.28.8 → 0.29.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.
@@ -8,9 +8,10 @@ import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter
8
8
  import { userInfo } from "os";
9
9
 
10
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, SupervisorAlertCallback } 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";
12
12
  import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
13
- import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
13
+ import type { TaskExitDiagnostic } from "./diagnostics.ts";
14
+ import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot, writeLaneSnapshot } from "./process-registry.ts";
14
15
  import { allocateLanes } from "./waves.ts";
15
16
  import { resolveOperatorId } from "./naming.ts";
16
17
  import { runGit, runGitWithEnv } from "./git.ts";
@@ -829,18 +830,31 @@ export async function resolveTaskMonitorState(
829
830
  // Assume alive initially, but if stale for >30s consult the registry
830
831
  // to avoid indefinite false "running" if the lane-runner died.
831
832
  const staleMs = snap?.updatedAt ? (now - snap.updatedAt) : 0;
833
+ const trackerAgeMs = now - tracker.firstObservedAt;
832
834
  if (staleMs > 30_000) {
833
835
  // Snapshot hasn't been updated for 30s+ — check registry as fallback.
834
836
  // But also check if the tracker just started (firstObservedAt within
835
837
  // last 60s) — wave transitions can leave stale snapshots from the
836
838
  // prior wave/task while the new worker is still spawning.
837
- const trackerAgeMs = now - tracker.firstObservedAt;
838
839
  if (trackerAgeMs < 60_000) {
839
840
  // New task, stale snapshot — give the worker startup grace period
840
841
  sessionAlive = true;
841
842
  } else {
842
843
  sessionAlive = isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber);
843
844
  }
845
+ } else if (snap == null && trackerAgeMs >= 60_000) {
846
+ // TP-190 (#561 sage post-mortem): when NO snapshot exists at all
847
+ // (not even stale) and the tracker has been observing this task
848
+ // for >= 60s, fall back to the registry liveness check. Without
849
+ // this branch, a snapshot-write failure in the spawn-failure catch
850
+ // (disk full, permission error, transient I/O hiccup) leaves
851
+ // `snap == null` AND `staleMs == 0`, which previously hit the
852
+ // unconditional-alive default below — reintroducing the same
853
+ // monitor hang the spawn-failure catch was supposed to fix.
854
+ // 60s tracker-age threshold matches the existing startup-grace
855
+ // boundary so we don't false-fail a slow-starting worker that
856
+ // hasn't yet written its first snapshot.
857
+ sessionAlive = isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber);
844
858
  } else {
845
859
  sessionAlive = true;
846
860
  }
@@ -1757,6 +1771,8 @@ export async function executeWave(
1757
1771
  reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] },
1758
1772
  workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
1759
1773
  workerExcludeExtensions?: string[],
1774
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
1775
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
1760
1776
  ): Promise<WaveExecutionResult> {
1761
1777
  const startedAt = Date.now();
1762
1778
  const policy = config.failure.on_task_failure;
@@ -1866,7 +1882,7 @@ export async function executeWave(
1866
1882
  ...buildWorkerEnv(workerConfig),
1867
1883
  ...buildReviewerEnv(reviewerConfig),
1868
1884
  ...buildWorkerExcludeEnv(workerExcludeExtensions),
1869
- }, onSupervisorAlert),
1885
+ }, onSupervisorAlert, onLaneTerminated, onLaneRespawned),
1870
1886
  );
1871
1887
 
1872
1888
  // Start monitoring as a sibling async loop
@@ -2577,6 +2593,14 @@ export async function executeLaneV2(
2577
2593
  isWorkspaceMode?: boolean,
2578
2594
  extraEnvVars?: Record<string, string>,
2579
2595
  onSupervisorAlert?: SupervisorAlertCallback,
2596
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback,
2597
+ /**
2598
+ * TP-187 (#538): Optional callback fired BEFORE the first task of this
2599
+ * lane begins. The supervisor process uses it to lift any zombie-alert
2600
+ * suppression that was applied when this lane number was previously
2601
+ * terminated (e.g., in a prior wave).
2602
+ */
2603
+ onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void,
2580
2604
  ): Promise<LaneExecutionResult> {
2581
2605
  const laneId = lane.laneId;
2582
2606
  const laneStartTime = Date.now();
@@ -2618,6 +2642,17 @@ export async function executeLaneV2(
2618
2642
  agentPrefix: agentIdPrefix,
2619
2643
  });
2620
2644
 
2645
+ // TP-187 (#538): Lane is freshly starting — emit lane-respawned so any
2646
+ // zombie-alert suppression carried over from a prior wave's termination of
2647
+ // this lane number is lifted before new alerts begin to flow.
2648
+ if (onLaneRespawned) {
2649
+ try {
2650
+ onLaneRespawned(lane.laneNumber, buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"), batchId);
2651
+ } catch (err) {
2652
+ execLog(laneId, "LANE", `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`);
2653
+ }
2654
+ }
2655
+
2621
2656
  for (const task of lane.tasks) {
2622
2657
  const taskSegmentId = task.task.activeSegmentId ?? null;
2623
2658
  if (shouldSkipRemaining || pauseSignal.paused) {
@@ -2675,6 +2710,7 @@ export async function executeLaneV2(
2675
2710
  warnPercent: 85,
2676
2711
  killPercent: 95,
2677
2712
  onSupervisorAlert,
2713
+ onLaneTerminated,
2678
2714
  };
2679
2715
 
2680
2716
  try {
@@ -2700,17 +2736,90 @@ export async function executeLaneV2(
2700
2736
  } catch (err: unknown) {
2701
2737
  const errMsg = err instanceof Error ? err.message : String(err);
2702
2738
  execLog(laneId, task.taskId, `Runtime V2 execution error: ${errMsg}`);
2739
+
2740
+ // TP-190 (#561): Spawn-stage failures (Pi CLI not findable, worktree
2741
+ // provisioning failure, etc.) reach this catch synchronously —
2742
+ // `spawnAgent()` calls `resolvePiCliPath()` and other resolvers that
2743
+ // throw before any process is registered. Tag the outcome with the
2744
+ // `spawn_failure` ExitClassification so:
2745
+ // 1. The retry classifier (TIER0_RETRYABLE_CLASSIFICATIONS) excludes
2746
+ // it deterministically — spawn errors are never transient.
2747
+ // 2. The supervisor `task-failure` IPC alert can carry
2748
+ // `context.exitCategory = "spawn_failure"` so the playbook can
2749
+ // escalate immediately rather than retrying.
2750
+ // 3. The engine's post-wave logic can transition `phase` to
2751
+ // `"failed"` when every lane in a wave spawn-failed.
2752
+ const spawnExitDiagnostic: TaskExitDiagnostic = {
2753
+ classification: "spawn_failure",
2754
+ exitCode: null,
2755
+ errorMessage: errMsg,
2756
+ tokensUsed: null,
2757
+ contextPct: null,
2758
+ partialProgressCommits: 0,
2759
+ partialProgressBranch: null,
2760
+ durationSec: 0,
2761
+ lastKnownStep: null,
2762
+ lastKnownCheckbox: null,
2763
+ repoId: lane.repoId ?? "default",
2764
+ };
2765
+ const workerAgentId = buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker");
2703
2766
  outcomes.push({
2704
2767
  taskId: task.taskId,
2705
2768
  status: "failed",
2706
2769
  segmentId: taskSegmentId,
2707
2770
  startTime: Date.now(),
2708
2771
  endTime: Date.now(),
2709
- exitReason: `Runtime V2 execution error: ${errMsg}`,
2710
- sessionName: buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
2772
+ exitReason: `spawn failure: ${errMsg}`,
2773
+ sessionName: workerAgentId,
2711
2774
  doneFileFound: false,
2712
2775
  laneNumber: lane.laneNumber,
2776
+ exitDiagnostic: spawnExitDiagnostic,
2713
2777
  });
2778
+
2779
+ // TP-190 (#561): Write a synthetic terminal lane snapshot so the
2780
+ // monitor (`monitorLanes` → `resolveTaskMonitorState`) reads
2781
+ // `snap.taskId === taskId` AND `snap.status === "failed"`, which sets
2782
+ // `sessionAlive = false` and triggers Priority 3 ("Session exited
2783
+ // without .DONE → failed"). Without this, the monitor's
2784
+ // `snap == null` startup-grace branch keeps `sessionAlive = true`
2785
+ // indefinitely and `executeWave` blocks forever on `await
2786
+ // monitorPromise`. Use the full `RuntimeLaneSnapshot` shape so
2787
+ // dashboard consumers stay schema-consistent.
2788
+ try {
2789
+ const spawnFailureSnapshot: RuntimeLaneSnapshot = {
2790
+ batchId,
2791
+ laneNumber: lane.laneNumber,
2792
+ laneId: `lane-${lane.laneNumber}`,
2793
+ repoId: lane.repoId ?? "default",
2794
+ taskId: task.taskId,
2795
+ segmentId: taskSegmentId,
2796
+ status: "failed",
2797
+ worker: {
2798
+ agentId: workerAgentId,
2799
+ status: "crashed",
2800
+ elapsedMs: 0,
2801
+ toolCalls: 0,
2802
+ contextPct: 0,
2803
+ costUsd: 0,
2804
+ lastTool: "",
2805
+ inputTokens: 0,
2806
+ outputTokens: 0,
2807
+ cacheReadTokens: 0,
2808
+ cacheWriteTokens: 0,
2809
+ },
2810
+ reviewer: null,
2811
+ progress: null,
2812
+ updatedAt: Date.now(),
2813
+ };
2814
+ writeLaneSnapshot(stateRoot, batchId, lane.laneNumber, spawnFailureSnapshot as unknown as Record<string, unknown>);
2815
+ } catch (snapErr) {
2816
+ // Best effort — if the snapshot write fails, the monitor's
2817
+ // 30s-staleness fallback (snap with old updatedAt) eventually
2818
+ // kicks in via the registry liveness check. Log so this is
2819
+ // 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)}`);
2821
+ }
2822
+
2714
2823
  shouldSkipRemaining = true;
2715
2824
  }
2716
2825
  }
@@ -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",