@xzzpig/pi-openspec-x 0.1.0 → 0.2.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/CHANGELOG.md +28 -0
- package/README.md +6 -2
- package/dist/public.d.ts +48 -0
- package/package.json +9 -5
- package/src/agent-prompts.ts +4 -1
- package/src/agents.ts +25 -0
- package/src/child-report-extensions/gap-analysis.ts +23 -0
- package/src/child-report-extensions/plan-review.ts +21 -0
- package/src/child-report-extensions/worker.ts +26 -0
- package/src/child-report-ingestion.ts +1530 -0
- package/src/child-report-reader.ts +955 -0
- package/src/dispatch-gate.ts +25 -18
- package/src/goal-base.ts +394 -0
- package/src/implement-command.ts +620 -60
- package/src/index.ts +113 -9
- package/src/objective.ts +2 -2
- package/src/plan-command.ts +46 -0
- package/src/progress-observer.ts +48 -54
- package/src/report-tools.ts +10 -9
- package/src/worker-dispatch.ts +2 -2
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child report ingestion (design D3, task 4.1).
|
|
3
|
+
*
|
|
4
|
+
* The three `report_*` tools are registered only in their own child processes
|
|
5
|
+
* (design D1), so the parent session never sees the tool call itself. This
|
|
6
|
+
* module reconstructs the delivery from the run evidence the parent does own:
|
|
7
|
+
* the launch receipt it recorded for its own native subagent call, the native
|
|
8
|
+
* start/complete events, and — for the report payload itself — the child's
|
|
9
|
+
* canonical Pi session record read through `child-report-reader.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Two invariants shape everything here:
|
|
12
|
+
*
|
|
13
|
+
* 1. A report observed while the child is still running is only a candidate.
|
|
14
|
+
* It becomes `ready` only once the native step reached a successful
|
|
15
|
+
* terminal state (design D3).
|
|
16
|
+
* 2. Nothing is inferred from free text, previews, or redacted task strings.
|
|
17
|
+
* Task association comes from the encoded dispatch marker carried in the
|
|
18
|
+
* real user dispatch, cross-checked against the report's own `taskId`.
|
|
19
|
+
*
|
|
20
|
+
* This module deliberately declares the v3 shapes it understands instead of
|
|
21
|
+
* importing them: `AsyncStatus` and the lifecycle version constant are not part
|
|
22
|
+
* of `@xzzpig/pi-subagents`' public surface, and this change must not reach
|
|
23
|
+
* into a peer's private modules or add a peer export (design D3).
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
CHILD_REPORT_TOOL_BY_AGENT,
|
|
31
|
+
DISPATCH_TASK_MARKER_PREFIX,
|
|
32
|
+
DISPATCH_TASK_MARKER_SUFFIX,
|
|
33
|
+
MAX_ACTIVE_CHILDREN,
|
|
34
|
+
readChildReportSession,
|
|
35
|
+
scanDispatchMarkers,
|
|
36
|
+
type ChildReportAttempt,
|
|
37
|
+
type ChildSessionExpectation,
|
|
38
|
+
type ChildSessionOutcome,
|
|
39
|
+
} from "./child-report-reader.ts";
|
|
40
|
+
|
|
41
|
+
/** Lifecycle artifact version this build understands (design D3). */
|
|
42
|
+
export const SUPPORTED_LIFECYCLE_VERSION = 3;
|
|
43
|
+
|
|
44
|
+
/** Entry type for a launch record (the durable half lives in task 4.2). */
|
|
45
|
+
export const CHILD_REPORT_DISPATCH_ENTRY =
|
|
46
|
+
"pi-openspec-x/child-report-dispatch";
|
|
47
|
+
|
|
48
|
+
/** Durable run entry: the run this flow is currently waiting on (design D5). */
|
|
49
|
+
export const CHILD_REPORT_RUN_ENTRY = "pi-openspec-x/child-report-run";
|
|
50
|
+
|
|
51
|
+
/** Durable receipt entry: a report that was accepted as gate evidence. */
|
|
52
|
+
export const CHILD_REPORT_RECEIPT_ENTRY = "pi-openspec-x/child-report-receipt";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Payload versions. As with the attempt entry, the version lives in the
|
|
56
|
+
* payload rather than in the customType suffix, so a reader keys on the type
|
|
57
|
+
* string and validates the version it finds.
|
|
58
|
+
*/
|
|
59
|
+
export const CHILD_REPORT_RUN_VERSION = 1;
|
|
60
|
+
export const CHILD_REPORT_RECEIPT_VERSION = 1;
|
|
61
|
+
|
|
62
|
+
/** How long a completion refresh may spend reading evidence (design D4). */
|
|
63
|
+
export const COMPLETION_REFRESH_BUDGET_MS = 1000;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Minimal v3 status structure, validated at runtime
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** The subset of a native v3 step this module relies on. */
|
|
70
|
+
export interface LifecycleStepLike {
|
|
71
|
+
index: number;
|
|
72
|
+
agent?: string;
|
|
73
|
+
runId?: string;
|
|
74
|
+
sessionFile?: string;
|
|
75
|
+
startedAt?: string;
|
|
76
|
+
endedAt?: string;
|
|
77
|
+
status?: string;
|
|
78
|
+
exitCode?: number;
|
|
79
|
+
timedOut?: boolean;
|
|
80
|
+
stopped?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The subset of a native v3 status document this module relies on. */
|
|
84
|
+
export interface LifecycleStatusLike {
|
|
85
|
+
lifecycleArtifactVersion: number;
|
|
86
|
+
runId: string;
|
|
87
|
+
sessionId?: string;
|
|
88
|
+
state?: string;
|
|
89
|
+
startedAt?: string;
|
|
90
|
+
sessionRoot?: string;
|
|
91
|
+
asyncDir?: string;
|
|
92
|
+
completionOwnerId?: string;
|
|
93
|
+
steps: LifecycleStepLike[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read a UTF-8 text file, or undefined when it cannot be read. */
|
|
97
|
+
function readTextFile(file: string): string | undefined {
|
|
98
|
+
try {
|
|
99
|
+
return fs.readFileSync(file, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The native states that mean a child finished successfully. */
|
|
106
|
+
const SUCCESSFUL_STATES = new Set(["complete", "completed"]);
|
|
107
|
+
|
|
108
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function asString(value: unknown): string | undefined {
|
|
113
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate a parsed `status.json` against the v3 shape this build supports.
|
|
118
|
+
*
|
|
119
|
+
* Every field is checked rather than asserted: a missing field means the fact
|
|
120
|
+
* it would have proven is unknown, and an unknown fact must never let a task
|
|
121
|
+
* complete (design D3).
|
|
122
|
+
*/
|
|
123
|
+
export function parseLifecycleStatus(
|
|
124
|
+
value: unknown,
|
|
125
|
+
): { ok: true; status: LifecycleStatusLike } | { ok: false; reason: string } {
|
|
126
|
+
if (!isRecord(value)) {
|
|
127
|
+
return { ok: false, reason: "the native status document is not an object" };
|
|
128
|
+
}
|
|
129
|
+
const version = value.lifecycleArtifactVersion;
|
|
130
|
+
if (typeof version !== "number") {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason:
|
|
134
|
+
"the native status document carries no lifecycle artifact version",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (version !== SUPPORTED_LIFECYCLE_VERSION) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: `the native lifecycle artifact version ${version} is not the supported version ${SUPPORTED_LIFECYCLE_VERSION}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const runId = asString(value.runId);
|
|
144
|
+
if (!runId) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: "the native status document carries no run id",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(value.steps)) {
|
|
151
|
+
return { ok: false, reason: "the native status document carries no steps" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const steps: LifecycleStepLike[] = [];
|
|
155
|
+
for (const [position, raw] of value.steps.entries()) {
|
|
156
|
+
if (!isRecord(raw)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: `native step ${position} is not an object`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// The producer defines step identity by array position; a model-visible or
|
|
163
|
+
// display ordering must never be used to guess it (design D2/D3).
|
|
164
|
+
const index = typeof raw.index === "number" ? raw.index : position;
|
|
165
|
+
const status = asString(raw.status);
|
|
166
|
+
if (status !== undefined && !isKnownStepState(status)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: `native step ${index} reports an unknown state ${status}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
steps.push({
|
|
173
|
+
index,
|
|
174
|
+
agent: asString(raw.agent),
|
|
175
|
+
runId: asString(raw.runId),
|
|
176
|
+
sessionFile: asString(raw.sessionFile),
|
|
177
|
+
startedAt: asString(raw.startedAt),
|
|
178
|
+
endedAt: asString(raw.endedAt),
|
|
179
|
+
status,
|
|
180
|
+
exitCode: typeof raw.exitCode === "number" ? raw.exitCode : undefined,
|
|
181
|
+
timedOut: raw.timedOut === true,
|
|
182
|
+
stopped: raw.stopped === true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
status: {
|
|
189
|
+
lifecycleArtifactVersion: version,
|
|
190
|
+
runId,
|
|
191
|
+
sessionId: asString(value.sessionId),
|
|
192
|
+
state: asString(value.state),
|
|
193
|
+
startedAt: asString(value.startedAt),
|
|
194
|
+
sessionRoot: asString(value.sessionRoot),
|
|
195
|
+
asyncDir: asString(value.asyncDir),
|
|
196
|
+
completionOwnerId: asString(value.completionOwnerId),
|
|
197
|
+
steps,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Step states the native producer can emit (both spellings are real). */
|
|
203
|
+
function isKnownStepState(state: string): boolean {
|
|
204
|
+
return new Set([
|
|
205
|
+
"pending",
|
|
206
|
+
"running",
|
|
207
|
+
"complete",
|
|
208
|
+
"completed",
|
|
209
|
+
"failed",
|
|
210
|
+
"partial",
|
|
211
|
+
"paused",
|
|
212
|
+
"stopped",
|
|
213
|
+
"rejected",
|
|
214
|
+
]).has(state);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Whether a native state counts as a successful terminal state. */
|
|
218
|
+
export function isSuccessfulTerminal(state: string | undefined): boolean {
|
|
219
|
+
return state !== undefined && SUCCESSFUL_STATES.has(state);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Locate the native step for an agent.
|
|
224
|
+
*
|
|
225
|
+
* A resumed run can reuse the previous run's session directory, so a single
|
|
226
|
+
* session file may hold an older attempt's report. Selecting by identity — and
|
|
227
|
+
* never "prefer a successful step" — is what keeps a stale success from
|
|
228
|
+
* shadowing a newer failure (design D2).
|
|
229
|
+
*/
|
|
230
|
+
export function locateStep(
|
|
231
|
+
steps: readonly LifecycleStepLike[],
|
|
232
|
+
options: { agent: string; expectedRunId?: string | undefined },
|
|
233
|
+
): { ok: true; step: LifecycleStepLike } | { ok: false; reason: string } {
|
|
234
|
+
const candidates = steps.filter((step) => step.agent === options.agent);
|
|
235
|
+
if (candidates.length === 0) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: `no step for agent ${options.agent} in the attributed run`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const identified = candidates.filter((step) => step.runId !== undefined);
|
|
242
|
+
if (options.expectedRunId !== undefined && identified.length > 0) {
|
|
243
|
+
const match = candidates.find(
|
|
244
|
+
(step) => step.runId === options.expectedRunId,
|
|
245
|
+
);
|
|
246
|
+
if (!match) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: `no step for agent ${options.agent} matches run ${options.expectedRunId}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, step: match };
|
|
253
|
+
}
|
|
254
|
+
// Without per-step run ids (the common pi-subagents shape) the newest step
|
|
255
|
+
// wins; deliberately NOT "the successful one".
|
|
256
|
+
const sorted = [...candidates].sort((a, b) => a.index - b.index);
|
|
257
|
+
return { ok: true, step: sorted[sorted.length - 1]! };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Protocol compatibility (design D3, revised: no preflight on the hot path)
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export interface ProtocolCheckInput {
|
|
265
|
+
cwd: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ProtocolCheckResult {
|
|
269
|
+
ok: boolean;
|
|
270
|
+
reason?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Verify that the peer's declared lifecycle artifact version matches what this
|
|
275
|
+
* build reads. Injectable so tests can drive both branches without a peer, and
|
|
276
|
+
* cached per flow so it never runs on a hot path (design D3).
|
|
277
|
+
*/
|
|
278
|
+
export type ProtocolChecker = (
|
|
279
|
+
input: ProtocolCheckInput,
|
|
280
|
+
) => ProtocolCheckResult;
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Run registry
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
export type ChildReportRunStatus = "pending" | "candidate" | "ready" | "failed";
|
|
287
|
+
|
|
288
|
+
export interface ChildReportRun {
|
|
289
|
+
flowId: string;
|
|
290
|
+
runId: string;
|
|
291
|
+
stepIndex: number;
|
|
292
|
+
agent: string;
|
|
293
|
+
toolName: string;
|
|
294
|
+
/** The task this run is attributed to, from the encoded dispatch marker. */
|
|
295
|
+
taskId?: string | undefined;
|
|
296
|
+
/** The attempt nonce the child extension recorded for its current prompt. */
|
|
297
|
+
attemptId?: string | undefined;
|
|
298
|
+
/** True when the dispatch marker could not name exactly one task. */
|
|
299
|
+
taskUnknown: boolean;
|
|
300
|
+
/** True when the run exceeded the active-child budget and is unusable. */
|
|
301
|
+
overBudget?: boolean | undefined;
|
|
302
|
+
/**
|
|
303
|
+
* Why the durable record could not be confirmed. A receipt that cannot be
|
|
304
|
+
* written will not survive a restart, so this is surfaced rather than
|
|
305
|
+
* swallowed (design D5).
|
|
306
|
+
*/
|
|
307
|
+
persistenceError?: string | undefined;
|
|
308
|
+
sessionRoot?: string | undefined;
|
|
309
|
+
sessionFile?: string | undefined;
|
|
310
|
+
asyncDir?: string | undefined;
|
|
311
|
+
startedAt?: string | undefined;
|
|
312
|
+
endedAt?: string | undefined;
|
|
313
|
+
stepTerminal: boolean;
|
|
314
|
+
status: ChildReportRunStatus;
|
|
315
|
+
reason?: string | undefined;
|
|
316
|
+
report?: Record<string, unknown> | undefined;
|
|
317
|
+
attempt?: ChildReportAttempt | undefined;
|
|
318
|
+
toolCallId?: string | undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface FlowRuns {
|
|
322
|
+
flowId: string;
|
|
323
|
+
/** The owning session, captured from the first event that carries it. */
|
|
324
|
+
sessionId?: string | undefined;
|
|
325
|
+
/** Bumped whenever the flow's context switches (design D4/D6). */
|
|
326
|
+
generation: number;
|
|
327
|
+
runs: Map<string, ChildReportRun>;
|
|
328
|
+
/** Insertion order, so "latest" is deterministic. */
|
|
329
|
+
order: string[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const flows = new Map<string, FlowRuns>();
|
|
333
|
+
|
|
334
|
+
/** Bumped when a flow is created, so late reads can detect a context switch. */
|
|
335
|
+
let generationCounter = 0;
|
|
336
|
+
|
|
337
|
+
/** Reset all ingestion state. Test-only. */
|
|
338
|
+
export function resetChildReportIngestionForTests(): void {
|
|
339
|
+
flows.clear();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function runKey(runId: string, stepIndex: number): string {
|
|
343
|
+
return `${runId}#${stepIndex}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function flowFor(flowId: string): FlowRuns {
|
|
347
|
+
const existing = flows.get(flowId);
|
|
348
|
+
if (existing) return existing;
|
|
349
|
+
generationCounter += 1;
|
|
350
|
+
const created: FlowRuns = {
|
|
351
|
+
flowId,
|
|
352
|
+
generation: generationCounter,
|
|
353
|
+
runs: new Map(),
|
|
354
|
+
order: [],
|
|
355
|
+
};
|
|
356
|
+
flows.set(flowId, created);
|
|
357
|
+
return created;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The ingestion flow id for a session and change.
|
|
362
|
+
*
|
|
363
|
+
* Defined here so the main entry and the tick gate cannot drift apart on the
|
|
364
|
+
* format: the gate resolves a task's receipt by the same id the entry bound.
|
|
365
|
+
*/
|
|
366
|
+
export function childReportFlowId(sessionId: string, changeId: string): string {
|
|
367
|
+
return `${sessionId}:${changeId}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Record the parent's own launch of a native child, before any event arrives.
|
|
372
|
+
*
|
|
373
|
+
* This is the only trusted source of run identity: the events that follow are
|
|
374
|
+
* matched against it rather than being believed on their own.
|
|
375
|
+
*/
|
|
376
|
+
export function noteChildRunLaunch(
|
|
377
|
+
flowId: string,
|
|
378
|
+
launch: {
|
|
379
|
+
runId: string;
|
|
380
|
+
stepIndex: number;
|
|
381
|
+
agent: string;
|
|
382
|
+
taskId?: string | undefined;
|
|
383
|
+
taskUnknown?: boolean;
|
|
384
|
+
startedAt?: string | undefined;
|
|
385
|
+
},
|
|
386
|
+
): ChildReportRun {
|
|
387
|
+
const flow = flowFor(flowId);
|
|
388
|
+
const key = runKey(launch.runId, launch.stepIndex);
|
|
389
|
+
const run: ChildReportRun = {
|
|
390
|
+
flowId,
|
|
391
|
+
runId: launch.runId,
|
|
392
|
+
stepIndex: launch.stepIndex,
|
|
393
|
+
agent: launch.agent,
|
|
394
|
+
toolName: CHILD_REPORT_TOOL_BY_AGENT[launch.agent] ?? "",
|
|
395
|
+
taskId: launch.taskId,
|
|
396
|
+
taskUnknown: launch.taskUnknown ?? launch.taskId === undefined,
|
|
397
|
+
startedAt: launch.startedAt,
|
|
398
|
+
stepTerminal: false,
|
|
399
|
+
status: "pending",
|
|
400
|
+
};
|
|
401
|
+
// Over budget means "unusable", never "silently dropped": a run that is not
|
|
402
|
+
// tracked could never be gated, so it must stay visibly blocking (design D4).
|
|
403
|
+
if (!flow.runs.has(key) && flow.runs.size >= MAX_ACTIVE_CHILDREN) {
|
|
404
|
+
run.status = "failed";
|
|
405
|
+
run.overBudget = true;
|
|
406
|
+
run.reason = `the flow already tracks ${MAX_ACTIVE_CHILDREN} child runs, so this one exceeds the active-child budget and cannot be used as evidence`;
|
|
407
|
+
}
|
|
408
|
+
if (!flow.runs.has(key)) flow.order.push(key);
|
|
409
|
+
flow.runs.set(key, run);
|
|
410
|
+
return run;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Record a native start event. It only supplies ownership (sessionRoot /
|
|
415
|
+
* asyncDir); the report itself is never read from an event payload.
|
|
416
|
+
*/
|
|
417
|
+
export function noteChildRunStarted(
|
|
418
|
+
flowId: string,
|
|
419
|
+
started: {
|
|
420
|
+
runId: string;
|
|
421
|
+
stepIndex?: number | undefined;
|
|
422
|
+
sessionRoot?: string | undefined;
|
|
423
|
+
asyncDir?: string | undefined;
|
|
424
|
+
cwd?: string | undefined;
|
|
425
|
+
},
|
|
426
|
+
): void {
|
|
427
|
+
const flow = flowFor(flowId);
|
|
428
|
+
// Resolved through `findRun` so a native run id that differs from the
|
|
429
|
+
// parent's tool-call id still attributes ownership to the right run.
|
|
430
|
+
const run = findRun(flow, started.runId, started.stepIndex);
|
|
431
|
+
if (run) {
|
|
432
|
+
run.sessionRoot = started.sessionRoot ?? run.sessionRoot;
|
|
433
|
+
run.asyncDir = started.asyncDir ?? run.asyncDir;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Locate the child's canonical session file from the run's native status
|
|
439
|
+
* document.
|
|
440
|
+
*
|
|
441
|
+
* The async completion payload carries no `sessionFile`, so the background path
|
|
442
|
+
* has to read `<asyncDir>/status.json` and take it from the matching native
|
|
443
|
+
* step (design D2). Nothing is trusted unless the document parses as the v3
|
|
444
|
+
* shape this build supports, and the step is selected by identity rather than
|
|
445
|
+
* by "prefer a successful step".
|
|
446
|
+
*/
|
|
447
|
+
export function resolveStepSessionFile(
|
|
448
|
+
run: ChildReportRun,
|
|
449
|
+
readFile: (path: string) => string | undefined,
|
|
450
|
+
): string | undefined {
|
|
451
|
+
if (run.sessionFile) return run.sessionFile;
|
|
452
|
+
if (!run.asyncDir) return undefined;
|
|
453
|
+
const raw = readFile(path.join(run.asyncDir, "status.json"));
|
|
454
|
+
if (!raw) return undefined;
|
|
455
|
+
let parsed: unknown;
|
|
456
|
+
try {
|
|
457
|
+
parsed = JSON.parse(raw);
|
|
458
|
+
} catch {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
const status = parseLifecycleStatus(parsed);
|
|
462
|
+
if (!status.ok) return undefined;
|
|
463
|
+
// Deliberately no `expectedRunId`: the dispatch records the parent's
|
|
464
|
+
// tool-call id while a step carries the child's own run id, so requiring a
|
|
465
|
+
// match here would reject every real run.
|
|
466
|
+
const located = locateStep(status.status.steps, { agent: run.agent });
|
|
467
|
+
if (!located.ok) return undefined;
|
|
468
|
+
return located.step.sessionFile;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Record that the native step reached a terminal state, then attempt the
|
|
473
|
+
* report read. Only a successful terminal state can produce `ready`.
|
|
474
|
+
*/
|
|
475
|
+
export function noteChildRunCompleted(
|
|
476
|
+
flowId: string,
|
|
477
|
+
completed: {
|
|
478
|
+
runId: string;
|
|
479
|
+
stepIndex?: number | undefined;
|
|
480
|
+
state?: string | undefined;
|
|
481
|
+
sessionFile?: string | undefined;
|
|
482
|
+
endedAt?: string | undefined;
|
|
483
|
+
success?: boolean | undefined;
|
|
484
|
+
},
|
|
485
|
+
readFile: (path: string) => string | undefined,
|
|
486
|
+
): ChildReportRun | undefined {
|
|
487
|
+
const flow = flowFor(flowId);
|
|
488
|
+
const target = findRun(flow, completed.runId, completed.stepIndex);
|
|
489
|
+
if (!target) return undefined;
|
|
490
|
+
|
|
491
|
+
const succeeded =
|
|
492
|
+
completed.success ??
|
|
493
|
+
(completed.state !== undefined && isSuccessfulTerminal(completed.state));
|
|
494
|
+
target.stepTerminal = succeeded;
|
|
495
|
+
target.endedAt = completed.endedAt ?? target.endedAt;
|
|
496
|
+
target.sessionFile = completed.sessionFile ?? target.sessionFile;
|
|
497
|
+
|
|
498
|
+
if (!succeeded) {
|
|
499
|
+
target.status = "failed";
|
|
500
|
+
target.reason = `the child run did not finish successfully (state ${completed.state ?? "unknown"})`;
|
|
501
|
+
return target;
|
|
502
|
+
}
|
|
503
|
+
// The async completion payload carries no sessionFile, so locate it from the
|
|
504
|
+
// native status document before giving up on the run.
|
|
505
|
+
if (!target.sessionFile) {
|
|
506
|
+
const resolved = resolveStepSessionFile(target, readFile);
|
|
507
|
+
if (resolved) {
|
|
508
|
+
target.sessionFile = resolved;
|
|
509
|
+
target.sessionRoot ??= path.dirname(resolved);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return ingestRun(target, readFile);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function findRun(
|
|
516
|
+
flow: FlowRuns,
|
|
517
|
+
runId: string,
|
|
518
|
+
stepIndex?: number | undefined,
|
|
519
|
+
): ChildReportRun | undefined {
|
|
520
|
+
if (stepIndex !== undefined) {
|
|
521
|
+
const exact = flow.runs.get(runKey(runId, stepIndex));
|
|
522
|
+
if (exact) return exact;
|
|
523
|
+
}
|
|
524
|
+
const matches = flow.order
|
|
525
|
+
.map((key) => flow.runs.get(key))
|
|
526
|
+
.filter((run): run is ChildReportRun => run?.runId === runId);
|
|
527
|
+
if (matches.length > 0) return matches[matches.length - 1];
|
|
528
|
+
// The native completion carries the child's own run id, while the parent
|
|
529
|
+
// recorded the dispatch under its tool-call id. When exactly one run for that
|
|
530
|
+
// step is still awaiting completion, that is the run being reported on; an
|
|
531
|
+
// ambiguous set is left unmatched rather than guessed (design D2/D3).
|
|
532
|
+
const pending = flow.order
|
|
533
|
+
.map((key) => flow.runs.get(key))
|
|
534
|
+
.filter(
|
|
535
|
+
(run): run is ChildReportRun =>
|
|
536
|
+
run !== undefined &&
|
|
537
|
+
(stepIndex === undefined || run.stepIndex === stepIndex) &&
|
|
538
|
+
!run.stepTerminal,
|
|
539
|
+
);
|
|
540
|
+
if (pending.length === 1) return pending[0];
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Read a run's canonical session evidence and move it to `ready` when every
|
|
546
|
+
* condition holds.
|
|
547
|
+
*
|
|
548
|
+
* A failure here is not silent: the run keeps an explicit reason so the gate
|
|
549
|
+
* can explain why a task cannot complete (design D2/D3).
|
|
550
|
+
*/
|
|
551
|
+
export function ingestRun(
|
|
552
|
+
run: ChildReportRun,
|
|
553
|
+
readFile: (path: string) => string | undefined,
|
|
554
|
+
): ChildReportRun {
|
|
555
|
+
if (run.overBudget === true) {
|
|
556
|
+
run.status = "failed";
|
|
557
|
+
run.reason =
|
|
558
|
+
"the run exceeded the active-child budget, so it can never become evidence";
|
|
559
|
+
return run;
|
|
560
|
+
}
|
|
561
|
+
if (!run.stepTerminal) {
|
|
562
|
+
run.status = "candidate";
|
|
563
|
+
run.reason = "the child run has not reached a successful terminal state";
|
|
564
|
+
return run;
|
|
565
|
+
}
|
|
566
|
+
if (run.taskUnknown) {
|
|
567
|
+
run.status = "candidate";
|
|
568
|
+
run.reason =
|
|
569
|
+
"the dispatch did not name exactly one task, so this report cannot be attributed";
|
|
570
|
+
return run;
|
|
571
|
+
}
|
|
572
|
+
if (!run.sessionFile || !run.sessionRoot) {
|
|
573
|
+
run.status = "failed";
|
|
574
|
+
run.reason =
|
|
575
|
+
"the successful child run has no attributed canonical session file";
|
|
576
|
+
return run;
|
|
577
|
+
}
|
|
578
|
+
const expected: ChildSessionExpectation = {
|
|
579
|
+
agent: run.agent,
|
|
580
|
+
toolName: run.toolName,
|
|
581
|
+
attemptId: run.attemptId,
|
|
582
|
+
taskId: run.taskId,
|
|
583
|
+
runId: run.runId,
|
|
584
|
+
stepIndex: run.stepIndex,
|
|
585
|
+
sessionRoot: run.sessionRoot,
|
|
586
|
+
sessionFile: run.sessionFile,
|
|
587
|
+
stepStartedAt: run.startedAt,
|
|
588
|
+
stepEndedAt: run.endedAt,
|
|
589
|
+
stepTerminal: run.stepTerminal,
|
|
590
|
+
};
|
|
591
|
+
void readFile; // The canonical reader owns file access; kept for symmetry.
|
|
592
|
+
|
|
593
|
+
let outcome: ChildSessionOutcome;
|
|
594
|
+
try {
|
|
595
|
+
outcome = readChildReportSession(expected);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
run.status = "failed";
|
|
598
|
+
run.reason = `reading the child session failed: ${
|
|
599
|
+
error instanceof Error ? error.message : String(error)
|
|
600
|
+
}`;
|
|
601
|
+
return run;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (outcome.status === "ready") {
|
|
605
|
+
run.status = "ready";
|
|
606
|
+
run.report = outcome.report;
|
|
607
|
+
run.attempt = outcome.attempt;
|
|
608
|
+
// Record the nonce the reader adopted, so the receipt and any durable
|
|
609
|
+
// replay carry the same binding the report was validated against.
|
|
610
|
+
run.attemptId = outcome.attempt.attemptId;
|
|
611
|
+
run.toolCallId = outcome.toolCallId;
|
|
612
|
+
run.reason = undefined;
|
|
613
|
+
return run;
|
|
614
|
+
}
|
|
615
|
+
run.status = outcome.status === "pending" ? "candidate" : "failed";
|
|
616
|
+
run.reason = outcome.reason;
|
|
617
|
+
return run;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Attribute a worker dispatch to a task.
|
|
622
|
+
*
|
|
623
|
+
* The marker is required to be unique: two different task ids in one dispatch
|
|
624
|
+
* means the report cannot be attributed to either, which becomes an unresolved
|
|
625
|
+
* barrier rather than a guess (design D3).
|
|
626
|
+
*/
|
|
627
|
+
export function attributeDispatchTask(prompt: string): {
|
|
628
|
+
taskId?: string | undefined;
|
|
629
|
+
taskUnknown: boolean;
|
|
630
|
+
} {
|
|
631
|
+
const markers = scanDispatchMarkers(prompt);
|
|
632
|
+
if (markers.length !== 1) {
|
|
633
|
+
return { taskId: undefined, taskUnknown: true };
|
|
634
|
+
}
|
|
635
|
+
return { taskId: markers[0], taskUnknown: false };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Every run recorded for a flow, newest last. */
|
|
639
|
+
export function childRunsForFlow(flowId: string): ChildReportRun[] {
|
|
640
|
+
const flow = flows.get(flowId);
|
|
641
|
+
if (!flow) return [];
|
|
642
|
+
return flow.order
|
|
643
|
+
.map((key) => flow.runs.get(key))
|
|
644
|
+
.filter((run): run is ChildReportRun => run !== undefined);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The newest run for a task, or undefined when none was recorded. */
|
|
648
|
+
export function latestRunForTask(
|
|
649
|
+
flowId: string,
|
|
650
|
+
taskId: string,
|
|
651
|
+
): ChildReportRun | undefined {
|
|
652
|
+
const matches = childRunsForFlow(flowId).filter(
|
|
653
|
+
(run) => run.taskId === taskId,
|
|
654
|
+
);
|
|
655
|
+
return matches[matches.length - 1];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether a task has a ready receipt from its latest run.
|
|
660
|
+
*
|
|
661
|
+
* A newer non-ready run for the same task invalidates the older success rather
|
|
662
|
+
* than being shadowed by it (design D3).
|
|
663
|
+
*/
|
|
664
|
+
export function childReportReadyForTask(
|
|
665
|
+
flowId: string,
|
|
666
|
+
taskId: string,
|
|
667
|
+
): { ready: true; run: ChildReportRun } | { ready: false; reason: string } {
|
|
668
|
+
const latest = latestRunForTask(flowId, taskId);
|
|
669
|
+
if (!latest) {
|
|
670
|
+
return {
|
|
671
|
+
ready: false,
|
|
672
|
+
reason: `no opsx-worker dispatch is recorded for task ${taskId}`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (latest.status === "ready") return { ready: true, run: latest };
|
|
676
|
+
return {
|
|
677
|
+
ready: false,
|
|
678
|
+
reason:
|
|
679
|
+
latest.reason ??
|
|
680
|
+
`the latest worker run for task ${taskId} is ${latest.status}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Refresh every non-terminal run of a flow before a completion is decided.
|
|
686
|
+
*
|
|
687
|
+
* A completion application may arrive before the native completion event has
|
|
688
|
+
* been delivered (the notifier can wake the session first), so the gate cannot
|
|
689
|
+
* simply wait for events. One bounded round is performed here — no polling, no
|
|
690
|
+
* sleeping — and whatever is still unresolved stays explicitly blocking.
|
|
691
|
+
*/
|
|
692
|
+
export function refreshForCompletion(
|
|
693
|
+
flowId: string,
|
|
694
|
+
deps: {
|
|
695
|
+
readFile?: (path: string) => string | undefined;
|
|
696
|
+
now?: () => number;
|
|
697
|
+
/** The generation the caller started under; a mismatch refuses. */
|
|
698
|
+
generation?: number | undefined;
|
|
699
|
+
} = {},
|
|
700
|
+
): ChildReportRun[] {
|
|
701
|
+
const flow = flows.get(flowId);
|
|
702
|
+
if (!flow) return [];
|
|
703
|
+
// A late refresh must never write a receipt into a context that has since
|
|
704
|
+
// switched (design D4/D6).
|
|
705
|
+
if (deps.generation !== undefined && flow.generation !== deps.generation) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
const now = deps.now ?? (() => Date.now());
|
|
709
|
+
const deadline = now() + COMPLETION_REFRESH_BUDGET_MS;
|
|
710
|
+
const touched: ChildReportRun[] = [];
|
|
711
|
+
for (const key of flow.order) {
|
|
712
|
+
if (now() > deadline) break;
|
|
713
|
+
const run = flow.runs.get(key);
|
|
714
|
+
if (!run) continue;
|
|
715
|
+
if (run.status === "ready") continue;
|
|
716
|
+
touched.push(ingestRun(run, deps.readFile ?? readTextFile));
|
|
717
|
+
}
|
|
718
|
+
return touched;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Wiring
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
export interface ChildReportIngestionDeps {
|
|
726
|
+
/**
|
|
727
|
+
* The active opsx flow for this session, when one is bound (design D5). The
|
|
728
|
+
* gate passes the flow id it is gating, so this is only used for events that
|
|
729
|
+
* arrive without a flow context.
|
|
730
|
+
*/
|
|
731
|
+
activeFlow?: (
|
|
732
|
+
sessionId: string,
|
|
733
|
+
) => { flowId: string; changeId?: string; goalId?: string } | undefined;
|
|
734
|
+
/** Session id accessor; defaults to the host session manager. */
|
|
735
|
+
sessionId?: (ctx: unknown) => string | undefined;
|
|
736
|
+
/**
|
|
737
|
+
* Called when a report becomes ready, so display-only progress can be
|
|
738
|
+
* projected. It must never gate anything (design D4).
|
|
739
|
+
*/
|
|
740
|
+
/**
|
|
741
|
+
* Surfaced when a durable record cannot be confirmed. The live E2E is the
|
|
742
|
+
* only thing that exercises persistence, so a silent failure here is
|
|
743
|
+
* expensive: it looks identical to persistence never being wired.
|
|
744
|
+
*/
|
|
745
|
+
onDiagnostic?: (message: string) => void;
|
|
746
|
+
onProgress?: (input: {
|
|
747
|
+
sessionId: string;
|
|
748
|
+
toolName: string;
|
|
749
|
+
report: Record<string, unknown>;
|
|
750
|
+
}) => void;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Wire ingestion into the running extension.
|
|
755
|
+
*
|
|
756
|
+
* Only the main session is observed. Child tool calls happen inside child
|
|
757
|
+
* sessions the parent cannot see, which is exactly why the report is read from
|
|
758
|
+
* the child's canonical record instead of from an event.
|
|
759
|
+
*/
|
|
760
|
+
export function registerChildReportIngestion(
|
|
761
|
+
pi: ExtensionAPI,
|
|
762
|
+
deps: ChildReportIngestionDeps = {},
|
|
763
|
+
): void {
|
|
764
|
+
const sessionIdOf = (ctx: unknown): string | undefined => {
|
|
765
|
+
if (deps.sessionId) return deps.sessionId(ctx);
|
|
766
|
+
const manager = (ctx as { sessionManager?: { getSessionId?(): string } })
|
|
767
|
+
?.sessionManager;
|
|
768
|
+
try {
|
|
769
|
+
const fromContext = manager?.getSessionId?.();
|
|
770
|
+
if (fromContext) return fromContext;
|
|
771
|
+
} catch {
|
|
772
|
+
// Fall through to the captured identity below.
|
|
773
|
+
}
|
|
774
|
+
// The native completion events carry no `ctx`, so without this the handlers
|
|
775
|
+
// resolve no flow and silently record nothing. The owning session was
|
|
776
|
+
// captured when the parent's own dispatch was observed (design D3).
|
|
777
|
+
for (const flow of flows.values()) {
|
|
778
|
+
if (flow.sessionId) return flow.sessionId;
|
|
779
|
+
}
|
|
780
|
+
return undefined;
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const flowIdFor = (ctx: unknown): string | undefined => {
|
|
784
|
+
const sessionId = sessionIdOf(ctx);
|
|
785
|
+
if (!sessionId) return undefined;
|
|
786
|
+
return deps.activeFlow?.(sessionId)?.flowId;
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// The parent's own launch of a native child is the only trusted identity
|
|
790
|
+
// source, so it is captured before any completion event can arrive. The
|
|
791
|
+
// context is kept too: the native completion events below carry no `ctx`, and
|
|
792
|
+
// a durable append has to be confirmed against the real session file.
|
|
793
|
+
let persistenceCtx: unknown;
|
|
794
|
+
pi.on("tool_call", (event, ctx) => {
|
|
795
|
+
persistenceCtx = ctx;
|
|
796
|
+
const toolName = (event as { toolName?: string }).toolName;
|
|
797
|
+
if (toolName !== "subagent") return;
|
|
798
|
+
const flowId = flowIdFor(ctx);
|
|
799
|
+
if (!flowId) return;
|
|
800
|
+
const sessionId = sessionIdOf(ctx);
|
|
801
|
+
if (sessionId) flowFor(flowId).sessionId = sessionId;
|
|
802
|
+
const input = (event as { input?: unknown }).input as
|
|
803
|
+
| { agent?: unknown; prompt?: unknown; task?: unknown }
|
|
804
|
+
| undefined;
|
|
805
|
+
const agent = typeof input?.agent === "string" ? input.agent : undefined;
|
|
806
|
+
if (!agent || !(agent in CHILD_REPORT_TOOL_BY_AGENT)) return;
|
|
807
|
+
// pi-subagents carries the brief in `task`; `prompt` is kept as the
|
|
808
|
+
// documented alias. Reading only `prompt` left every live dispatch with an
|
|
809
|
+
// empty brief, so no marker was ever found and every run was `taskUnknown`.
|
|
810
|
+
const rawBrief = input?.task ?? input?.prompt;
|
|
811
|
+
const brief = typeof rawBrief === "string" ? rawBrief : "";
|
|
812
|
+
const attribution = attributeDispatchTask(brief);
|
|
813
|
+
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
|
814
|
+
const runId = toolCallId ?? `${agent}-${Date.now()}`;
|
|
815
|
+
noteChildRunLaunch(flowId, {
|
|
816
|
+
runId,
|
|
817
|
+
stepIndex: 0,
|
|
818
|
+
agent,
|
|
819
|
+
taskId: attribution.taskId,
|
|
820
|
+
taskUnknown: attribution.taskUnknown,
|
|
821
|
+
});
|
|
822
|
+
// Durable dispatch record (design D3): identity comes from the parent's own
|
|
823
|
+
// launch, so it is written before any completion event can arrive.
|
|
824
|
+
appendAndConfirm(pi, ctx as never, CHILD_REPORT_DISPATCH_ENTRY, {
|
|
825
|
+
version: CHILD_REPORT_RUN_VERSION,
|
|
826
|
+
flowId,
|
|
827
|
+
runId,
|
|
828
|
+
stepIndex: 0,
|
|
829
|
+
agent,
|
|
830
|
+
...(attribution.taskId ? { taskId: attribution.taskId } : {}),
|
|
831
|
+
taskUnknown: attribution.taskUnknown,
|
|
832
|
+
startedAt: new Date().toISOString(),
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Persist a run and, once it is ready, its receipt. Persistence is evidence,
|
|
838
|
+
* not control flow: a failure must not break the run, and the in-memory
|
|
839
|
+
* receipt stays authoritative for the gate (design D5).
|
|
840
|
+
*/
|
|
841
|
+
const persistRun = (run: ChildReportRun | undefined): void => {
|
|
842
|
+
if (!run) return;
|
|
843
|
+
if (run.status !== "ready") {
|
|
844
|
+
// A completed run that never becomes ready is the difference between a
|
|
845
|
+
// blocked task and a working chain, and it is otherwise invisible: the
|
|
846
|
+
// run entry records no status. Say why (design D2/D3).
|
|
847
|
+
deps.onDiagnostic?.(
|
|
848
|
+
`The child run for task ${run.taskId ?? "<unknown>"} produced no usable report (status ${run.status}${run.reason ? `: ${run.reason}` : ""}).`,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
let outcome: { ok: true } | { ok: false; reason: string };
|
|
852
|
+
try {
|
|
853
|
+
outcome = persistChildReportRun(
|
|
854
|
+
pi,
|
|
855
|
+
(persistenceCtx ?? {}) as never,
|
|
856
|
+
run,
|
|
857
|
+
{},
|
|
858
|
+
);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
outcome = {
|
|
861
|
+
ok: false,
|
|
862
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (!outcome.ok) {
|
|
866
|
+
run.persistenceError = outcome.reason;
|
|
867
|
+
deps.onDiagnostic?.(
|
|
868
|
+
`The child report for task ${run.taskId ?? "<unknown>"} could not be recorded durably (${outcome.reason}), so it will not survive a restart.`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Project display-only progress for a report that just became ready.
|
|
875
|
+
*
|
|
876
|
+
* Only a `ready` run is announced: a failed run, a candidate still waiting
|
|
877
|
+
* on its child, or a report whose attempt nonce did not match never reaches
|
|
878
|
+
* the board (design D3/D4).
|
|
879
|
+
*/
|
|
880
|
+
const announceProgress = (
|
|
881
|
+
flowId: string,
|
|
882
|
+
run: ChildReportRun | undefined,
|
|
883
|
+
): void => {
|
|
884
|
+
if (!run || run.status !== "ready" || !run.report) return;
|
|
885
|
+
const sessionId = flows.get(flowId)?.sessionId;
|
|
886
|
+
if (!sessionId) return;
|
|
887
|
+
deps.onProgress?.({
|
|
888
|
+
sessionId,
|
|
889
|
+
toolName: run.toolName,
|
|
890
|
+
report: run.report,
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const events = pi.events;
|
|
895
|
+
events.on("subagent:async-started", (event) => {
|
|
896
|
+
const payload = event as {
|
|
897
|
+
// The native start event names the run `id`; `runId` is accepted too
|
|
898
|
+
// because other lifecycle events use that spelling. Reading only `runId`
|
|
899
|
+
// made this handler return immediately, so the asyncDir was never
|
|
900
|
+
// captured and the background path could never locate the child session.
|
|
901
|
+
id?: string;
|
|
902
|
+
runId?: string;
|
|
903
|
+
asyncDir?: string;
|
|
904
|
+
sessionRoot?: string;
|
|
905
|
+
cwd?: string;
|
|
906
|
+
};
|
|
907
|
+
const startedId = payload.id ?? payload.runId;
|
|
908
|
+
if (!startedId) return;
|
|
909
|
+
const flowId = flowIdFor(undefined);
|
|
910
|
+
if (!flowId) return;
|
|
911
|
+
noteChildRunStarted(flowId, {
|
|
912
|
+
runId: startedId,
|
|
913
|
+
sessionRoot: payload.sessionRoot,
|
|
914
|
+
asyncDir: payload.asyncDir,
|
|
915
|
+
cwd: payload.cwd,
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
events.on("subagent:async-complete", (event) => {
|
|
920
|
+
const payload = event as {
|
|
921
|
+
runId?: string;
|
|
922
|
+
results?: Array<{
|
|
923
|
+
index?: number;
|
|
924
|
+
runId?: string;
|
|
925
|
+
agent?: string;
|
|
926
|
+
status?: string;
|
|
927
|
+
state?: string;
|
|
928
|
+
success?: boolean;
|
|
929
|
+
sessionFile?: string;
|
|
930
|
+
endedAt?: string;
|
|
931
|
+
structuredOutput?: unknown;
|
|
932
|
+
}>;
|
|
933
|
+
};
|
|
934
|
+
const flowId = flowIdFor(undefined);
|
|
935
|
+
if (!flowId) return;
|
|
936
|
+
for (const result of payload.results ?? []) {
|
|
937
|
+
const runId = result.runId ?? payload.runId;
|
|
938
|
+
if (!runId) continue;
|
|
939
|
+
const state = result.state ?? result.status;
|
|
940
|
+
const run = noteChildRunCompleted(
|
|
941
|
+
flowId,
|
|
942
|
+
{
|
|
943
|
+
runId,
|
|
944
|
+
stepIndex: result.index,
|
|
945
|
+
state,
|
|
946
|
+
success: result.success,
|
|
947
|
+
sessionFile: result.sessionFile,
|
|
948
|
+
// A terminal step needs an end boundary; when the event omits one,
|
|
949
|
+
// the moment the parent observed the completion is what it knows.
|
|
950
|
+
endedAt: result.endedAt ?? new Date().toISOString(),
|
|
951
|
+
},
|
|
952
|
+
readTextFile,
|
|
953
|
+
);
|
|
954
|
+
if (!run) {
|
|
955
|
+
deps.onDiagnostic?.(
|
|
956
|
+
`A child completion event arrived for run ${runId}, but no dispatched run matched it, so its report cannot be attributed.`,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
persistRun(run);
|
|
960
|
+
announceProgress(flowId, run);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
events.on("subagent:foreground-complete", (event) => {
|
|
965
|
+
const payload = event as {
|
|
966
|
+
runId?: string;
|
|
967
|
+
results?: Array<{
|
|
968
|
+
index?: number;
|
|
969
|
+
runId?: string;
|
|
970
|
+
status?: string;
|
|
971
|
+
state?: string;
|
|
972
|
+
success?: boolean;
|
|
973
|
+
sessionFile?: string;
|
|
974
|
+
endedAt?: string;
|
|
975
|
+
}>;
|
|
976
|
+
};
|
|
977
|
+
const flowId = flowIdFor(undefined);
|
|
978
|
+
if (!flowId) return;
|
|
979
|
+
for (const result of payload.results ?? []) {
|
|
980
|
+
const runId = result.runId ?? payload.runId;
|
|
981
|
+
if (!runId) continue;
|
|
982
|
+
// The parent receives the child's canonical session file in its own
|
|
983
|
+
// foreground result, so that path's directory is the trusted root for
|
|
984
|
+
// this run. Without it the run has no attributed session and can never
|
|
985
|
+
// become ready, which would silently disable foreground ingestion.
|
|
986
|
+
if (result.sessionFile) {
|
|
987
|
+
noteChildRunStarted(flowId, {
|
|
988
|
+
runId,
|
|
989
|
+
stepIndex: result.index,
|
|
990
|
+
sessionRoot: path.dirname(result.sessionFile),
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const run = noteChildRunCompleted(
|
|
994
|
+
flowId,
|
|
995
|
+
{
|
|
996
|
+
runId,
|
|
997
|
+
stepIndex: result.index,
|
|
998
|
+
state: result.state ?? result.status,
|
|
999
|
+
success: result.success,
|
|
1000
|
+
sessionFile: result.sessionFile,
|
|
1001
|
+
endedAt: result.endedAt ?? new Date().toISOString(),
|
|
1002
|
+
},
|
|
1003
|
+
readTextFile,
|
|
1004
|
+
);
|
|
1005
|
+
if (!run) {
|
|
1006
|
+
deps.onDiagnostic?.(
|
|
1007
|
+
`A child completion event arrived for run ${runId}, but no dispatched run matched it, so its report cannot be attributed.`,
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
persistRun(run);
|
|
1011
|
+
announceProgress(flowId, run);
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// A fresh session or a branch switch starts a new generation: stale run
|
|
1016
|
+
// records from the previous branch must not authorize anything, and any
|
|
1017
|
+
// in-flight read must not land in the new context.
|
|
1018
|
+
// The live foreground completion event carries no sessionFile, so the parent
|
|
1019
|
+
// cannot locate the child's canonical session from it alone. The subagent
|
|
1020
|
+
// tool *result* does carry it, and it can arrive after the completion event,
|
|
1021
|
+
// so capture it here and re-ingest a run that already finished without one.
|
|
1022
|
+
pi.on("tool_result", (event, ctx) => {
|
|
1023
|
+
if ((event as { toolName?: unknown }).toolName !== "subagent") return;
|
|
1024
|
+
persistenceCtx = ctx;
|
|
1025
|
+
const details = (event as { details?: unknown }).details as
|
|
1026
|
+
| { results?: unknown; toolCallId?: unknown }
|
|
1027
|
+
| undefined;
|
|
1028
|
+
const results = Array.isArray(details?.results) ? details.results : [];
|
|
1029
|
+
if (results.length === 0) return;
|
|
1030
|
+
// A result entry carries `index` and `sessionFile` but no `runId`, and the
|
|
1031
|
+
// details object does not reliably carry a tool-call id either. The
|
|
1032
|
+
// tool_result event's own toolCallId is the same key the dispatch used.
|
|
1033
|
+
const eventRunId = (event as { toolCallId?: unknown }).toolCallId;
|
|
1034
|
+
const detailsRunId =
|
|
1035
|
+
(typeof eventRunId === "string" ? eventRunId : undefined) ??
|
|
1036
|
+
(typeof details?.toolCallId === "string"
|
|
1037
|
+
? details.toolCallId
|
|
1038
|
+
: undefined);
|
|
1039
|
+
const flowId = flowIdFor(ctx);
|
|
1040
|
+
if (!flowId) return;
|
|
1041
|
+
const flow = flowFor(flowId);
|
|
1042
|
+
for (const entry of results) {
|
|
1043
|
+
const result = entry as {
|
|
1044
|
+
runId?: unknown;
|
|
1045
|
+
index?: unknown;
|
|
1046
|
+
sessionFile?: unknown;
|
|
1047
|
+
};
|
|
1048
|
+
const runId =
|
|
1049
|
+
(typeof result.runId === "string" ? result.runId : undefined) ??
|
|
1050
|
+
detailsRunId;
|
|
1051
|
+
const sessionFile =
|
|
1052
|
+
typeof result.sessionFile === "string" ? result.sessionFile : undefined;
|
|
1053
|
+
if (!runId || !sessionFile) continue;
|
|
1054
|
+
// Deliberately not `findRun`: that only resolves runs still awaiting a
|
|
1055
|
+
// completion, and this result arrives after the run has finished.
|
|
1056
|
+
const stepIndex =
|
|
1057
|
+
typeof result.index === "number" ? result.index : undefined;
|
|
1058
|
+
const run = flow.order
|
|
1059
|
+
.map((key) => flow.runs.get(key))
|
|
1060
|
+
.find(
|
|
1061
|
+
(candidate): candidate is ChildReportRun =>
|
|
1062
|
+
candidate !== undefined &&
|
|
1063
|
+
candidate.runId === runId &&
|
|
1064
|
+
(stepIndex === undefined || candidate.stepIndex === stepIndex),
|
|
1065
|
+
);
|
|
1066
|
+
if (!run) continue;
|
|
1067
|
+
run.sessionRoot = path.dirname(sessionFile);
|
|
1068
|
+
run.sessionFile = sessionFile;
|
|
1069
|
+
// The subagent tool result returning *is* the foreground completion
|
|
1070
|
+
// signal: the call is over, so the run is terminal and the child session
|
|
1071
|
+
// is closed. Marking it here makes this path self-sufficient rather than
|
|
1072
|
+
// depending on which native event happens to arrive first.
|
|
1073
|
+
run.stepTerminal = true;
|
|
1074
|
+
run.endedAt ??= new Date().toISOString();
|
|
1075
|
+
ingestRun(run, readTextFile);
|
|
1076
|
+
persistRun(run);
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Rebuild this flow's runs and receipts from the current branch (design D5).
|
|
1082
|
+
*
|
|
1083
|
+
* A cold start has no in-memory state, so without this a receipt recorded
|
|
1084
|
+
* before the restart cannot authorize a completion afterwards and the gate
|
|
1085
|
+
* would block forever on work that was already verified.
|
|
1086
|
+
*/
|
|
1087
|
+
const restoreChildReportFlow = (ctx: unknown): void => {
|
|
1088
|
+
try {
|
|
1089
|
+
const flowId = flowIdFor(ctx as never);
|
|
1090
|
+
if (!flowId) return;
|
|
1091
|
+
const branch = readIngestionBranch(ctx as never);
|
|
1092
|
+
if (branch.length === 0) return;
|
|
1093
|
+
rebuildChildReportFlow(flowId, branch);
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Restoring is best-effort: a failure leaves the flow unrebuilt, which
|
|
1096
|
+
// keeps the gate closed rather than opening it on unverified state.
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1101
|
+
resetChildReportIngestionForTests();
|
|
1102
|
+
restoreChildReportFlow(ctx);
|
|
1103
|
+
});
|
|
1104
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
1105
|
+
resetChildReportIngestionForTests();
|
|
1106
|
+
restoreChildReportFlow(ctx);
|
|
1107
|
+
});
|
|
1108
|
+
// Nothing outlives the session it observed: watchers, handles and buffers
|
|
1109
|
+
// are released on shutdown (design D4).
|
|
1110
|
+
pi.on("session_shutdown", () => {
|
|
1111
|
+
releaseChildReportIngestion();
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
// Durable run and receipt entries (design D5, task 4.2)
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
/** The durable shape of a run, written to the owning branch. */
|
|
1120
|
+
export interface DurableRunPayload {
|
|
1121
|
+
version: number;
|
|
1122
|
+
flowId: string;
|
|
1123
|
+
changeId?: string | undefined;
|
|
1124
|
+
goalId?: string | undefined;
|
|
1125
|
+
runId: string;
|
|
1126
|
+
stepIndex: number;
|
|
1127
|
+
agent: string;
|
|
1128
|
+
taskId?: string | undefined;
|
|
1129
|
+
taskUnknown: boolean;
|
|
1130
|
+
startedAt?: string | undefined;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** The durable shape of an accepted report. */
|
|
1134
|
+
export interface DurableReceiptPayload {
|
|
1135
|
+
version: number;
|
|
1136
|
+
flowId: string;
|
|
1137
|
+
goalId?: string | undefined;
|
|
1138
|
+
runId: string;
|
|
1139
|
+
stepIndex: number;
|
|
1140
|
+
agent: string;
|
|
1141
|
+
taskId: string;
|
|
1142
|
+
toolCallId: string;
|
|
1143
|
+
attemptId: string;
|
|
1144
|
+
changedFiles: string[];
|
|
1145
|
+
claims?: string | undefined;
|
|
1146
|
+
verificationPassed?: boolean | undefined;
|
|
1147
|
+
selfVerification?: { command?: string; result?: string } | undefined;
|
|
1148
|
+
recordedAt: string;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Minimal branch-entry view; the host owns the real type. */
|
|
1152
|
+
export interface BranchEntryLike {
|
|
1153
|
+
id?: string;
|
|
1154
|
+
customType?: string;
|
|
1155
|
+
data?: unknown;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Read the current branch, tolerating hosts that expose no manager. */
|
|
1159
|
+
export function readIngestionBranch(ctx: {
|
|
1160
|
+
sessionManager?: { getBranch?(): readonly unknown[] };
|
|
1161
|
+
}): BranchEntryLike[] {
|
|
1162
|
+
try {
|
|
1163
|
+
return (ctx.sessionManager?.getBranch?.() ?? []) as BranchEntryLike[];
|
|
1164
|
+
} catch {
|
|
1165
|
+
return [];
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* The persisted session file, when the host has one.
|
|
1171
|
+
*
|
|
1172
|
+
* Confirmation reads this rather than the in-memory branch: an entry can be
|
|
1173
|
+
* appended to the branch and still fail to reach the record, and a receipt
|
|
1174
|
+
* that only exists in memory must never authorize a completion (design D5).
|
|
1175
|
+
*/
|
|
1176
|
+
function sessionFileOf(ctx: {
|
|
1177
|
+
sessionManager?: { getSessionFile?(): string | null };
|
|
1178
|
+
}): string | undefined {
|
|
1179
|
+
try {
|
|
1180
|
+
return ctx.sessionManager?.getSessionFile?.() ?? undefined;
|
|
1181
|
+
} catch {
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function entryMatches(
|
|
1187
|
+
entry: BranchEntryLike,
|
|
1188
|
+
customType: string,
|
|
1189
|
+
runId: string,
|
|
1190
|
+
): boolean {
|
|
1191
|
+
if (entry.customType !== customType) return false;
|
|
1192
|
+
const data = entry.data as { runId?: unknown } | undefined;
|
|
1193
|
+
return data?.runId === runId;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Append a durable entry and confirm it actually landed.
|
|
1198
|
+
*
|
|
1199
|
+
* A thrown append and a silently missing entry are both failures; neither may
|
|
1200
|
+
* be treated as success, because the gate would then rely on evidence that
|
|
1201
|
+
* does not exist (design D5).
|
|
1202
|
+
*/
|
|
1203
|
+
export function appendAndConfirm<T extends { runId: string }>(
|
|
1204
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
1205
|
+
ctx: {
|
|
1206
|
+
sessionManager?: {
|
|
1207
|
+
getBranch?(): readonly unknown[];
|
|
1208
|
+
getSessionFile?(): string | null;
|
|
1209
|
+
};
|
|
1210
|
+
},
|
|
1211
|
+
customType: string,
|
|
1212
|
+
payload: T,
|
|
1213
|
+
):
|
|
1214
|
+
| { ok: true; confirmedBy: "disk" | "branch" }
|
|
1215
|
+
| { ok: false; reason: string } {
|
|
1216
|
+
try {
|
|
1217
|
+
pi.appendEntry(customType, payload);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
return {
|
|
1220
|
+
ok: false,
|
|
1221
|
+
reason: `writing the ${customType} entry failed: ${
|
|
1222
|
+
error instanceof Error ? error.message : String(error)
|
|
1223
|
+
}`,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const file = sessionFileOf(ctx);
|
|
1228
|
+
if (file) {
|
|
1229
|
+
let content: string;
|
|
1230
|
+
try {
|
|
1231
|
+
content = fs.readFileSync(file, "utf8");
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
return {
|
|
1234
|
+
ok: false,
|
|
1235
|
+
reason: `the session record could not be read to confirm the ${customType} entry: ${
|
|
1236
|
+
error instanceof Error ? error.message : String(error)
|
|
1237
|
+
}`,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
for (const line of content.split("\n")) {
|
|
1241
|
+
if (!line.trim()) continue;
|
|
1242
|
+
let parsed: unknown;
|
|
1243
|
+
try {
|
|
1244
|
+
parsed = JSON.parse(line);
|
|
1245
|
+
} catch {
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
if (entryMatches(parsed as BranchEntryLike, customType, payload.runId)) {
|
|
1249
|
+
return { ok: true, confirmedBy: "disk" };
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return {
|
|
1253
|
+
ok: false,
|
|
1254
|
+
reason: `the ${customType} entry is not present in the session record, so it was not durably recorded`,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// No persisted record to check against (in-memory sessions): the branch is
|
|
1259
|
+
// the best available witness, and that is recorded rather than hidden.
|
|
1260
|
+
const branch = readIngestionBranch(ctx);
|
|
1261
|
+
if (branch.some((entry) => entryMatches(entry, customType, payload.runId))) {
|
|
1262
|
+
return { ok: true, confirmedBy: "branch" };
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
ok: false,
|
|
1266
|
+
reason: `the ${customType} entry did not appear on the branch after it was appended`,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** Build the durable run payload for a recorded run. */
|
|
1271
|
+
export function buildRunPayload(
|
|
1272
|
+
run: ChildReportRun,
|
|
1273
|
+
binding: { changeId?: string | undefined; goalId?: string | undefined } = {},
|
|
1274
|
+
): DurableRunPayload {
|
|
1275
|
+
return {
|
|
1276
|
+
version: CHILD_REPORT_RUN_VERSION,
|
|
1277
|
+
flowId: run.flowId,
|
|
1278
|
+
changeId: binding.changeId,
|
|
1279
|
+
goalId: binding.goalId,
|
|
1280
|
+
runId: run.runId,
|
|
1281
|
+
stepIndex: run.stepIndex,
|
|
1282
|
+
agent: run.agent,
|
|
1283
|
+
taskId: run.taskId,
|
|
1284
|
+
taskUnknown: run.taskUnknown,
|
|
1285
|
+
startedAt: run.startedAt,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Build the durable receipt payload for a ready run. */
|
|
1290
|
+
export function buildReceiptPayload(
|
|
1291
|
+
run: ChildReportRun,
|
|
1292
|
+
binding: { goalId?: string | undefined } = {},
|
|
1293
|
+
recordedAt: string = new Date().toISOString(),
|
|
1294
|
+
): DurableReceiptPayload | undefined {
|
|
1295
|
+
if (run.status !== "ready" || !run.taskId) return undefined;
|
|
1296
|
+
const report = run.report ?? {};
|
|
1297
|
+
const changedFiles = Array.isArray(report.changedFiles)
|
|
1298
|
+
? report.changedFiles.filter(
|
|
1299
|
+
(file): file is string => typeof file === "string",
|
|
1300
|
+
)
|
|
1301
|
+
: [];
|
|
1302
|
+
const selfVerification = isRecord(report.selfVerification)
|
|
1303
|
+
? {
|
|
1304
|
+
command: asString(report.selfVerification.command),
|
|
1305
|
+
result: asString(report.selfVerification.result),
|
|
1306
|
+
}
|
|
1307
|
+
: undefined;
|
|
1308
|
+
return {
|
|
1309
|
+
version: CHILD_REPORT_RECEIPT_VERSION,
|
|
1310
|
+
flowId: run.flowId,
|
|
1311
|
+
goalId: binding.goalId,
|
|
1312
|
+
runId: run.runId,
|
|
1313
|
+
stepIndex: run.stepIndex,
|
|
1314
|
+
agent: run.agent,
|
|
1315
|
+
taskId: run.taskId,
|
|
1316
|
+
toolCallId: run.toolCallId ?? "",
|
|
1317
|
+
attemptId: run.attemptId ?? "",
|
|
1318
|
+
changedFiles,
|
|
1319
|
+
claims: asString(report.claims),
|
|
1320
|
+
verificationPassed:
|
|
1321
|
+
typeof report.verificationPassed === "boolean"
|
|
1322
|
+
? report.verificationPassed
|
|
1323
|
+
: undefined,
|
|
1324
|
+
selfVerification,
|
|
1325
|
+
recordedAt,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** Validate a durable run payload read back from a branch. */
|
|
1330
|
+
export function parseDurableRun(value: unknown): DurableRunPayload | undefined {
|
|
1331
|
+
if (!isRecord(value)) return undefined;
|
|
1332
|
+
if (value.version !== CHILD_REPORT_RUN_VERSION) return undefined;
|
|
1333
|
+
const runId = asString(value.runId);
|
|
1334
|
+
const flowId = asString(value.flowId);
|
|
1335
|
+
const agent = asString(value.agent);
|
|
1336
|
+
if (!runId || !flowId || !agent) return undefined;
|
|
1337
|
+
return {
|
|
1338
|
+
version: CHILD_REPORT_RUN_VERSION,
|
|
1339
|
+
flowId,
|
|
1340
|
+
changeId: asString(value.changeId),
|
|
1341
|
+
goalId: asString(value.goalId),
|
|
1342
|
+
runId,
|
|
1343
|
+
stepIndex: typeof value.stepIndex === "number" ? value.stepIndex : 0,
|
|
1344
|
+
agent,
|
|
1345
|
+
taskId: asString(value.taskId),
|
|
1346
|
+
taskUnknown: value.taskUnknown === true,
|
|
1347
|
+
startedAt: asString(value.startedAt),
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** Validate a durable receipt payload read back from a branch. */
|
|
1352
|
+
export function parseDurableReceipt(
|
|
1353
|
+
value: unknown,
|
|
1354
|
+
): DurableReceiptPayload | undefined {
|
|
1355
|
+
if (!isRecord(value)) return undefined;
|
|
1356
|
+
if (value.version !== CHILD_REPORT_RECEIPT_VERSION) return undefined;
|
|
1357
|
+
const runId = asString(value.runId);
|
|
1358
|
+
const flowId = asString(value.flowId);
|
|
1359
|
+
const taskId = asString(value.taskId);
|
|
1360
|
+
const agent = asString(value.agent);
|
|
1361
|
+
if (!runId || !flowId || !taskId || !agent) return undefined;
|
|
1362
|
+
return {
|
|
1363
|
+
version: CHILD_REPORT_RECEIPT_VERSION,
|
|
1364
|
+
flowId,
|
|
1365
|
+
goalId: asString(value.goalId),
|
|
1366
|
+
runId,
|
|
1367
|
+
stepIndex: typeof value.stepIndex === "number" ? value.stepIndex : 0,
|
|
1368
|
+
agent,
|
|
1369
|
+
taskId,
|
|
1370
|
+
toolCallId: asString(value.toolCallId) ?? "",
|
|
1371
|
+
attemptId: asString(value.attemptId) ?? "",
|
|
1372
|
+
changedFiles: Array.isArray(value.changedFiles)
|
|
1373
|
+
? value.changedFiles.filter(
|
|
1374
|
+
(file): file is string => typeof file === "string",
|
|
1375
|
+
)
|
|
1376
|
+
: [],
|
|
1377
|
+
claims: asString(value.claims),
|
|
1378
|
+
verificationPassed:
|
|
1379
|
+
typeof value.verificationPassed === "boolean"
|
|
1380
|
+
? value.verificationPassed
|
|
1381
|
+
: undefined,
|
|
1382
|
+
selfVerification: isRecord(value.selfVerification)
|
|
1383
|
+
? {
|
|
1384
|
+
command: asString(value.selfVerification.command),
|
|
1385
|
+
result: asString(value.selfVerification.result),
|
|
1386
|
+
}
|
|
1387
|
+
: undefined,
|
|
1388
|
+
recordedAt: asString(value.recordedAt) ?? "",
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Rebuild a flow's runs and receipts from the owning branch.
|
|
1394
|
+
*
|
|
1395
|
+
* Replay is ordered, so a later dispatch for the same task invalidates an
|
|
1396
|
+
* earlier receipt instead of leaving it available to authorize a completion
|
|
1397
|
+
* (design D3/D5). Rebuilding is idempotent: it replaces the flow's state rather
|
|
1398
|
+
* than appending to it.
|
|
1399
|
+
*/
|
|
1400
|
+
export function rebuildChildReportFlow(
|
|
1401
|
+
flowId: string,
|
|
1402
|
+
branch: readonly BranchEntryLike[],
|
|
1403
|
+
): { runs: ChildReportRun[]; receipts: DurableReceiptPayload[] } {
|
|
1404
|
+
const runs: DurableRunPayload[] = [];
|
|
1405
|
+
const receipts: DurableReceiptPayload[] = [];
|
|
1406
|
+
for (const entry of branch) {
|
|
1407
|
+
if (entry.customType === CHILD_REPORT_RUN_ENTRY) {
|
|
1408
|
+
const parsed = parseDurableRun(entry.data);
|
|
1409
|
+
if (parsed && parsed.flowId === flowId) runs.push(parsed);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (entry.customType === CHILD_REPORT_RECEIPT_ENTRY) {
|
|
1413
|
+
const parsed = parseDurableReceipt(entry.data);
|
|
1414
|
+
if (parsed && parsed.flowId === flowId) receipts.push(parsed);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const flow = flowFor(flowId);
|
|
1419
|
+
flow.runs.clear();
|
|
1420
|
+
flow.order = [];
|
|
1421
|
+
|
|
1422
|
+
// A receipt is only still authoritative while no later run was dispatched
|
|
1423
|
+
// for the same task; that is what "the latest dispatch invalidates the old
|
|
1424
|
+
// receipt" means in durable terms.
|
|
1425
|
+
const latestDispatchIndex = new Map<string, number>();
|
|
1426
|
+
runs.forEach((run, index) => {
|
|
1427
|
+
if (run.taskId) latestDispatchIndex.set(run.taskId, index);
|
|
1428
|
+
});
|
|
1429
|
+
const receiptIndex = new Map<string, number>();
|
|
1430
|
+
receipts.forEach((receipt, index) => {
|
|
1431
|
+
if (receipt.taskId) receiptIndex.set(receipt.taskId, index);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
const effectiveReceipts = receipts.filter((receipt) => {
|
|
1435
|
+
const dispatchIndex = latestDispatchIndex.get(receipt.taskId);
|
|
1436
|
+
const index = receiptIndex.get(receipt.taskId);
|
|
1437
|
+
if (dispatchIndex === undefined || index === undefined) return false;
|
|
1438
|
+
return dispatchIndex <= index;
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
for (const payload of runs) {
|
|
1442
|
+
const key = runKey(payload.runId, payload.stepIndex);
|
|
1443
|
+
const restored: ChildReportRun = {
|
|
1444
|
+
flowId,
|
|
1445
|
+
runId: payload.runId,
|
|
1446
|
+
stepIndex: payload.stepIndex,
|
|
1447
|
+
agent: payload.agent,
|
|
1448
|
+
toolName: CHILD_REPORT_TOOL_BY_AGENT[payload.agent] ?? "",
|
|
1449
|
+
taskId: payload.taskId,
|
|
1450
|
+
taskUnknown: payload.taskUnknown,
|
|
1451
|
+
startedAt: payload.startedAt,
|
|
1452
|
+
stepTerminal: false,
|
|
1453
|
+
status: "pending",
|
|
1454
|
+
};
|
|
1455
|
+
const receipt = effectiveReceipts.find(
|
|
1456
|
+
(candidate) =>
|
|
1457
|
+
candidate.runId === payload.runId &&
|
|
1458
|
+
candidate.stepIndex === payload.stepIndex,
|
|
1459
|
+
);
|
|
1460
|
+
if (receipt) {
|
|
1461
|
+
restored.status = "ready";
|
|
1462
|
+
restored.taskId = receipt.taskId;
|
|
1463
|
+
restored.toolCallId = receipt.toolCallId;
|
|
1464
|
+
restored.attemptId = receipt.attemptId;
|
|
1465
|
+
restored.report = {
|
|
1466
|
+
taskId: receipt.taskId,
|
|
1467
|
+
changedFiles: receipt.changedFiles,
|
|
1468
|
+
claims: receipt.claims,
|
|
1469
|
+
verificationPassed: receipt.verificationPassed,
|
|
1470
|
+
selfVerification: receipt.selfVerification,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
if (!flow.runs.has(key)) flow.order.push(key);
|
|
1474
|
+
flow.runs.set(key, restored);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return { runs: childRunsForFlow(flowId), receipts: effectiveReceipts };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Persist a run and, when it is ready, its receipt — each confirmed before it
|
|
1482
|
+
* is treated as evidence.
|
|
1483
|
+
*/
|
|
1484
|
+
export function persistChildReportRun(
|
|
1485
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
1486
|
+
ctx: {
|
|
1487
|
+
sessionManager?: {
|
|
1488
|
+
getBranch?(): readonly unknown[];
|
|
1489
|
+
getSessionFile?(): string | null;
|
|
1490
|
+
};
|
|
1491
|
+
},
|
|
1492
|
+
run: ChildReportRun,
|
|
1493
|
+
binding: { changeId?: string | undefined; goalId?: string | undefined } = {},
|
|
1494
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
1495
|
+
const runResult = appendAndConfirm(
|
|
1496
|
+
pi,
|
|
1497
|
+
ctx,
|
|
1498
|
+
CHILD_REPORT_RUN_ENTRY,
|
|
1499
|
+
buildRunPayload(run, binding),
|
|
1500
|
+
);
|
|
1501
|
+
if (!runResult.ok) return runResult;
|
|
1502
|
+
|
|
1503
|
+
if (run.status !== "ready") return { ok: true };
|
|
1504
|
+
const receipt = buildReceiptPayload(run, binding);
|
|
1505
|
+
if (!receipt) {
|
|
1506
|
+
return {
|
|
1507
|
+
ok: false,
|
|
1508
|
+
reason:
|
|
1509
|
+
"the run is ready but carries no attributable task, so no receipt can be recorded",
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
const receiptResult = appendAndConfirm(
|
|
1513
|
+
pi,
|
|
1514
|
+
ctx,
|
|
1515
|
+
CHILD_REPORT_RECEIPT_ENTRY,
|
|
1516
|
+
receipt,
|
|
1517
|
+
);
|
|
1518
|
+
if (!receiptResult.ok) return receiptResult;
|
|
1519
|
+
return { ok: true };
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// ---------------------------------------------------------------------------
|
|
1523
|
+
/**
|
|
1524
|
+
* Release everything this session observed.
|
|
1525
|
+
*
|
|
1526
|
+
* Called on session_shutdown so nothing outlives the runs it observed.
|
|
1527
|
+
*/
|
|
1528
|
+
export function releaseChildReportIngestion(): void {
|
|
1529
|
+
resetChildReportIngestionForTests();
|
|
1530
|
+
}
|