pi-subagents 0.35.1 → 0.37.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 +71 -0
- package/README.md +224 -31
- package/agents/advisor.md +73 -0
- package/agents/delegate.md +2 -0
- package/agents/worker.md +2 -0
- package/package.json +11 -13
- package/skills/pi-subagents/SKILL.md +42 -13
- package/src/agents/agent-management.ts +7 -1
- package/src/agents/agents.ts +121 -17
- package/src/api/capability-ceiling.ts +17 -0
- package/src/api/delegation.ts +127 -0
- package/src/api/preflight.ts +399 -0
- package/src/extension/config.ts +7 -1
- package/src/extension/index.ts +52 -38
- package/src/extension/rpc.ts +31 -2
- package/src/extension/schemas.ts +29 -3
- package/src/extension/tool-description.ts +4 -2
- package/src/intercom/intercom-bridge.ts +1 -1
- package/src/intercom/native-supervisor-channel.ts +45 -6
- package/src/intercom/result-intercom.ts +7 -0
- package/src/runs/background/async-execution.ts +158 -17
- package/src/runs/background/async-job-tracker.ts +4 -0
- package/src/runs/background/async-resume.ts +47 -8
- package/src/runs/background/async-status.ts +94 -3
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/completion-batcher.ts +6 -4
- package/src/runs/background/completion-dedupe.ts +2 -11
- package/src/runs/background/fleet-view.ts +9 -4
- package/src/runs/background/notify.ts +132 -120
- package/src/runs/background/process-terminal.ts +280 -0
- package/src/runs/background/result-watcher.ts +138 -78
- package/src/runs/background/run-status.ts +10 -2
- package/src/runs/background/scheduled-runs.ts +6 -1
- package/src/runs/background/stale-run-reconciler.ts +6 -0
- package/src/runs/background/subagent-runner.ts +406 -54
- package/src/runs/background/subagent-wait.ts +130 -4
- package/src/runs/background/wait-tool.ts +2 -2
- package/src/runs/foreground/chain-execution.ts +181 -111
- package/src/runs/foreground/execution.ts +147 -47
- package/src/runs/foreground/foreground-control.ts +90 -0
- package/src/runs/foreground/subagent-executor.ts +426 -165
- package/src/runs/shared/acceptance.ts +94 -41
- package/src/runs/shared/agent-contract.ts +38 -0
- package/src/runs/shared/capability-ceiling.ts +177 -0
- package/src/runs/shared/child-protocol.ts +1 -1
- package/src/runs/shared/completion-guard.ts +36 -5
- package/src/runs/shared/context-mode.ts +44 -0
- package/src/runs/shared/dynamic-fanout.ts +5 -5
- package/src/runs/shared/long-running-guard.ts +4 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
- package/src/runs/shared/nested-events.ts +35 -3
- package/src/runs/shared/parallel-handoff.ts +154 -0
- package/src/runs/shared/parallel-utils.ts +11 -0
- package/src/runs/shared/pi-args.ts +148 -56
- package/src/runs/shared/run-history.ts +90 -5
- package/src/runs/shared/session-lease.ts +25 -5
- package/src/runs/shared/structured-output.ts +112 -7
- package/src/runs/shared/subagent-control.ts +4 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +31 -19
- package/src/runs/shared/task-intent.ts +10 -5
- package/src/runs/shared/tool-availability.ts +21 -3
- package/src/runs/shared/tool-budget.ts +11 -5
- package/src/runs/shared/turn-budget.ts +2 -1
- package/src/runs/shared/worktree.ts +63 -14
- package/src/shared/accessible-dir.ts +25 -0
- package/src/shared/artifacts.ts +37 -7
- package/src/shared/atomic-json.ts +14 -42
- package/src/shared/child-transcript.ts +52 -0
- package/src/shared/file-system-retry.ts +47 -0
- package/src/shared/launch-contract.ts +123 -0
- package/src/shared/settings.ts +9 -1
- package/src/shared/types.ts +314 -28
- package/src/shared/utils.ts +17 -42
- package/src/slash/delegation-adapters.ts +153 -6
- package/src/slash/delegation-json.ts +108 -0
- package/src/slash/delegation-request.ts +182 -36
- package/src/slash/prompt-template-bridge.ts +222 -37
- package/src/slash/selector.ts +147 -0
- package/src/slash/slash-commands.ts +15 -6
- package/src/slash/slash-live-state.ts +2 -2
- package/src/slash/subagents-admin.ts +42 -42
- package/src/tui/fleet-status.ts +362 -0
- package/src/tui/fleet-transcript.ts +472 -0
- package/src/tui/fleet.ts +318 -59
- package/src/tui/render.ts +17 -15
- package/src/watchdog/change-signature.ts +105 -12
- package/src/watchdog/review.ts +7 -2
- package/src/watchdog/runtime.ts +5 -3
- package/src/slash/subagents-editor.ts +0 -86
|
@@ -1,28 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Completion notification delivery.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* immediately, flushing any held successes first, so failure and attention
|
|
8
|
-
* signals are never delayed.
|
|
4
|
+
* Async result files call this notifier directly and are deleted only after
|
|
5
|
+
* `sendMessage()` accepts the notification. The event bus remains an
|
|
6
|
+
* observation channel, not a delivery acknowledgement.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { buildCompletionKey,
|
|
10
|
+
import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
|
|
13
11
|
import {
|
|
14
12
|
type CompletionBatchConfig,
|
|
15
13
|
type CompletionBatcher,
|
|
16
14
|
createCompletionBatcher,
|
|
17
15
|
resolveCompletionBatchConfig,
|
|
18
16
|
} from "./completion-batcher.ts";
|
|
19
|
-
import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type SubagentState } from "../../shared/types.ts";
|
|
20
|
-
|
|
21
|
-
interface ChainStepResult {
|
|
22
|
-
agent: string;
|
|
23
|
-
output: string;
|
|
24
|
-
success: boolean;
|
|
25
|
-
}
|
|
17
|
+
import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type SubagentState } from "../../shared/types.ts";
|
|
26
18
|
|
|
27
19
|
export interface SubagentNotifyDetails {
|
|
28
20
|
agent: string;
|
|
@@ -33,27 +25,30 @@ export interface SubagentNotifyDetails {
|
|
|
33
25
|
durationMs?: number;
|
|
34
26
|
sessionLabel?: string;
|
|
35
27
|
sessionValue?: string;
|
|
28
|
+
handoffPath?: string;
|
|
36
29
|
}
|
|
37
30
|
|
|
38
|
-
interface
|
|
39
|
-
|
|
31
|
+
export interface CompletionNotification {
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
id?: string | null;
|
|
40
34
|
source?: "async" | "foreground";
|
|
41
|
-
agent
|
|
42
|
-
success
|
|
43
|
-
summary
|
|
35
|
+
agent?: string | null;
|
|
36
|
+
success?: boolean;
|
|
37
|
+
summary?: string;
|
|
44
38
|
exitCode?: number;
|
|
45
39
|
state?: string;
|
|
46
|
-
timestamp
|
|
40
|
+
timestamp?: number;
|
|
47
41
|
durationMs?: number;
|
|
48
42
|
cwd?: string;
|
|
49
43
|
sessionFile?: string;
|
|
50
44
|
shareUrl?: string;
|
|
51
45
|
gistUrl?: string;
|
|
52
46
|
shareError?: string;
|
|
53
|
-
results?: ChainStepResult[];
|
|
54
47
|
taskIndex?: number;
|
|
55
48
|
totalTasks?: number;
|
|
56
49
|
sessionId?: string | null;
|
|
50
|
+
triggerTurn?: boolean;
|
|
51
|
+
parallelHandoff?: ParallelHandoffReference;
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
interface NotifyTimerApi {
|
|
@@ -67,6 +62,11 @@ export interface RegisterSubagentNotifyOptions {
|
|
|
67
62
|
now?: () => number;
|
|
68
63
|
}
|
|
69
64
|
|
|
65
|
+
export interface CompletionNotifier {
|
|
66
|
+
deliver(result: CompletionNotification): Promise<boolean>;
|
|
67
|
+
dispose(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
70
|
function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
|
|
71
71
|
if (!details.sessionValue) return undefined;
|
|
72
72
|
return details.sessionLabel ? `${details.sessionLabel}: ${details.sessionValue}` : details.sessionValue;
|
|
@@ -79,6 +79,8 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
|
79
79
|
`${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
|
|
80
80
|
"",
|
|
81
81
|
details.resultPreview.trim() ? details.resultPreview : "(no output)",
|
|
82
|
+
details.handoffPath ? "" : undefined,
|
|
83
|
+
details.handoffPath ? `Parallel handoff: ${details.handoffPath}` : undefined,
|
|
82
84
|
sessionLine ? "" : undefined,
|
|
83
85
|
sessionLine,
|
|
84
86
|
]
|
|
@@ -99,7 +101,12 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
const sessionLine = sessionIndex >= 0 ? body[sessionIndex] : undefined;
|
|
102
|
-
const
|
|
104
|
+
const handoffIndex = body.findIndex((line) => line.startsWith("Parallel handoff: "));
|
|
105
|
+
const metadataIndexes = [sessionIndex, handoffIndex].filter((index) => index >= 0);
|
|
106
|
+
const firstMetadataIndex = metadataIndexes.length ? Math.min(...metadataIndexes) : body.length;
|
|
107
|
+
const resultEnd = firstMetadataIndex > 0 && body[firstMetadataIndex - 1]?.trim() === "" ? firstMetadataIndex - 1 : firstMetadataIndex;
|
|
108
|
+
const resultPreview = body.slice(0, resultEnd).join("\n").trim() || "(no output)";
|
|
109
|
+
const handoffPath = handoffIndex >= 0 ? body[handoffIndex]!.slice("Parallel handoff: ".length).trim() : undefined;
|
|
103
110
|
let sessionLabel: string | undefined;
|
|
104
111
|
let sessionValue: string | undefined;
|
|
105
112
|
if (sessionLine) {
|
|
@@ -113,6 +120,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
113
120
|
...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
|
|
114
121
|
...(match[4] ? { taskInfo: match[4] } : {}),
|
|
115
122
|
resultPreview,
|
|
123
|
+
...(handoffPath ? { handoffPath } : {}),
|
|
116
124
|
...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
|
|
117
125
|
};
|
|
118
126
|
}
|
|
@@ -126,35 +134,47 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
126
134
|
const sessionLine = formatSessionLine(detail);
|
|
127
135
|
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}`);
|
|
128
136
|
blocks.push(detail.resultPreview.trim() ? detail.resultPreview : "(no output)");
|
|
137
|
+
if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
|
|
129
138
|
if (sessionLine) blocks.push(sessionLine);
|
|
130
139
|
blocks.push("");
|
|
131
140
|
}
|
|
132
141
|
return blocks.join("\n").trimEnd();
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
interface PendingCompletion {
|
|
145
|
+
key: string;
|
|
146
|
+
details: SubagentNotifyDetails;
|
|
147
|
+
triggerTurn: boolean;
|
|
148
|
+
resolve(accepted: boolean): void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sendCompletion(pi: Pick<ExtensionAPI, "sendMessage">, items: PendingCompletion[]): boolean {
|
|
152
|
+
if (items.length === 0) return true;
|
|
153
|
+
const details = items.map((item) => item.details);
|
|
154
|
+
const content = details.length === 1 ? formatSingleCompletion(details[0]!) : formatGroupedCompletion(details);
|
|
155
|
+
try {
|
|
156
|
+
pi.sendMessage(
|
|
157
|
+
{
|
|
158
|
+
customType: "subagent-notify",
|
|
159
|
+
content,
|
|
160
|
+
display: true,
|
|
161
|
+
},
|
|
162
|
+
{ triggerTurn: items.some((item) => item.triggerTurn) },
|
|
163
|
+
);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
148
168
|
}
|
|
149
169
|
|
|
150
|
-
function completionBatchKey(result:
|
|
170
|
+
function completionBatchKey(result: CompletionNotification): string {
|
|
151
171
|
const sessionId = typeof result.sessionId === "string" ? result.sessionId.trim() : "";
|
|
152
172
|
if (sessionId) return `session:${sessionId}`;
|
|
153
173
|
const cwd = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
|
154
174
|
return cwd ? `cwd:${cwd}` : "unknown";
|
|
155
175
|
}
|
|
156
176
|
|
|
157
|
-
export function buildCompletionDetails(result:
|
|
177
|
+
export function buildCompletionDetails(result: CompletionNotification): SubagentNotifyDetails {
|
|
158
178
|
const agent = result.agent ?? "unknown";
|
|
159
179
|
const summary = typeof result.summary === "string" ? result.summary : "";
|
|
160
180
|
const paused = !result.success && (
|
|
@@ -163,12 +183,15 @@ export function buildCompletionDetails(result: SubagentResult): SubagentNotifyDe
|
|
|
163
183
|
|| summary.startsWith("Paused after interrupt.")
|
|
164
184
|
);
|
|
165
185
|
const status = paused ? "paused" : result.success ? "completed" : "failed";
|
|
166
|
-
|
|
167
186
|
const taskInfo =
|
|
168
187
|
result.taskIndex !== undefined && result.totalTasks !== undefined
|
|
169
188
|
? ` (${result.taskIndex + 1}/${result.totalTasks})`
|
|
170
189
|
: undefined;
|
|
171
190
|
|
|
191
|
+
const parallelHandoff = result.parallelHandoff && typeof result.parallelHandoff === "object"
|
|
192
|
+
? result.parallelHandoff as { path?: unknown }
|
|
193
|
+
: undefined;
|
|
194
|
+
const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
|
|
172
195
|
const session =
|
|
173
196
|
result.shareUrl
|
|
174
197
|
? { label: "Session", value: result.shareUrl }
|
|
@@ -177,7 +200,6 @@ export function buildCompletionDetails(result: SubagentResult): SubagentNotifyDe
|
|
|
177
200
|
: result.sessionFile
|
|
178
201
|
? { label: "Session file", value: result.sessionFile }
|
|
179
202
|
: undefined;
|
|
180
|
-
|
|
181
203
|
return {
|
|
182
204
|
agent,
|
|
183
205
|
status,
|
|
@@ -185,6 +207,7 @@ export function buildCompletionDetails(result: SubagentResult): SubagentNotifyDe
|
|
|
185
207
|
...(taskInfo ? { taskInfo } : {}),
|
|
186
208
|
resultPreview: summary,
|
|
187
209
|
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
|
|
210
|
+
...(handoffPath ? { handoffPath } : {}),
|
|
188
211
|
...(session ? { sessionLabel: session.label, sessionValue: session.value } : {}),
|
|
189
212
|
};
|
|
190
213
|
}
|
|
@@ -193,102 +216,91 @@ export default function registerSubagentNotify(
|
|
|
193
216
|
pi: ExtensionAPI,
|
|
194
217
|
state: Pick<SubagentState, "currentSessionId">,
|
|
195
218
|
options: RegisterSubagentNotifyOptions = {},
|
|
196
|
-
):
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const globalStore = globalThis as Record<string, unknown>;
|
|
200
|
-
const previousUnsubscribe = globalStore[unsubscribeStoreKey];
|
|
201
|
-
if (typeof previousUnsubscribe === "function") {
|
|
202
|
-
try {
|
|
203
|
-
previousUnsubscribe();
|
|
204
|
-
} catch {
|
|
205
|
-
// Best effort cleanup for stale handlers from an older reload.
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
const previousBatcher = globalStore[batcherStoreKey];
|
|
209
|
-
if (previousBatcher && typeof (previousBatcher as { dispose?: () => void }).dispose === "function") {
|
|
210
|
-
try {
|
|
211
|
-
(previousBatcher as { dispose: () => void }).dispose();
|
|
212
|
-
} catch {
|
|
213
|
-
// Best effort cleanup for a stale batcher from an older reload.
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
|
|
219
|
+
): CompletionNotifier {
|
|
220
|
+
const seen = new Map<string, number>();
|
|
221
|
+
const pending = new Map<string, Promise<boolean>>();
|
|
218
222
|
const ttlMs = 10 * 60 * 1000;
|
|
219
|
-
const
|
|
223
|
+
const now = options.now ?? Date.now;
|
|
220
224
|
const batchConfig = resolveCompletionBatchConfig(options.batchConfig);
|
|
221
|
-
const batchers = new Map<string, CompletionBatcher<
|
|
225
|
+
const batchers = new Map<string, CompletionBatcher<PendingCompletion>>();
|
|
222
226
|
let disposed = false;
|
|
223
227
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const key = buildCompletionKey(result, "notify");
|
|
230
|
-
if (markSeenWithTtl(seen, key, now, ttlMs)) return;
|
|
231
|
-
|
|
232
|
-
const details = buildCompletionDetails(result);
|
|
233
|
-
if (result.source === "foreground") {
|
|
234
|
-
sendCompletion(pi, [details]);
|
|
235
|
-
return;
|
|
228
|
+
const settle = (items: PendingCompletion[], accepted: boolean) => {
|
|
229
|
+
for (const item of items) {
|
|
230
|
+
pending.delete(item.key);
|
|
231
|
+
if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
|
|
232
|
+
item.resolve(accepted);
|
|
236
233
|
}
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
};
|
|
235
|
+
const emit = (items: PendingCompletion[]) => settle(items, sendCompletion(pi, items));
|
|
236
|
+
const getBatcher = (result: CompletionNotification) => {
|
|
237
|
+
const key = completionBatchKey(result);
|
|
238
|
+
let batcher = batchers.get(key);
|
|
239
239
|
if (!batcher) {
|
|
240
|
-
batcher = createCompletionBatcher<
|
|
240
|
+
batcher = createCompletionBatcher<PendingCompletion>({
|
|
241
241
|
config: batchConfig,
|
|
242
|
-
emit
|
|
242
|
+
emit,
|
|
243
243
|
...(options.timers ? { timers: options.timers } : {}),
|
|
244
|
-
now
|
|
244
|
+
now,
|
|
245
245
|
});
|
|
246
|
-
batchers.set(
|
|
246
|
+
batchers.set(key, batcher);
|
|
247
247
|
}
|
|
248
|
+
return batcher;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const deliver = (result: CompletionNotification): Promise<boolean> => {
|
|
252
|
+
if (disposed || typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return Promise.resolve(false);
|
|
253
|
+
const key = buildCompletionKey(result, "notify");
|
|
254
|
+
const seenAt = seen.get(key);
|
|
255
|
+
if (seenAt !== undefined && now() - seenAt <= ttlMs) return Promise.resolve(true);
|
|
256
|
+
if (seenAt !== undefined) seen.delete(key);
|
|
257
|
+
const inFlight = pending.get(key);
|
|
258
|
+
if (inFlight) return inFlight;
|
|
259
|
+
const details = buildCompletionDetails(result);
|
|
260
|
+
let resolve!: (accepted: boolean) => void;
|
|
261
|
+
const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
|
|
262
|
+
pending.set(key, completion);
|
|
263
|
+
const item: PendingCompletion = {
|
|
264
|
+
key,
|
|
265
|
+
details,
|
|
266
|
+
triggerTurn: result.triggerTurn !== false,
|
|
267
|
+
resolve,
|
|
268
|
+
};
|
|
269
|
+
if (details.source === "foreground") {
|
|
270
|
+
emit([item]);
|
|
271
|
+
return completion;
|
|
272
|
+
}
|
|
273
|
+
const batcher = getBatcher(result);
|
|
248
274
|
if (details.status !== "completed") {
|
|
249
|
-
// Failures and paused runs bypass grouping. Flush any held
|
|
250
|
-
// successes for the same owner first so they are not stranded
|
|
251
|
-
// behind this signal, then emit the non-completion result immediately.
|
|
252
275
|
batcher.flush();
|
|
253
|
-
|
|
254
|
-
return;
|
|
276
|
+
emit([item]);
|
|
277
|
+
return completion;
|
|
255
278
|
}
|
|
256
|
-
batcher.push(
|
|
279
|
+
batcher.push(item);
|
|
280
|
+
return completion;
|
|
257
281
|
};
|
|
258
282
|
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
283
|
+
const unsubscribeAsync = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, (data) => {
|
|
284
|
+
void deliver(data as CompletionNotification);
|
|
285
|
+
});
|
|
286
|
+
const unsubscribeForeground = pi.events.on(SUBAGENT_FOREGROUND_COMPLETE_EVENT, (data) => {
|
|
287
|
+
void deliver(data as CompletionNotification);
|
|
288
|
+
});
|
|
264
289
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
unsubscribe();
|
|
279
|
-
} catch {
|
|
280
|
-
// Best effort cleanup must continue through every owned handler.
|
|
290
|
+
return {
|
|
291
|
+
deliver,
|
|
292
|
+
dispose() {
|
|
293
|
+
if (disposed) return;
|
|
294
|
+
disposed = true;
|
|
295
|
+
for (const batcher of batchers.values()) settle(batcher.dispose(), false);
|
|
296
|
+
batchers.clear();
|
|
297
|
+
for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
|
|
298
|
+
try {
|
|
299
|
+
unsubscribe?.();
|
|
300
|
+
} catch {
|
|
301
|
+
// The runtime is already shutting down; pending records stay on disk.
|
|
302
|
+
}
|
|
281
303
|
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
delete globalStore[unsubscribeStoreKey];
|
|
285
|
-
}
|
|
286
|
-
if (globalStore[batcherStoreKey] === batcherRegistration) {
|
|
287
|
-
delete globalStore[batcherStoreKey];
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
globalStore[unsubscribeStoreKey] = dispose;
|
|
292
|
-
globalStore[batcherStoreKey] = batcherRegistration;
|
|
293
|
-
return dispose;
|
|
304
|
+
},
|
|
305
|
+
};
|
|
294
306
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
|
+
import {
|
|
5
|
+
SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
6
|
+
type AsyncStatus,
|
|
7
|
+
type CanonicalSessionTerminalV1,
|
|
8
|
+
type ProcessInstanceExitV1,
|
|
9
|
+
type ProcessTerminalReason,
|
|
10
|
+
type ProcessTerminalV1,
|
|
11
|
+
} from "../../shared/types.ts";
|
|
12
|
+
import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
|
|
13
|
+
|
|
14
|
+
export interface ProcessTerminalCandidate {
|
|
15
|
+
version: 1;
|
|
16
|
+
runId: string;
|
|
17
|
+
runnerProcessInstanceId: string;
|
|
18
|
+
writers: Record<string, ProcessInstanceExitV1[]>;
|
|
19
|
+
expectedWriters?: Record<string, number>;
|
|
20
|
+
sessionFile?: string;
|
|
21
|
+
revivalLeaseToken?: string;
|
|
22
|
+
revivalLeaseReleaseAcknowledged?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunnerCloseObservation {
|
|
26
|
+
processInstanceId: string;
|
|
27
|
+
closeObservedAt: number;
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
signal: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExitV1 {
|
|
37
|
+
if (!isRecord(value)) return false;
|
|
38
|
+
if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
|
|
39
|
+
if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
|
|
40
|
+
if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
|
|
41
|
+
if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
|
|
42
|
+
if (typeof value.signal !== "string" && value.signal !== null) return false;
|
|
43
|
+
return value.kind === "runner"
|
|
44
|
+
? value.attempt === undefined
|
|
45
|
+
: typeof value.attempt === "number" && Number.isInteger(value.attempt) && value.attempt >= 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validInstance(value: unknown): value is ProcessInstanceExitV1 {
|
|
49
|
+
return validProcessInstance(value, "pi-writer");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function processTerminalCandidatePath(asyncDir: string): string {
|
|
53
|
+
return path.join(asyncDir, "process-terminal-candidate.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function processTerminalPath(asyncDir: string): string {
|
|
57
|
+
return path.join(asyncDir, "process-terminal.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function errorMessage(error: unknown): string {
|
|
61
|
+
return error instanceof Error ? error.message : String(error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalCandidate | undefined {
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(fs.readFileSync(processTerminalCandidatePath(asyncDir), "utf-8")) as unknown;
|
|
67
|
+
if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
|
|
68
|
+
throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
|
|
69
|
+
}
|
|
70
|
+
const writers: Record<string, ProcessInstanceExitV1[]> = {};
|
|
71
|
+
for (const [index, entries] of Object.entries(raw.writers)) {
|
|
72
|
+
if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
|
|
73
|
+
writers[index] = entries;
|
|
74
|
+
}
|
|
75
|
+
let expectedWriters: Record<string, number> | undefined;
|
|
76
|
+
if (raw.expectedWriters !== undefined) {
|
|
77
|
+
if (!isRecord(raw.expectedWriters)) throw new Error("Invalid expected writer process records.");
|
|
78
|
+
expectedWriters = {};
|
|
79
|
+
for (const [index, count] of Object.entries(raw.expectedWriters)) {
|
|
80
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 0) throw new Error(`Invalid expected writer count for child '${index}'.`);
|
|
81
|
+
expectedWriters[index] = count;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (raw.sessionFile !== undefined && typeof raw.sessionFile !== "string") throw new Error("Invalid process-terminal candidate sessionFile.");
|
|
85
|
+
if (raw.revivalLeaseToken !== undefined && typeof raw.revivalLeaseToken !== "string") throw new Error("Invalid process-terminal candidate lease token.");
|
|
86
|
+
if (raw.revivalLeaseReleaseAcknowledged !== undefined && typeof raw.revivalLeaseReleaseAcknowledged !== "boolean") throw new Error("Invalid process-terminal lease release acknowledgement.");
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
runId: raw.runId,
|
|
90
|
+
runnerProcessInstanceId: raw.runnerProcessInstanceId,
|
|
91
|
+
writers,
|
|
92
|
+
...(expectedWriters ? { expectedWriters } : {}),
|
|
93
|
+
...(raw.sessionFile ? { sessionFile: raw.sessionFile } : {}),
|
|
94
|
+
...(raw.revivalLeaseToken ? { revivalLeaseToken: raw.revivalLeaseToken } : {}),
|
|
95
|
+
...(raw.revivalLeaseReleaseAcknowledged !== undefined ? { revivalLeaseReleaseAcknowledged: raw.revivalLeaseReleaseAcknowledged } : {}),
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeProcessTerminalCandidate(asyncDir: string, candidate: ProcessTerminalCandidate): void {
|
|
104
|
+
writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token: string, acknowledged: boolean): void {
|
|
108
|
+
const candidate = readProcessTerminalCandidate(asyncDir);
|
|
109
|
+
if (!candidate || candidate.revivalLeaseToken !== token) return;
|
|
110
|
+
writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminalV1 {
|
|
114
|
+
return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resumeDisposition(state: string | undefined, sessionFile: string | undefined): "resumable" | "non-resumable" | "unavailable" {
|
|
118
|
+
if (state === "stopped") return "non-resumable";
|
|
119
|
+
if (state !== "complete" && state !== "completed" && state !== "failed" && state !== "paused") return "unavailable";
|
|
120
|
+
return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminalV1 | undefined {
|
|
124
|
+
if (!candidate.sessionFile || lease.state !== "free") return undefined;
|
|
125
|
+
if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
|
|
126
|
+
return {
|
|
127
|
+
canonicalSessionId: canonicalSessionId(candidate.sessionFile),
|
|
128
|
+
leaseDisposition: candidate.revivalLeaseToken ? "released" : "not-held",
|
|
129
|
+
freeAtObservation: true,
|
|
130
|
+
...(candidate.revivalLeaseToken ? { canonicalSessionLeaseReleased: true } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminalV1 {
|
|
135
|
+
if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
|
|
136
|
+
throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
|
|
137
|
+
}
|
|
138
|
+
if (fallback?.runId && raw.runId !== fallback.runId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to run '${raw.runId}', expected '${fallback.runId}'.`);
|
|
139
|
+
if (fallback?.runnerProcessInstanceId && raw.runnerProcessInstanceId !== fallback.runnerProcessInstanceId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to runner '${raw.runnerProcessInstanceId}', expected '${fallback.runnerProcessInstanceId}'.`);
|
|
140
|
+
if (raw.instances !== undefined && (!Array.isArray(raw.instances) || !raw.instances.every((entry) => validProcessInstance(entry)))) {
|
|
141
|
+
throw new Error(`Invalid process-terminal instances in '${asyncDir}'.`);
|
|
142
|
+
}
|
|
143
|
+
if (raw.state === "observed") {
|
|
144
|
+
if (typeof raw.observedAt !== "number" || !Number.isFinite(raw.observedAt)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing observedAt.`);
|
|
145
|
+
if (!Array.isArray(raw.instances)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing instances.`);
|
|
146
|
+
const runner = raw.instances.find((entry) => isRecord(entry) && entry.kind === "runner");
|
|
147
|
+
if (!validProcessInstance(runner, "runner") || runner.processInstanceId !== raw.runnerProcessInstanceId) throw new Error(`Observed process-terminal proof in '${asyncDir}' has no matching runner instance.`);
|
|
148
|
+
}
|
|
149
|
+
if (raw.resumeDisposition !== undefined && !["resumable", "non-resumable", "unavailable"].includes(String(raw.resumeDisposition))) throw new Error(`Invalid process-terminal resume disposition in '${asyncDir}'.`);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminalV1 | undefined {
|
|
154
|
+
if (value === undefined) return undefined;
|
|
155
|
+
try {
|
|
156
|
+
validateProof(value, label, fallback);
|
|
157
|
+
return value;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminalV1 | undefined {
|
|
164
|
+
try {
|
|
165
|
+
const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
|
|
166
|
+
validateProof(raw, asyncDir, fallback);
|
|
167
|
+
return raw;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
170
|
+
return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stepProcessTerminalProof(
|
|
175
|
+
proof: ProcessTerminalV1,
|
|
176
|
+
childIndex: number,
|
|
177
|
+
state: ProcessTerminalV1["state"],
|
|
178
|
+
records: ProcessInstanceExitV1[],
|
|
179
|
+
resumeDispositionValue: ProcessTerminalV1["resumeDisposition"],
|
|
180
|
+
): ProcessTerminalV1 {
|
|
181
|
+
const base = {
|
|
182
|
+
version: 1 as const,
|
|
183
|
+
runId: proof.runId,
|
|
184
|
+
childIndex,
|
|
185
|
+
runnerProcessInstanceId: proof.runnerProcessInstanceId,
|
|
186
|
+
...(resumeDispositionValue ? { resumeDisposition: resumeDispositionValue } : {}),
|
|
187
|
+
};
|
|
188
|
+
if (state === "observed") {
|
|
189
|
+
return { ...base, state, observedAt: proof.state === "observed" ? proof.observedAt : Date.now(), instances: records };
|
|
190
|
+
}
|
|
191
|
+
if (state === "unknown") {
|
|
192
|
+
return { ...base, state, reason: proof.state === "unknown" ? proof.reason : "writer-close-unverified" };
|
|
193
|
+
}
|
|
194
|
+
return { ...base, state };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function overlayStatus(asyncDir: string, proof: ProcessTerminalV1, candidate?: ProcessTerminalCandidate): void {
|
|
198
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
199
|
+
try {
|
|
200
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
201
|
+
status.processTerminal = proof;
|
|
202
|
+
if (status.steps) {
|
|
203
|
+
for (const [index, step] of status.steps.entries()) {
|
|
204
|
+
const records = candidate?.writers[String(index)] ?? [];
|
|
205
|
+
const expected = candidate?.expectedWriters?.[String(index)] ?? (records.length > 0 ? records.length : 0);
|
|
206
|
+
const stepState = expected === 0 ? "not-started" : proof.state === "observed" && records.length === expected ? "observed" : proof.state === "pending" ? "pending" : "unknown";
|
|
207
|
+
step.processTerminal = stepProcessTerminalProof(proof, index, stepState, records, resumeDisposition(step.status, step.sessionFile ?? candidate?.sessionFile));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
writeAtomicJson(statusPath, status);
|
|
211
|
+
} catch {
|
|
212
|
+
// The proof sidecar remains authoritative when terminal status is unavailable.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function finalizeProcessTerminal(
|
|
217
|
+
asyncDir: string,
|
|
218
|
+
runId: string,
|
|
219
|
+
runnerClose: RunnerCloseObservation,
|
|
220
|
+
): ProcessTerminalV1 {
|
|
221
|
+
const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
|
|
222
|
+
if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
|
|
223
|
+
if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
|
|
224
|
+
if (existing.state === "unknown") return existing;
|
|
225
|
+
}
|
|
226
|
+
let proof: ProcessTerminalV1;
|
|
227
|
+
let candidateForOverlay: ProcessTerminalCandidate | undefined;
|
|
228
|
+
try {
|
|
229
|
+
const candidate = readProcessTerminalCandidate(asyncDir);
|
|
230
|
+
candidateForOverlay = candidate;
|
|
231
|
+
if (!candidate) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-candidate-missing");
|
|
232
|
+
else if (candidate.runId !== runId || candidate.runnerProcessInstanceId !== runnerClose.processInstanceId) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-instance-mismatch");
|
|
233
|
+
else {
|
|
234
|
+
const allWriters = Object.values(candidate.writers).flat();
|
|
235
|
+
const status = (() => {
|
|
236
|
+
try { return JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatus; } catch { return undefined; }
|
|
237
|
+
})();
|
|
238
|
+
const session = candidate.sessionFile ? inspectSessionLease(candidate.sessionFile) : undefined;
|
|
239
|
+
const writerEntries = Object.entries(candidate.writers);
|
|
240
|
+
const expectedWriters = candidate.expectedWriters ?? Object.fromEntries(writerEntries.map(([index, records]) => [index, records.length]));
|
|
241
|
+
const expectedEntries = Object.entries(expectedWriters);
|
|
242
|
+
const expectedIndexes = new Set(expectedEntries.map(([index]) => index));
|
|
243
|
+
const writerIndexes = new Set(writerEntries.map(([index]) => index));
|
|
244
|
+
const inconsistentWriters = writerEntries.some(([index, records]) => !expectedIndexes.has(index) || records.length !== expectedWriters[index])
|
|
245
|
+
|| expectedEntries.some(([index, expected]) => !writerIndexes.has(index) && expected !== 0);
|
|
246
|
+
if (session && session.state !== "free") {
|
|
247
|
+
proof = unknownProof(runId, runnerClose.processInstanceId, session.state === "owned" ? "canonical-session-lease-active" : "canonical-session-unavailable");
|
|
248
|
+
} else if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) {
|
|
249
|
+
proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
|
|
250
|
+
} else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
|
|
251
|
+
proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
|
|
252
|
+
} else {
|
|
253
|
+
const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
|
|
254
|
+
const canonicalSession = session && sessionProjection(candidate, session);
|
|
255
|
+
proof = {
|
|
256
|
+
version: 1,
|
|
257
|
+
state: "observed",
|
|
258
|
+
runId,
|
|
259
|
+
runnerProcessInstanceId: runnerClose.processInstanceId,
|
|
260
|
+
observedAt: runnerClose.closeObservedAt,
|
|
261
|
+
instances: [runner, ...allWriters],
|
|
262
|
+
resumeDisposition: resumeDisposition(status?.state, candidate.sessionFile ?? status?.sessionFile),
|
|
263
|
+
...(canonicalSession ? { canonicalSession } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
proof = unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", errorMessage(error));
|
|
269
|
+
}
|
|
270
|
+
let durable = false;
|
|
271
|
+
try {
|
|
272
|
+
writeAtomicJson(processTerminalPath(asyncDir), proof);
|
|
273
|
+
durable = true;
|
|
274
|
+
overlayStatus(asyncDir, proof, candidateForOverlay);
|
|
275
|
+
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
|
+
} catch {
|
|
277
|
+
// Do not emit a process-terminal event when the proof sidecar was not durable.
|
|
278
|
+
}
|
|
279
|
+
return durable ? proof : unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", "Failed to persist process-terminal proof.");
|
|
280
|
+
}
|