pi-subagents 0.47.0 → 0.48.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/CHANGELOG.md +37 -0
- package/README.md +2 -0
- package/agents/reviewer.md +3 -4
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +16 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +30 -11
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +7 -9
- package/src/extension/schemas.ts +4 -4
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/intercom/native-supervisor-channel.ts +102 -4
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +46 -0
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-job-tracker.ts +279 -134
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +33 -4
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/control-channel.ts +55 -17
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/result-watcher.ts +87 -6
- package/src/runs/background/run-status.ts +23 -1
- package/src/runs/background/scheduled-runs.ts +23 -0
- package/src/runs/background/stale-run-reconciler.ts +10 -3
- package/src/runs/background/subagent-runner.ts +75 -33
- package/src/runs/foreground/async-dismiss-action.ts +85 -0
- package/src/runs/foreground/async-steering-action.ts +6 -7
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +648 -195
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/artifacts.ts +1 -1
- package/src/shared/file-coalescer.ts +9 -0
- package/src/shared/types.ts +97 -1
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- package/src/workflows/scripted-workflow.ts +26 -6
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
5
|
import { appendJsonl } from "../../shared/artifacts.ts";
|
|
6
6
|
import type { AsyncParallelGroupStatus, AsyncStatus, WorkflowGraphNode, WorkflowGraphSnapshot } from "../../shared/types.ts";
|
|
7
|
-
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { PROMPT_REDACTED, readStatus } from "../../shared/utils.ts";
|
|
8
8
|
import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep, RunnerCheckpointStep } from "../shared/parallel-utils.ts";
|
|
9
9
|
import { isCheckpointRunnerStep, isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
|
|
10
10
|
|
|
@@ -19,6 +19,7 @@ export interface ChainAppendRequest {
|
|
|
19
19
|
export interface ChainAppendResult {
|
|
20
20
|
request: ChainAppendRequest;
|
|
21
21
|
pendingCount: number;
|
|
22
|
+
bookkeepingError?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
type StatusStep = NonNullable<AsyncStatus["steps"]>[number];
|
|
@@ -69,6 +70,7 @@ export function enqueueChainAppendRequest(input: {
|
|
|
69
70
|
runId: string;
|
|
70
71
|
steps: RunnerStep[];
|
|
71
72
|
now?: number;
|
|
73
|
+
admit?: (persist: () => void) => void;
|
|
72
74
|
}): ChainAppendResult {
|
|
73
75
|
const status = readStatus(input.asyncDir);
|
|
74
76
|
if (!status) throw new Error(`No async run status found for '${input.runId}'.`);
|
|
@@ -85,20 +87,36 @@ export function enqueueChainAppendRequest(input: {
|
|
|
85
87
|
steps: input.steps,
|
|
86
88
|
};
|
|
87
89
|
fs.mkdirSync(appendDir(input.asyncDir), { recursive: true });
|
|
88
|
-
writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
|
|
89
|
-
|
|
90
|
+
const persist = () => writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
|
|
91
|
+
if (input.admit) input.admit(persist);
|
|
92
|
+
else persist();
|
|
93
|
+
let pendingCount = 1;
|
|
94
|
+
const bookkeepingErrors: string[] = [];
|
|
95
|
+
try {
|
|
96
|
+
pendingCount = countPendingChainAppendRequests(input.asyncDir);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
bookkeepingErrors.push(`pending append count failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
}
|
|
90
100
|
const statusPath = path.join(input.asyncDir, "status.json");
|
|
91
101
|
const updatedStatus = { ...status, pendingAppends: pendingCount, lastUpdate: request.createdAt };
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
try {
|
|
103
|
+
writeAtomicJson(statusPath, updatedStatus);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
bookkeepingErrors.push(`status update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
|
|
109
|
+
type: "subagent.chain.append.requested",
|
|
110
|
+
ts: request.createdAt,
|
|
111
|
+
runId: input.runId,
|
|
112
|
+
requestId: request.id,
|
|
113
|
+
stepCount: input.steps.length,
|
|
114
|
+
pendingAppends: pendingCount,
|
|
115
|
+
}));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
bookkeepingErrors.push(`event append failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
return { request, pendingCount, ...(bookkeepingErrors.length > 0 ? { bookkeepingError: bookkeepingErrors.join("; ") } : {}) };
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
function readAppendRequest(filePath: string): ChainAppendRequest | undefined {
|
|
@@ -135,8 +153,8 @@ const MAX_STATUS_STEP_DESCRIPTION_CHARS = 160;
|
|
|
135
153
|
|
|
136
154
|
/** Bounded one-line per-step task description persisted into status.json for fleet display. */
|
|
137
155
|
export function statusStepDescription(task: string | undefined): string | undefined {
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
if (!task?.trim()) return undefined;
|
|
157
|
+
const description = PROMPT_REDACTED;
|
|
140
158
|
return description.length > MAX_STATUS_STEP_DESCRIPTION_CHARS
|
|
141
159
|
? `${description.slice(0, MAX_STATUS_STEP_DESCRIPTION_CHARS - 1)}…`
|
|
142
160
|
: description;
|
|
@@ -32,6 +32,7 @@ function writeJsonToExistingDir(filePath: string, payload: object): void {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
35
|
+
const CONTROL_SAFETY_POLL_INTERVAL_MS = 5000;
|
|
35
36
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
36
37
|
|
|
37
38
|
export interface InterruptRequest {
|
|
@@ -621,9 +622,9 @@ export function deliverCheckpointDecisionRequest(input: {
|
|
|
621
622
|
|
|
622
623
|
/**
|
|
623
624
|
* Runner side: watch the control inbox and route interrupt requests into
|
|
624
|
-
* `onInterrupt`. Uses `fs.watch` when available
|
|
625
|
-
*
|
|
626
|
-
*
|
|
625
|
+
* `onInterrupt`. Uses `fs.watch` when available and starts interval polling
|
|
626
|
+
* only when native watching is unavailable or fails. Fires once per distinct
|
|
627
|
+
* request. Returns a disposer.
|
|
627
628
|
*/
|
|
628
629
|
export function watchAsyncControlInbox(
|
|
629
630
|
asyncDir: string,
|
|
@@ -636,6 +637,7 @@ export function watchAsyncControlInbox(
|
|
|
636
637
|
onSteerCapability?: (capability: SteerCapability) => void;
|
|
637
638
|
onSteerAck?: (ack: SteerAck) => void;
|
|
638
639
|
pollIntervalMs?: number;
|
|
640
|
+
safetyPollIntervalMs?: number;
|
|
639
641
|
fs?: ControlChannelFs;
|
|
640
642
|
timers?: ControlChannelTimers;
|
|
641
643
|
},
|
|
@@ -669,27 +671,63 @@ export function watchAsyncControlInbox(
|
|
|
669
671
|
// Handle a request that may have arrived before the watcher started.
|
|
670
672
|
check();
|
|
671
673
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
674
|
+
const watchers: fs.FSWatcher[] = [];
|
|
675
|
+
const watchedDirs = new Set<string>();
|
|
676
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
677
|
+
let safetyInterval: ReturnType<typeof setInterval> | undefined;
|
|
678
|
+
const startPolling = (): void => {
|
|
679
|
+
if (interval || disposed) return;
|
|
680
|
+
interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
681
|
+
interval.unref?.();
|
|
682
|
+
};
|
|
683
|
+
const startSafetyPolling = (): void => {
|
|
684
|
+
if (safetyInterval || disposed) return;
|
|
685
|
+
safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
|
|
686
|
+
safetyInterval.unref?.();
|
|
687
|
+
};
|
|
688
|
+
const watchDir = (target: string, create = false): void => {
|
|
689
|
+
if (disposed || watchedDirs.has(target)) return;
|
|
690
|
+
if (create) fsImpl.mkdirSync(target, { recursive: true });
|
|
691
|
+
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => {
|
|
692
|
+
watchExistingSteerAckDirs();
|
|
693
|
+
check();
|
|
677
694
|
});
|
|
695
|
+
watcher.on?.("error", startPolling);
|
|
696
|
+
watchers.push(watcher);
|
|
697
|
+
watchedDirs.add(target);
|
|
698
|
+
};
|
|
699
|
+
const watchExistingSteerAckDirs = (): void => {
|
|
700
|
+
const ackRoot = path.join(dir, STEER_ACKS_DIR);
|
|
701
|
+
let entries: string[];
|
|
702
|
+
try {
|
|
703
|
+
entries = fsImpl.readdirSync(ackRoot).filter((name) => /^\d+$/.test(name));
|
|
704
|
+
} catch {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
for (const entry of entries) watchDir(path.join(ackRoot, entry));
|
|
708
|
+
};
|
|
709
|
+
try {
|
|
710
|
+
watchDir(dir);
|
|
711
|
+
watchDir(steerRequestsDir(asyncDir), true);
|
|
712
|
+
watchDir(steerCapabilitiesDir(asyncDir), true);
|
|
713
|
+
watchDir(path.join(dir, STEER_ACKS_DIR), true);
|
|
714
|
+
watchExistingSteerAckDirs();
|
|
715
|
+
startSafetyPolling();
|
|
678
716
|
} catch {
|
|
679
|
-
|
|
717
|
+
startPolling();
|
|
680
718
|
}
|
|
681
719
|
|
|
682
|
-
const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
683
|
-
interval.unref?.();
|
|
684
|
-
|
|
685
720
|
return () => {
|
|
686
721
|
if (disposed) return;
|
|
687
722
|
disposed = true;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
723
|
+
for (const watcher of watchers) {
|
|
724
|
+
try {
|
|
725
|
+
watcher.close();
|
|
726
|
+
} catch {
|
|
727
|
+
// ignore
|
|
728
|
+
}
|
|
692
729
|
}
|
|
693
|
-
timers.clearInterval(interval);
|
|
730
|
+
if (interval) timers.clearInterval(interval);
|
|
731
|
+
if (safetyInterval) timers.clearInterval(safetyInterval);
|
|
694
732
|
};
|
|
695
733
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { ProcessTreeTerminalV1 } from "../../shared/types.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TERM_GRACE_MS = 3000;
|
|
5
|
+
const DEFAULT_KILL_VERIFY_MS = 1000;
|
|
6
|
+
const VERIFY_INTERVAL_MS = 25;
|
|
7
|
+
|
|
8
|
+
type SignalResult = "sent" | "absent" | { diagnostic: string };
|
|
9
|
+
|
|
10
|
+
function diagnostic(error: unknown): string {
|
|
11
|
+
return error instanceof Error ? error.message : String(error);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function signalProcess(id: number, signal: NodeJS.Signals): SignalResult {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(id, signal);
|
|
17
|
+
return "sent";
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return "absent";
|
|
20
|
+
return { diagnostic: diagnostic(error) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function activeProcessGroupMembers(processGroupId: number): number[] | { diagnostic: string } {
|
|
25
|
+
const result = spawnSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf-8" });
|
|
26
|
+
if (result.error || result.status !== 0) {
|
|
27
|
+
return { diagnostic: result.error ? diagnostic(result.error) : (result.stderr.trim() || `ps exited with ${result.status}`) };
|
|
28
|
+
}
|
|
29
|
+
const members: number[] = [];
|
|
30
|
+
for (const line of result.stdout.split("\n")) {
|
|
31
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
|
|
32
|
+
if (!match || Number(match[2]) !== processGroupId || match[3]!.startsWith("Z")) continue;
|
|
33
|
+
members.push(Number(match[1]));
|
|
34
|
+
}
|
|
35
|
+
return members;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function waitUntilGroupTerminal(
|
|
39
|
+
processGroupId: number,
|
|
40
|
+
timeoutMs: number,
|
|
41
|
+
): Promise<false | { state: "enumeration-failed" | "still-active"; diagnostic: string }> {
|
|
42
|
+
const deadline = Date.now() + timeoutMs;
|
|
43
|
+
while (true) {
|
|
44
|
+
const members = activeProcessGroupMembers(processGroupId);
|
|
45
|
+
if (Array.isArray(members) && members.length === 0) return false;
|
|
46
|
+
const remaining = deadline - Date.now();
|
|
47
|
+
if (remaining <= 0) {
|
|
48
|
+
if (!Array.isArray(members)) return { state: "enumeration-failed", diagnostic: members.diagnostic };
|
|
49
|
+
return { state: "still-active", diagnostic: `Process group ${processGroupId} still has active members: ${members.join(", ")}.` };
|
|
50
|
+
}
|
|
51
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(VERIFY_INTERVAL_MS, remaining)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function observed(processGroupId: number): ProcessTreeTerminalV1 {
|
|
56
|
+
return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Owns one writer process group and arbitrates its cleanup exactly once. */
|
|
60
|
+
export interface OwnedProcessTreeController {
|
|
61
|
+
terminate(): Promise<ProcessTreeTerminalV1>;
|
|
62
|
+
finishAfterWriterClose(): Promise<ProcessTreeTerminalV1>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createOwnedProcessTreeController(
|
|
66
|
+
pid: number,
|
|
67
|
+
options: { termGraceMs?: number; killVerifyMs?: number } = {},
|
|
68
|
+
): OwnedProcessTreeController {
|
|
69
|
+
let termination: Promise<ProcessTreeTerminalV1> | undefined;
|
|
70
|
+
const posixGroupOwned = process.platform !== "win32";
|
|
71
|
+
const target = posixGroupOwned ? -pid : pid;
|
|
72
|
+
|
|
73
|
+
const terminate = (): Promise<ProcessTreeTerminalV1> => {
|
|
74
|
+
if (termination) return termination;
|
|
75
|
+
termination = (async () => {
|
|
76
|
+
if (!posixGroupOwned) {
|
|
77
|
+
signalProcess(target, "SIGTERM");
|
|
78
|
+
return { state: "unknown", reason: "unsupported-platform" };
|
|
79
|
+
}
|
|
80
|
+
const term = signalProcess(target, "SIGTERM");
|
|
81
|
+
if (term !== "sent" && term !== "absent") {
|
|
82
|
+
return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
|
|
83
|
+
}
|
|
84
|
+
const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
|
|
85
|
+
if (termExit === false) return observed(pid);
|
|
86
|
+
|
|
87
|
+
const kill = signalProcess(target, "SIGKILL");
|
|
88
|
+
if (kill !== "sent" && kill !== "absent") {
|
|
89
|
+
const members = activeProcessGroupMembers(pid);
|
|
90
|
+
if (!Array.isArray(members) || members.length > 0) {
|
|
91
|
+
return { state: "unknown", reason: "signal-failed", diagnostic: kill.diagnostic };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const killExit = await waitUntilGroupTerminal(pid, options.killVerifyMs ?? DEFAULT_KILL_VERIFY_MS);
|
|
95
|
+
if (killExit !== false) {
|
|
96
|
+
return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
|
|
97
|
+
}
|
|
98
|
+
return observed(pid);
|
|
99
|
+
})();
|
|
100
|
+
return termination;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return { terminate, finishAfterWriterClose: terminate };
|
|
104
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ProcessTerminalV1,
|
|
11
11
|
} from "../../shared/types.ts";
|
|
12
12
|
import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
|
|
13
|
+
import { releaseActiveRunIndex } from "./active-run-index.ts";
|
|
13
14
|
|
|
14
15
|
export interface ProcessTerminalCandidate {
|
|
15
16
|
version: 1;
|
|
@@ -40,9 +41,19 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
|
|
|
40
41
|
if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
|
|
41
42
|
if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
|
|
42
43
|
if (typeof value.signal !== "string" && value.signal !== null) return false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
if (value.kind === "runner") return value.attempt === undefined;
|
|
45
|
+
if (typeof value.attempt !== "number" || !Number.isInteger(value.attempt) || value.attempt < 0 || !isRecord(value.processTree)) return false;
|
|
46
|
+
if (value.processTree.state === "observed") {
|
|
47
|
+
return value.processTree.mechanism === "posix-process-group"
|
|
48
|
+
&& typeof value.processTree.processGroupId === "number"
|
|
49
|
+
&& Number.isInteger(value.processTree.processGroupId)
|
|
50
|
+
&& value.processTree.processGroupId > 0
|
|
51
|
+
&& typeof value.processTree.verifiedAt === "number"
|
|
52
|
+
&& Number.isFinite(value.processTree.verifiedAt);
|
|
53
|
+
}
|
|
54
|
+
return value.processTree.state === "unknown"
|
|
55
|
+
&& ["unsupported-platform", "signal-failed", "verification-failed"].includes(String(value.processTree.reason))
|
|
56
|
+
&& (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
function validInstance(value: unknown): value is ProcessInstanceExitV1 {
|
|
@@ -249,6 +260,8 @@ export function finalizeProcessTerminal(
|
|
|
249
260
|
proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
|
|
250
261
|
} else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
|
|
251
262
|
proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
|
|
263
|
+
} else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
|
|
264
|
+
proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
|
|
252
265
|
} else {
|
|
253
266
|
const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
|
|
254
267
|
const canonicalSession = session && sessionProjection(candidate, session);
|
|
@@ -271,6 +284,7 @@ export function finalizeProcessTerminal(
|
|
|
271
284
|
try {
|
|
272
285
|
writeAtomicJson(processTerminalPath(asyncDir), proof);
|
|
273
286
|
durable = true;
|
|
287
|
+
if (proof.state === "observed") releaseActiveRunIndex(asyncDir);
|
|
274
288
|
overlayStatus(asyncDir, proof, candidateForOverlay);
|
|
275
289
|
fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.run.process_terminal", lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, ts: Date.now(), runId, processTerminal: proof })}\n`, "utf-8");
|
|
276
290
|
} catch {
|
|
@@ -21,14 +21,15 @@ import {
|
|
|
21
21
|
import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
23
|
import { recordWaitCompletion } from "./wait-completions.ts";
|
|
24
|
-
import { syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
|
|
24
|
+
import { MISSION_BINDING_FILE, syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
|
|
25
25
|
import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
|
|
26
26
|
|
|
27
27
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
28
28
|
const POLL_INTERVAL_MS = 3000;
|
|
29
|
+
const HEALTHY_SCAN_INTERVAL_MS = 60_000;
|
|
29
30
|
const RETRY_DELAY_MS = 100;
|
|
30
31
|
|
|
31
|
-
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
|
|
32
|
+
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "statSync" | "watch">;
|
|
32
33
|
|
|
33
34
|
type ResultWatcherTimers = {
|
|
34
35
|
setTimeout: typeof setTimeout;
|
|
@@ -43,6 +44,10 @@ type ResultWatcherDeps = {
|
|
|
43
44
|
notifier?: Pick<CompletionNotifier, "deliver">;
|
|
44
45
|
/** Receives persisted completions before active-session delivery filtering. */
|
|
45
46
|
observeCompletion?: (result: CompletionNotification & { runId: string }) => void;
|
|
47
|
+
/** Returns cross-session run ids that the completion observer currently owns. */
|
|
48
|
+
observedCompletionRunIds?: () => Iterable<string>;
|
|
49
|
+
/** Parses a relevant result payload after its lightweight identity check. */
|
|
50
|
+
parseResult?: (raw: string) => ResultFileData;
|
|
46
51
|
/** External grouped-result transport. Disable when native completion notifications own delivery. */
|
|
47
52
|
deliverIntercomResults?: boolean;
|
|
48
53
|
};
|
|
@@ -76,6 +81,33 @@ type ResultFileData = CompletionNotification & {
|
|
|
76
81
|
parallelHandoff?: ParallelHandoffReference;
|
|
77
82
|
};
|
|
78
83
|
|
|
84
|
+
type ResultFileIdentity = {
|
|
85
|
+
sessionId?: string;
|
|
86
|
+
runId?: string;
|
|
87
|
+
asyncDir?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function jsonStringProperty(raw: string, property: string): string | undefined {
|
|
91
|
+
const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
|
|
92
|
+
let encoded: string | undefined;
|
|
93
|
+
for (const match of matches) encoded = match[1];
|
|
94
|
+
if (!encoded) return undefined;
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(encoded) as unknown;
|
|
97
|
+
return typeof value === "string" && value ? value : undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resultFileIdentity(raw: string, file: string): ResultFileIdentity {
|
|
104
|
+
return {
|
|
105
|
+
sessionId: jsonStringProperty(raw, "sessionId"),
|
|
106
|
+
runId: file.replace(/\.json$/i, ""),
|
|
107
|
+
asyncDir: jsonStringProperty(raw, "asyncDir"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
function sanitizeNestedResultChildren(value: unknown, resultPath: string, label: string): NestedRunSummary[] | undefined {
|
|
80
112
|
if (value === undefined) return undefined;
|
|
81
113
|
if (!Array.isArray(value)) {
|
|
@@ -121,9 +153,11 @@ export function createResultWatcher(
|
|
|
121
153
|
const fsApi = deps.fs ?? fs;
|
|
122
154
|
const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
|
|
123
155
|
const notifier = deps.notifier ?? { deliver: async () => true };
|
|
156
|
+
const parseResult = deps.parseResult ?? ((raw: string) => JSON.parse(raw) as ResultFileData);
|
|
124
157
|
const deliverIntercomResults = deps.deliverIntercomResults !== false;
|
|
125
158
|
const pendingTriggerTurn = new Map<string, boolean>();
|
|
126
159
|
const processing = new Set<string>();
|
|
160
|
+
const identityCache = new Map<string, { signature: string; identity: ResultFileIdentity }>();
|
|
127
161
|
let deliveryActive = true;
|
|
128
162
|
let deliveryEpoch = 0;
|
|
129
163
|
let resultScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -143,12 +177,51 @@ export function createResultWatcher(
|
|
|
143
177
|
state.resultFileCoalescer.schedule(file, delayMs);
|
|
144
178
|
};
|
|
145
179
|
|
|
180
|
+
const inspectResult = (file: string): ResultFileIdentity | undefined => {
|
|
181
|
+
const resultPath = path.join(resultsDir, file);
|
|
182
|
+
try {
|
|
183
|
+
const stat = fsApi.statSync(resultPath);
|
|
184
|
+
const signature = `${stat.size}:${stat.mtimeMs}`;
|
|
185
|
+
const cached = identityCache.get(file);
|
|
186
|
+
if (cached?.signature === signature) return cached.identity;
|
|
187
|
+
const identity = resultFileIdentity(fsApi.readFileSync(resultPath, "utf-8"), file);
|
|
188
|
+
identityCache.set(file, { signature, identity });
|
|
189
|
+
return identity;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
identityCache.delete(file);
|
|
192
|
+
if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const observedRunIds = (): ReadonlySet<string> => {
|
|
198
|
+
try {
|
|
199
|
+
return new Set(deps.observedCompletionRunIds?.() ?? []);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error("Failed to inspect observed subagent completion ids:", error);
|
|
202
|
+
return new Set();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const shouldProcessResult = (file: string, observed?: ReadonlySet<string>): boolean => {
|
|
207
|
+
const identity = inspectResult(file);
|
|
208
|
+
if (!identity) return false;
|
|
209
|
+
// Missing identity stays on the normal parser path so malformed or legacy
|
|
210
|
+
// files keep their existing diagnostics and compatibility behavior.
|
|
211
|
+
if (!identity.sessionId) return true;
|
|
212
|
+
if (identity.sessionId === state.currentSessionId) return true;
|
|
213
|
+
if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
|
|
214
|
+
if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
|
|
215
|
+
return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
|
|
216
|
+
};
|
|
217
|
+
|
|
146
218
|
const handleResult = async (file: string, triggerTurn: boolean) => {
|
|
147
219
|
const resultPath = path.join(resultsDir, file);
|
|
148
220
|
if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
|
|
221
|
+
if (!shouldProcessResult(file)) return;
|
|
149
222
|
processing.add(file);
|
|
150
223
|
try {
|
|
151
|
-
const data =
|
|
224
|
+
const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
|
|
152
225
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
153
226
|
const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
|
|
154
227
|
try {
|
|
@@ -189,6 +262,7 @@ export function createResultWatcher(
|
|
|
189
262
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
190
263
|
try {
|
|
191
264
|
fsApi.unlinkSync(resultPath);
|
|
265
|
+
identityCache.delete(file);
|
|
192
266
|
} catch (error) {
|
|
193
267
|
if (!isNotFound(error)) {
|
|
194
268
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -314,6 +388,7 @@ export function createResultWatcher(
|
|
|
314
388
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
315
389
|
try {
|
|
316
390
|
fsApi.unlinkSync(resultPath);
|
|
391
|
+
identityCache.delete(file);
|
|
317
392
|
} catch (error) {
|
|
318
393
|
if (!isNotFound(error)) {
|
|
319
394
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -338,7 +413,9 @@ export function createResultWatcher(
|
|
|
338
413
|
const triggerTurn = options.triggerTurn !== false;
|
|
339
414
|
fsApi.readdirSync(resultsDir)
|
|
340
415
|
.filter((f) => f.endsWith(".json"))
|
|
341
|
-
.forEach((file) =>
|
|
416
|
+
.forEach((file) => {
|
|
417
|
+
if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
|
|
418
|
+
});
|
|
342
419
|
} catch (error) {
|
|
343
420
|
if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
|
|
344
421
|
}
|
|
@@ -395,7 +472,10 @@ export function createResultWatcher(
|
|
|
395
472
|
return;
|
|
396
473
|
}
|
|
397
474
|
const fileName = file.toString();
|
|
398
|
-
if (fileName.endsWith(".json"))
|
|
475
|
+
if (fileName.endsWith(".json")) {
|
|
476
|
+
identityCache.delete(fileName);
|
|
477
|
+
scheduleResult(fileName, true);
|
|
478
|
+
}
|
|
399
479
|
});
|
|
400
480
|
state.watcher.on("error", (error) => {
|
|
401
481
|
if (shouldPoll(error)) return startPolling(error);
|
|
@@ -405,7 +485,7 @@ export function createResultWatcher(
|
|
|
405
485
|
scheduleRestart();
|
|
406
486
|
});
|
|
407
487
|
state.watcher.unref?.();
|
|
408
|
-
resultScanTimer = timers.setInterval(primeExistingResults,
|
|
488
|
+
resultScanTimer = timers.setInterval(primeExistingResults, HEALTHY_SCAN_INTERVAL_MS);
|
|
409
489
|
resultScanTimer.unref?.();
|
|
410
490
|
} catch (error) {
|
|
411
491
|
if (shouldPoll(error)) return startPolling(error);
|
|
@@ -430,6 +510,7 @@ export function createResultWatcher(
|
|
|
430
510
|
state.resultFileCoalescer.clear();
|
|
431
511
|
pendingTriggerTurn.clear();
|
|
432
512
|
processing.clear();
|
|
513
|
+
identityCache.clear();
|
|
433
514
|
};
|
|
434
515
|
|
|
435
516
|
return { startResultWatcher, primeExistingResults, stopResultWatcher };
|
|
@@ -7,6 +7,7 @@ import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
|
7
7
|
import { formatModelThinking } from "../../shared/formatters.ts";
|
|
8
8
|
import { formatActivityLabel } from "../../shared/status-format.ts";
|
|
9
9
|
import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
|
|
10
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
10
11
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
11
12
|
import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
|
|
12
13
|
import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
|
|
@@ -18,6 +19,7 @@ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-
|
|
|
18
19
|
import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
|
|
19
20
|
import { readMissionBinding } from "../../missions/lifecycle.ts";
|
|
20
21
|
import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts";
|
|
22
|
+
import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
|
|
21
23
|
|
|
22
24
|
interface RunStatusParams {
|
|
23
25
|
action?: string;
|
|
@@ -315,6 +317,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
if (asyncDir) {
|
|
320
|
+
const diskStatus = readStatus(asyncDir);
|
|
318
321
|
let reconciliation;
|
|
319
322
|
try {
|
|
320
323
|
reconciliation = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
|
|
@@ -327,6 +330,22 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
327
330
|
};
|
|
328
331
|
}
|
|
329
332
|
const status = reconciliation.status;
|
|
333
|
+
if (!status && diskStatus?.displayDismissedAt !== undefined) {
|
|
334
|
+
if (params.view === "transcript") {
|
|
335
|
+
if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
|
|
338
|
+
isError: true,
|
|
339
|
+
details: { mode: "single", results: [] },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { content: [{ type: "text", text: formatAsyncRunTranscript(diskStatus, asyncDir, { index: params.index, lines: params.lines, sessionRoots: deps.sessionRoots }) }], details: { mode: "single", results: [] } };
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: "text", text: `Run: ${diskStatus.runId}\nState: display-dismissed\nDismissed: ${new Date(diskStatus.displayDismissedAt).toISOString()}\nNo running work was terminated.` }],
|
|
346
|
+
details: { mode: "single", results: [] },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
330
349
|
const effectiveRunId = status?.runId ?? resolvedId ?? "unknown";
|
|
331
350
|
const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
|
|
332
351
|
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
@@ -371,6 +390,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
371
390
|
const steeringText = formatSteeringSummary(status);
|
|
372
391
|
const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
|
|
373
392
|
?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
|
|
393
|
+
const runFanoutBudgetDescriptor = readRunFanoutBudgetDescriptor(asyncDir);
|
|
394
|
+
const runFanoutBudget = runFanoutBudgetDescriptor ? getRunFanoutBudgetSnapshot(runFanoutBudgetDescriptor) : status.runFanoutBudget;
|
|
374
395
|
let missionId: string | undefined;
|
|
375
396
|
try {
|
|
376
397
|
missionId = readMissionBinding(asyncDir)?.missionId;
|
|
@@ -392,6 +413,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
392
413
|
statusActivityText ? `Activity: ${statusActivityText}` : undefined,
|
|
393
414
|
steeringText ? `Steering: ${steeringText}` : undefined,
|
|
394
415
|
`Mode: ${status.mode}`,
|
|
416
|
+
runFanoutBudget ? formatRunFanoutBudget(runFanoutBudget) : undefined,
|
|
395
417
|
status.parentWorkflowRunId ? `Workflow parent: ${status.parentWorkflowRunId}${status.workflowKey ? ` (${status.workflowKey})` : ""}` : undefined,
|
|
396
418
|
status.mode === "workflow" && workflowReturnPreview !== undefined ? `Return: ${workflowReturnPreview}` : undefined,
|
|
397
419
|
status.mode === "workflow" && workflowEmitPreview !== undefined ? `Latest emit: ${workflowEmitPreview}` : undefined,
|
|
@@ -448,7 +470,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
448
470
|
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
449
471
|
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
450
472
|
|
|
451
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
|
|
473
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
|
|
452
474
|
}
|
|
453
475
|
}
|
|
454
476
|
|
|
@@ -406,6 +406,29 @@ export class ScheduledRunManager {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
observedCompletionRunIds(): Set<string> {
|
|
410
|
+
const runIds = new Set<string>();
|
|
411
|
+
for (const store of this.stores.values()) {
|
|
412
|
+
let ids: string[];
|
|
413
|
+
try {
|
|
414
|
+
ids = store.ids();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error(`Failed to inspect schedule store '${store.root}' during async completion discovery:`, error);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const id of ids) {
|
|
420
|
+
try {
|
|
421
|
+
for (const run of store.history(id)) {
|
|
422
|
+
if (run.state === "running" && run.asyncId) runIds.add(run.asyncId);
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion discovery:`, error);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return runIds;
|
|
430
|
+
}
|
|
431
|
+
|
|
409
432
|
handleAsyncCompletion(payload: unknown): void {
|
|
410
433
|
if (!payload || typeof payload !== "object") return;
|
|
411
434
|
const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
|