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.
- package/bin/get-version.mjs +50 -0
- package/bin/taskplane.mjs +5 -8
- package/extensions/taskplane/agent-bridge-extension.ts +58 -3
- package/extensions/taskplane/agent-host.ts +11 -17
- package/extensions/taskplane/config-schema.ts +17 -12
- package/extensions/taskplane/engine-worker.ts +27 -0
- package/extensions/taskplane/engine.ts +78 -1
- package/extensions/taskplane/execution.ts +23 -1
- package/extensions/taskplane/extension.ts +303 -0
- package/extensions/taskplane/lane-runner.ts +74 -3
- package/extensions/taskplane/mailbox.ts +83 -0
- package/extensions/taskplane/messages.ts +19 -0
- package/extensions/taskplane/path-resolver.ts +63 -24
- package/extensions/taskplane/persistence.ts +378 -3
- package/extensions/taskplane/resume.ts +56 -6
- package/extensions/taskplane/tool-allowlist-constants.ts +37 -0
- package/extensions/taskplane/types.ts +34 -5
- package/extensions/taskplane/worktree.ts +5 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +37 -0
- package/templates/agents/supervisor.md +62 -1
- package/templates/agents/task-worker.md +58 -6
|
@@ -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
|
-
|
|
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}"
|
|
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` +
|