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.
- 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/diagnostics.ts +9 -0
- package/extensions/taskplane/engine-worker.ts +27 -0
- package/extensions/taskplane/engine.ts +235 -1
- package/extensions/taskplane/execution.ts +115 -6
- 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 +64 -7
- package/extensions/taskplane/tool-allowlist-constants.ts +37 -0
- package/extensions/taskplane/types.ts +60 -7
- 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
|
@@ -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 {
|
|
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: `
|
|
2710
|
-
sessionName:
|
|
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",
|