pi-subagents 0.45.2 → 0.46.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 +24 -0
- package/README.md +2 -0
- package/docs/agents.md +342 -0
- package/docs/configuration.md +320 -0
- package/docs/extension-api.md +308 -0
- package/docs/missions.md +117 -0
- package/docs/models.md +190 -0
- package/docs/observability.md +174 -0
- package/docs/tool-reference.md +343 -0
- package/docs/watchdog.md +176 -0
- package/docs/workflows.md +163 -0
- package/package.json +4 -2
- package/skills/pi-subagents/references/execution-controls.md +2 -2
- package/src/agents/agents.ts +17 -8
- package/src/agents/frontmatter.ts +7 -3
- package/src/agents/skills.ts +2 -9
- package/src/api/project-panes.ts +30 -0
- package/src/extension/config.ts +15 -1
- package/src/extension/index.ts +36 -16
- package/src/extension/schemas.ts +3 -2
- package/src/extension/subagent-guide.ts +39 -0
- package/src/extension/tool-description.ts +4 -4
- package/src/inspectors/herdr/project-panes.ts +457 -62
- package/src/missions/actions.ts +25 -2
- package/src/missions/lifecycle.ts +21 -2
- package/src/missions/store.ts +77 -1
- package/src/missions/types.ts +33 -0
- package/src/runs/background/async-execution.ts +7 -1
- package/src/runs/background/completion-replay.ts +267 -0
- package/src/runs/background/result-watcher.ts +12 -4
- package/src/runs/background/wait-completions.ts +39 -5
- package/src/runs/background/wait-subscriptions.ts +18 -3
- package/src/runs/foreground/execution.ts +4 -0
- package/src/runs/foreground/foreground-history.ts +137 -0
- package/src/runs/foreground/subagent-executor.ts +310 -44
- package/src/shared/fork-context.ts +13 -0
- package/src/shared/prompt-resources.ts +51 -0
- package/src/shared/types.ts +30 -1
- package/src/shared/utf8.ts +11 -0
- package/src/slash/prompt-workflows.ts +2 -15
- package/src/slash/slash-commands.ts +19 -1
- package/src/tui/fleet-status.ts +8 -2
- package/src/tui/fleet.ts +135 -25
- package/src/tui/render.ts +120 -7
- package/src/workflows/scripted-workflow.ts +167 -10
|
@@ -160,7 +160,7 @@ function persistedBinding(binding: MissionLaunchBinding): PersistedMissionBindin
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
function
|
|
163
|
+
export function writeMissionAsyncBinding(asyncDir: string, binding: MissionLaunchBinding): void {
|
|
164
164
|
writePrivateAtomicJson(path.join(asyncDir, MISSION_BINDING_FILE), persistedBinding(binding));
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -210,7 +210,7 @@ export function attachMissionToLaunchResult(input: {
|
|
|
210
210
|
...(input.result.details.results.length === 1 && input.result.details.results[0]?.acceptance ? { acceptance: input.result.details.results[0].acceptance } : {}),
|
|
211
211
|
});
|
|
212
212
|
if (input.result.details.asyncDir) {
|
|
213
|
-
|
|
213
|
+
writeMissionAsyncBinding(input.result.details.asyncDir, input.binding);
|
|
214
214
|
const statusPath = path.join(input.result.details.asyncDir, "status.json");
|
|
215
215
|
if (fs.existsSync(statusPath)) {
|
|
216
216
|
try {
|
|
@@ -337,10 +337,29 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
|
|
|
337
337
|
return total + (usageFromUnknown((result as { tokens?: unknown }).tokens)?.tokens ?? 0);
|
|
338
338
|
}, 0) }
|
|
339
339
|
: undefined);
|
|
340
|
+
const workflowRunId = typeof event.parentWorkflowRunId === "string" && event.parentWorkflowRunId.trim() ? event.parentWorkflowRunId.trim() : undefined;
|
|
341
|
+
const workflowKey = typeof event.workflowKey === "string" && event.workflowKey.trim() ? event.workflowKey.trim() : undefined;
|
|
342
|
+
const workflowChildStatus = runStatus === "complete" || runStatus === "completed" || event.success === true
|
|
343
|
+
? "completed"
|
|
344
|
+
: runStatus === "paused"
|
|
345
|
+
? "paused"
|
|
346
|
+
: runStatus === "stopped"
|
|
347
|
+
? "stopped"
|
|
348
|
+
: "failed";
|
|
349
|
+
const workflowChildTerminal = !["running", "queued", "active", "paused"].includes(workflowChildStatus);
|
|
340
350
|
return updateMission(binding.location, binding.missionId, {
|
|
341
351
|
status: missionStatusForRun(current, runId, runStatus),
|
|
342
352
|
addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain", "workflow"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
|
|
343
353
|
addArtifacts: artifacts,
|
|
354
|
+
...(workflowRunId && workflowKey ? { upsertWorkflowChildren: [{
|
|
355
|
+
workflowRunId,
|
|
356
|
+
key: workflowKey,
|
|
357
|
+
runId,
|
|
358
|
+
status: workflowChildStatus,
|
|
359
|
+
artifactPaths: artifacts.map((artifact) => artifact.path),
|
|
360
|
+
...(workflowChildTerminal ? { completedAt } : {}),
|
|
361
|
+
heartbeat: { status: workflowChildStatus, ...(summary ? { message: summary } : {}) },
|
|
362
|
+
}] } : {}),
|
|
344
363
|
...(summary ? { summary } : {}),
|
|
345
364
|
});
|
|
346
365
|
}
|
package/src/missions/store.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type MissionTokenBudget,
|
|
28
28
|
type MissionTokenUsage,
|
|
29
29
|
type MissionUpdateInput,
|
|
30
|
+
type MissionWorkflowChild,
|
|
30
31
|
} from "./types.ts";
|
|
31
32
|
|
|
32
33
|
const MISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -149,6 +150,33 @@ function parseDecision(value: unknown, label: string): MissionDecision {
|
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
function parseWorkflowChild(value: unknown, label: string): MissionWorkflowChild {
|
|
154
|
+
const input = asObject(value, label);
|
|
155
|
+
const artifactPaths = input.artifactPaths === undefined ? [] : stringArray(input.artifactPaths, `${label}.artifactPaths`);
|
|
156
|
+
const heartbeat = input.heartbeat === undefined ? undefined : asObject(input.heartbeat, `${label}.heartbeat`);
|
|
157
|
+
return {
|
|
158
|
+
workflowRunId: requiredString(input.workflowRunId, `${label}.workflowRunId`),
|
|
159
|
+
key: validateMissionId(input.key, `${label}.key`),
|
|
160
|
+
status: requiredString(input.status, `${label}.status`),
|
|
161
|
+
startedAt: timestamp(input.startedAt, `${label}.startedAt`),
|
|
162
|
+
updatedAt: timestamp(input.updatedAt, `${label}.updatedAt`),
|
|
163
|
+
artifactPaths,
|
|
164
|
+
...(optionalString(input.runId, `${label}.runId`) ? { runId: input.runId as string } : {}),
|
|
165
|
+
...(optionalString(input.agent, `${label}.agent`) ? { agent: input.agent as string } : {}),
|
|
166
|
+
...(optionalString(input.task, `${label}.task`) ? { task: input.task as string } : {}),
|
|
167
|
+
...(optionalString(input.label, `${label}.label`) ? { label: input.label as string } : {}),
|
|
168
|
+
...(optionalString(input.phase, `${label}.phase`) ? { phase: input.phase as string } : {}),
|
|
169
|
+
...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
|
|
170
|
+
...(optionalString(input.sessionPath, `${label}.sessionPath`) ? { sessionPath: input.sessionPath as string } : {}),
|
|
171
|
+
...(heartbeat ? { heartbeat: {
|
|
172
|
+
updatedAt: timestamp(heartbeat.updatedAt, `${label}.heartbeat.updatedAt`),
|
|
173
|
+
...(optionalString(heartbeat.status, `${label}.heartbeat.status`) ? { status: heartbeat.status as string } : {}),
|
|
174
|
+
...(optionalString(heartbeat.phase, `${label}.heartbeat.phase`) ? { phase: heartbeat.phase as string } : {}),
|
|
175
|
+
...(optionalString(heartbeat.message, `${label}.heartbeat.message`) ? { message: heartbeat.message as string } : {}),
|
|
176
|
+
} } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
152
180
|
function parseArtifact(value: unknown, label: string): MissionArtifact {
|
|
153
181
|
const input = asObject(value, label);
|
|
154
182
|
const kind = requiredString(input.kind, `${label}.kind`) as MissionArtifactKind;
|
|
@@ -186,10 +214,12 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
|
|
|
186
214
|
const input = asObject(value, source);
|
|
187
215
|
if (input.schemaVersion !== 1) throw new Error(`${source}.schemaVersion must be 1`);
|
|
188
216
|
if (!Array.isArray(input.runs)) throw new Error(`${source}.runs must be an array`);
|
|
217
|
+
if (input.workflowChildren !== undefined && !Array.isArray(input.workflowChildren)) throw new Error(`${source}.workflowChildren must be an array`);
|
|
189
218
|
if (!Array.isArray(input.decisions)) throw new Error(`${source}.decisions must be an array`);
|
|
190
219
|
if (!Array.isArray(input.artifacts)) throw new Error(`${source}.artifacts must be an array`);
|
|
191
220
|
if (input.receipts !== undefined && !Array.isArray(input.receipts)) throw new Error(`${source}.receipts must be an array`);
|
|
192
221
|
const runs = input.runs as unknown[];
|
|
222
|
+
const workflowChildren = (input.workflowChildren ?? []) as unknown[];
|
|
193
223
|
const decisions = input.decisions as unknown[];
|
|
194
224
|
const artifacts = input.artifacts as unknown[];
|
|
195
225
|
const receipts = (input.receipts ?? []) as unknown[];
|
|
@@ -211,6 +241,7 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
|
|
|
211
241
|
createdAt: timestamp(input.createdAt, `${source}.createdAt`),
|
|
212
242
|
updatedAt: timestamp(input.updatedAt, `${source}.updatedAt`),
|
|
213
243
|
runs: runs.map((item, index) => parseRunLink(item, `${source}.runs[${index}]`)),
|
|
244
|
+
workflowChildren: workflowChildren.map((item, index) => parseWorkflowChild(item, `${source}.workflowChildren[${index}]`)),
|
|
214
245
|
decisions: decisions.map((item, index) => parseDecision(item, `${source}.decisions[${index}]`)),
|
|
215
246
|
artifacts: artifacts.map((item, index) => parseArtifact(item, `${source}.artifacts[${index}]`)),
|
|
216
247
|
receipts: receipts.map((item, index) => parseReceipt(item, `${source}.receipts[${index}]`)),
|
|
@@ -346,6 +377,7 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
|
|
|
346
377
|
updatedAt: createdAt,
|
|
347
378
|
cwd: location.projectRoot,
|
|
348
379
|
runs: [],
|
|
380
|
+
workflowChildren: [],
|
|
349
381
|
decisions: [],
|
|
350
382
|
artifacts: [],
|
|
351
383
|
receipts: [],
|
|
@@ -412,6 +444,28 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
412
444
|
if (existingIndex === -1) runs.push(run);
|
|
413
445
|
else runs[existingIndex] = { ...runs[existingIndex]!, ...run };
|
|
414
446
|
}
|
|
447
|
+
const workflowChildren = [...current.workflowChildren];
|
|
448
|
+
for (const candidate of update.upsertWorkflowChildren ?? []) {
|
|
449
|
+
const nowIso = now.toISOString();
|
|
450
|
+
const parsed = parseWorkflowChild({
|
|
451
|
+
...candidate,
|
|
452
|
+
startedAt: candidate.startedAt ?? nowIso,
|
|
453
|
+
updatedAt: nowIso,
|
|
454
|
+
artifactPaths: candidate.artifactPaths ?? [],
|
|
455
|
+
...(candidate.heartbeat ? { heartbeat: { ...candidate.heartbeat, updatedAt: nowIso } } : {}),
|
|
456
|
+
}, "mission.update.upsertWorkflowChildren[]");
|
|
457
|
+
const existingIndex = workflowChildren.findIndex((child) => child.workflowRunId === parsed.workflowRunId && child.key === parsed.key);
|
|
458
|
+
if (existingIndex === -1) workflowChildren.push(parsed);
|
|
459
|
+
else {
|
|
460
|
+
const existing = workflowChildren[existingIndex]!;
|
|
461
|
+
workflowChildren[existingIndex] = parseWorkflowChild({
|
|
462
|
+
...existing,
|
|
463
|
+
...parsed,
|
|
464
|
+
startedAt: existing.startedAt,
|
|
465
|
+
artifactPaths: [...new Set([...existing.artifactPaths, ...parsed.artifactPaths])],
|
|
466
|
+
}, "mission.update.upsertWorkflowChildren[]");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
415
469
|
const artifacts = [...current.artifacts];
|
|
416
470
|
for (const candidate of update.addArtifacts ?? []) {
|
|
417
471
|
const artifact = parseArtifact(candidate, "mission.update.addArtifacts[]");
|
|
@@ -439,6 +493,18 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
439
493
|
...(decision.recommendation ? { recommendation: requiredString(decision.recommendation, "mission.update.addDecisions[].recommendation") } : {}),
|
|
440
494
|
})),
|
|
441
495
|
];
|
|
496
|
+
if (update.resolveDecision) {
|
|
497
|
+
const decisionId = validateMissionId(update.resolveDecision.id, "mission.update.resolveDecision.id");
|
|
498
|
+
const decisionIndex = decisions.findIndex((decision) => decision.id === decisionId);
|
|
499
|
+
if (decisionIndex === -1) throw new Error(`Decision '${decisionId}' was not found in mission '${missionId}'`);
|
|
500
|
+
if (decisions[decisionIndex]!.status === "resolved") throw new Error(`Decision '${decisionId}' is already resolved`);
|
|
501
|
+
decisions[decisionIndex] = {
|
|
502
|
+
...decisions[decisionIndex]!,
|
|
503
|
+
status: "resolved",
|
|
504
|
+
resolvedAt: createdAt,
|
|
505
|
+
resolution: requiredString(update.resolveDecision.resolution, "mission.update.resolveDecision.resolution").trim(),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
442
508
|
const budget = update.budget !== undefined ? parseBudget(update.budget, "mission.update.budget") : current.budget;
|
|
443
509
|
const usage = update.usage !== undefined
|
|
444
510
|
? parseUsage(update.usage, "mission.update.usage")
|
|
@@ -452,10 +518,20 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
452
518
|
? { status: "active" }
|
|
453
519
|
: goal;
|
|
454
520
|
}
|
|
521
|
+
const hasOpenDecisions = decisions.some((decision) => decision.status === "open");
|
|
522
|
+
const requestedStatus = update.status !== undefined ? missionStatus(update.status, "mission.update.status") : undefined;
|
|
523
|
+
const candidateStatus = requestedStatus
|
|
524
|
+
?? (update.addDecisions?.length && current.status === "active"
|
|
525
|
+
? "needs_decision"
|
|
526
|
+
: update.resolveDecision && current.status === "needs_decision" && !hasOpenDecisions
|
|
527
|
+
? "active"
|
|
528
|
+
: current.status);
|
|
529
|
+
const decisionStatus = hasOpenDecisions && (candidateStatus === "active" || candidateStatus === "completed") ? "needs_decision" : candidateStatus;
|
|
455
530
|
const next: MissionRecord = {
|
|
456
531
|
...current,
|
|
457
532
|
updatedAt: createdAt,
|
|
458
533
|
runs,
|
|
534
|
+
workflowChildren,
|
|
459
535
|
artifacts,
|
|
460
536
|
receipts,
|
|
461
537
|
decisions,
|
|
@@ -463,7 +539,7 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
463
539
|
...(update.objective !== undefined ? { objective: requiredString(update.objective, "mission.update.objective").trim() } : {}),
|
|
464
540
|
...(budget ? { budget } : {}),
|
|
465
541
|
...(goal ? { goal, usage } : {}),
|
|
466
|
-
|
|
542
|
+
status: decisionStatus,
|
|
467
543
|
...(update.summary !== undefined ? { summary: requiredString(update.summary, "mission.update.summary") } : {}),
|
|
468
544
|
...(update.labels !== undefined ? { labels: stringArray(update.labels, "mission.update.labels") } : {}),
|
|
469
545
|
...(update.acceptance !== undefined ? { acceptance: update.acceptance } : {}),
|
package/src/missions/types.ts
CHANGED
|
@@ -51,6 +51,36 @@ export interface MissionDecision {
|
|
|
51
51
|
resolution?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export interface MissionChildHeartbeat {
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
status?: string;
|
|
57
|
+
phase?: string;
|
|
58
|
+
message?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MissionWorkflowChild {
|
|
62
|
+
workflowRunId: string;
|
|
63
|
+
key: string;
|
|
64
|
+
status: string;
|
|
65
|
+
startedAt: string;
|
|
66
|
+
updatedAt: string;
|
|
67
|
+
runId?: string;
|
|
68
|
+
agent?: string;
|
|
69
|
+
task?: string;
|
|
70
|
+
label?: string;
|
|
71
|
+
phase?: string;
|
|
72
|
+
completedAt?: string;
|
|
73
|
+
sessionPath?: string;
|
|
74
|
+
artifactPaths: string[];
|
|
75
|
+
heartbeat?: MissionChildHeartbeat;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type MissionWorkflowChildUpdate = Pick<MissionWorkflowChild, "workflowRunId" | "key" | "status"> & Partial<Omit<MissionWorkflowChild, "workflowRunId" | "key" | "status" | "startedAt" | "updatedAt" | "artifactPaths" | "heartbeat">> & {
|
|
79
|
+
startedAt?: string;
|
|
80
|
+
artifactPaths?: string[];
|
|
81
|
+
heartbeat?: Omit<MissionChildHeartbeat, "updatedAt"> & { updatedAt?: string };
|
|
82
|
+
};
|
|
83
|
+
|
|
54
84
|
export interface MissionArtifact {
|
|
55
85
|
kind: MissionArtifactKind;
|
|
56
86
|
path: string;
|
|
@@ -80,6 +110,7 @@ export interface MissionRecord {
|
|
|
80
110
|
cwd?: string;
|
|
81
111
|
ownerSessionId?: string;
|
|
82
112
|
runs: MissionRunLink[];
|
|
113
|
+
workflowChildren: MissionWorkflowChild[];
|
|
83
114
|
decisions: MissionDecision[];
|
|
84
115
|
artifacts: MissionArtifact[];
|
|
85
116
|
receipts: MissionReceipt[];
|
|
@@ -151,7 +182,9 @@ export interface MissionUpdateInput {
|
|
|
151
182
|
labels?: string[];
|
|
152
183
|
acceptance?: unknown;
|
|
153
184
|
addRuns?: MissionRunLink[];
|
|
185
|
+
upsertWorkflowChildren?: MissionWorkflowChildUpdate[];
|
|
154
186
|
addArtifacts?: MissionArtifact[];
|
|
155
187
|
addDecisions?: Array<Omit<MissionDecision, "id" | "status" | "createdAt">>;
|
|
188
|
+
resolveDecision?: { id: string; resolution: string };
|
|
156
189
|
addReceipts?: Array<Omit<MissionReceipt, "createdAt">>;
|
|
157
190
|
}
|
|
@@ -1197,6 +1197,10 @@ export function executeAsyncChain(
|
|
|
1197
1197
|
/**
|
|
1198
1198
|
* Execute a single agent asynchronously
|
|
1199
1199
|
*/
|
|
1200
|
+
export function workflowAwaitedAsyncResultPath(asyncDir: string): string {
|
|
1201
|
+
return path.join(asyncDir, "workflow-result.json");
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1200
1204
|
export function executeAsyncSingle(
|
|
1201
1205
|
id: string,
|
|
1202
1206
|
params: AsyncSingleParams,
|
|
@@ -1456,7 +1460,9 @@ export function executeAsyncSingle(
|
|
|
1456
1460
|
...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
|
|
1457
1461
|
},
|
|
1458
1462
|
],
|
|
1459
|
-
resultPath:
|
|
1463
|
+
resultPath: params.parentWorkflowRunId !== undefined && params.revivalLease !== undefined
|
|
1464
|
+
? workflowAwaitedAsyncResultPath(asyncDir)
|
|
1465
|
+
: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(DIRS.results, `${id}.json`),
|
|
1460
1466
|
cwd: runnerCwd,
|
|
1461
1467
|
placeholder: "{previous}",
|
|
1462
1468
|
maxOutput,
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
|
+
import type { WaitCompletion } from "../../shared/types.ts";
|
|
5
|
+
import { utf8Tail } from "../../shared/utf8.ts";
|
|
6
|
+
|
|
7
|
+
const REPLAY_VERSION = 1;
|
|
8
|
+
const ARCHIVE_VERSION = 1;
|
|
9
|
+
const ARCHIVE_TEXT_LIMIT_BYTES = 64 * 1024;
|
|
10
|
+
const REPLAY_DIR_NAME = "completion-replay";
|
|
11
|
+
const ARCHIVE_DIR_NAME = "output-archives";
|
|
12
|
+
|
|
13
|
+
export interface CompletionArchiveEntry {
|
|
14
|
+
agent?: string;
|
|
15
|
+
source: "output-artifact" | "session" | "result-tail";
|
|
16
|
+
path?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
truncated?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompletionArchive {
|
|
22
|
+
version: 1;
|
|
23
|
+
runId: string;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
entries: CompletionArchiveEntry[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CompletionReplayRecord {
|
|
29
|
+
version: 1;
|
|
30
|
+
runId: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
completedAt: number;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
completion: WaitCompletion;
|
|
35
|
+
archivePath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeRunFile(runId: string): string {
|
|
39
|
+
return `${encodeURIComponent(runId)}.json`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function completionReplayPath(resultsDir: string, runId: string): string {
|
|
43
|
+
return path.join(resultsDir, REPLAY_DIR_NAME, safeRunFile(runId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function completionArchivePath(resultsDir: string, runId: string): string {
|
|
47
|
+
return path.join(resultsDir, ARCHIVE_DIR_NAME, safeRunFile(runId));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
51
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function existingFile(value: unknown): string | undefined {
|
|
55
|
+
const filePath = nonEmptyString(value);
|
|
56
|
+
if (!filePath) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(filePath).isFile() ? filePath : undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function outputArtifactPath(child: Record<string, unknown>): string | undefined {
|
|
65
|
+
if (!child.artifactPaths || typeof child.artifactPaths !== "object" || Array.isArray(child.artifactPaths)) return undefined;
|
|
66
|
+
return existingFile((child.artifactPaths as Record<string, unknown>).outputPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Create a small archive that references saved child artifacts and retains only bounded fallback output text. */
|
|
70
|
+
export function writeCompletionArchive(resultsDir: string, runId: string, data: Record<string, unknown>, createdAt: number): string {
|
|
71
|
+
const entries: CompletionArchiveEntry[] = [];
|
|
72
|
+
const fallback: string[] = [];
|
|
73
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
74
|
+
for (const value of results) {
|
|
75
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
76
|
+
const child = value as Record<string, unknown>;
|
|
77
|
+
const agent = nonEmptyString(child.agent);
|
|
78
|
+
const artifactPath = outputArtifactPath(child);
|
|
79
|
+
if (artifactPath) {
|
|
80
|
+
entries.push({ ...(agent ? { agent } : {}), source: "output-artifact", path: artifactPath });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const sessionPath = existingFile(child.sessionFile);
|
|
84
|
+
if (sessionPath) {
|
|
85
|
+
entries.push({ ...(agent ? { agent } : {}), source: "session", path: sessionPath });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const output = nonEmptyString(child.output);
|
|
89
|
+
const error = nonEmptyString(child.error);
|
|
90
|
+
if (output || error) {
|
|
91
|
+
fallback.push([agent ? `[${agent}]` : undefined, error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (results.length === 0) {
|
|
95
|
+
const sessionPath = existingFile(data.sessionFile);
|
|
96
|
+
if (sessionPath) entries.push({ source: "session", path: sessionPath });
|
|
97
|
+
}
|
|
98
|
+
if (entries.length === 0 && fallback.length === 0) {
|
|
99
|
+
const summary = nonEmptyString(data.summary);
|
|
100
|
+
if (summary) fallback.push(summary);
|
|
101
|
+
}
|
|
102
|
+
if (fallback.length > 0) {
|
|
103
|
+
const bounded = utf8Tail(fallback.join("\n\n"), ARCHIVE_TEXT_LIMIT_BYTES);
|
|
104
|
+
entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
|
|
105
|
+
}
|
|
106
|
+
const archive: CompletionArchive = { version: ARCHIVE_VERSION, runId, createdAt, entries };
|
|
107
|
+
const archivePath = completionArchivePath(resultsDir, runId);
|
|
108
|
+
writePrivateAtomicJson(archivePath, archive);
|
|
109
|
+
return archivePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseCompletion(value: unknown, runId: string): WaitCompletion | undefined {
|
|
113
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
114
|
+
const completion = value as Partial<WaitCompletion>;
|
|
115
|
+
if (completion.runId !== runId) return undefined;
|
|
116
|
+
return completion as WaitCompletion;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseReplay(value: unknown): CompletionReplayRecord | undefined {
|
|
120
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
121
|
+
const record = value as Partial<CompletionReplayRecord>;
|
|
122
|
+
if (record.version !== REPLAY_VERSION
|
|
123
|
+
|| typeof record.runId !== "string"
|
|
124
|
+
|| typeof record.sessionId !== "string"
|
|
125
|
+
|| typeof record.completedAt !== "number"
|
|
126
|
+
|| typeof record.expiresAt !== "number"
|
|
127
|
+
|| typeof record.archivePath !== "string") return undefined;
|
|
128
|
+
const completion = parseCompletion(record.completion, record.runId);
|
|
129
|
+
return completion ? { ...record, completion } as CompletionReplayRecord : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateReplayRecord(resultsDir: string, runId: string, record: CompletionReplayRecord): CompletionReplayRecord | undefined {
|
|
133
|
+
if (record.runId !== runId) return undefined;
|
|
134
|
+
const archivePath = completionArchivePath(resultsDir, runId);
|
|
135
|
+
return path.resolve(record.archivePath) === path.resolve(archivePath)
|
|
136
|
+
? { ...record, archivePath, completion: { ...record.completion, archivePath } }
|
|
137
|
+
: undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runIdFromReplayFile(file: string): string | undefined {
|
|
141
|
+
if (!file.endsWith(".json")) return undefined;
|
|
142
|
+
try {
|
|
143
|
+
const runId = decodeURIComponent(file.slice(0, -".json".length));
|
|
144
|
+
return safeRunFile(runId) === file ? runId : undefined;
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function removeBestEffort(filePath: string): void {
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(filePath, { force: true });
|
|
153
|
+
} catch { /* cleanup only */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseArchive(value: unknown): CompletionArchive | undefined {
|
|
157
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
158
|
+
const archive = value as Partial<CompletionArchive>;
|
|
159
|
+
if (archive.version !== ARCHIVE_VERSION || typeof archive.runId !== "string" || typeof archive.createdAt !== "number" || !Array.isArray(archive.entries)) return undefined;
|
|
160
|
+
const entries = archive.entries.flatMap((value): CompletionArchiveEntry[] => {
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
162
|
+
const entry = value as Partial<CompletionArchiveEntry>;
|
|
163
|
+
if (entry.source !== "output-artifact" && entry.source !== "session" && entry.source !== "result-tail") return [];
|
|
164
|
+
return [{
|
|
165
|
+
...(typeof entry.agent === "string" ? { agent: entry.agent } : {}),
|
|
166
|
+
source: entry.source,
|
|
167
|
+
...(typeof entry.path === "string" ? { path: entry.path } : {}),
|
|
168
|
+
...(typeof entry.text === "string" ? { text: entry.text } : {}),
|
|
169
|
+
...(entry.truncated === true ? { truncated: true } : {}),
|
|
170
|
+
}];
|
|
171
|
+
});
|
|
172
|
+
return { version: ARCHIVE_VERSION, runId: archive.runId, createdAt: archive.createdAt, entries };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Persist a terminal completion before its one-shot result file is removed. */
|
|
176
|
+
export function writeCompletionReplay(input: {
|
|
177
|
+
resultsDir: string;
|
|
178
|
+
runId: string;
|
|
179
|
+
sessionId: string;
|
|
180
|
+
completion: WaitCompletion;
|
|
181
|
+
data: Record<string, unknown>;
|
|
182
|
+
now: number;
|
|
183
|
+
ttlMs: number;
|
|
184
|
+
}): CompletionReplayRecord {
|
|
185
|
+
const archivePath = writeCompletionArchive(input.resultsDir, input.runId, input.data, input.now);
|
|
186
|
+
const completion = { ...input.completion, archivePath };
|
|
187
|
+
const record: CompletionReplayRecord = {
|
|
188
|
+
version: REPLAY_VERSION,
|
|
189
|
+
runId: input.runId,
|
|
190
|
+
sessionId: input.sessionId,
|
|
191
|
+
completedAt: input.now,
|
|
192
|
+
expiresAt: input.now + input.ttlMs,
|
|
193
|
+
completion,
|
|
194
|
+
archivePath,
|
|
195
|
+
};
|
|
196
|
+
writePrivateAtomicJson(completionReplayPath(input.resultsDir, input.runId), record);
|
|
197
|
+
cleanupCompletionReplay(input.resultsDir, input.now, input.ttlMs);
|
|
198
|
+
return record;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Read a current replay record. Unknown fields are ignored and unknown versions are skipped. */
|
|
202
|
+
export function readCompletionReplay(resultsDir: string, runId: string, options: { sessionId?: string; now?: number } = {}): CompletionReplayRecord | undefined {
|
|
203
|
+
const replayPath = completionReplayPath(resultsDir, runId);
|
|
204
|
+
let parsed: CompletionReplayRecord | undefined;
|
|
205
|
+
try {
|
|
206
|
+
parsed = parseReplay(JSON.parse(fs.readFileSync(replayPath, "utf-8")));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
if (!parsed) return undefined;
|
|
212
|
+
const safeRecord = validateReplayRecord(resultsDir, runId, parsed);
|
|
213
|
+
if (!safeRecord) {
|
|
214
|
+
removeBestEffort(replayPath);
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
parsed = safeRecord;
|
|
218
|
+
if (options.sessionId !== undefined && parsed.sessionId !== options.sessionId) return undefined;
|
|
219
|
+
if (parsed.expiresAt <= (options.now ?? Date.now())) {
|
|
220
|
+
removeBestEffort(replayPath);
|
|
221
|
+
removeBestEffort(parsed.archivePath);
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function readCompletionArchive(archivePath: string): CompletionArchive | undefined {
|
|
228
|
+
try {
|
|
229
|
+
return parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Opportunistically remove expired replay and orphan archive files without affecting delivery. */
|
|
237
|
+
export function cleanupCompletionReplay(resultsDir: string, now: number, maxAgeMs: number): void {
|
|
238
|
+
const replayDir = path.join(resultsDir, REPLAY_DIR_NAME);
|
|
239
|
+
try {
|
|
240
|
+
for (const file of fs.readdirSync(replayDir)) {
|
|
241
|
+
const runId = runIdFromReplayFile(file);
|
|
242
|
+
if (!runId) continue;
|
|
243
|
+
const filePath = path.join(replayDir, file);
|
|
244
|
+
try {
|
|
245
|
+
const record = parseReplay(JSON.parse(fs.readFileSync(filePath, "utf-8")));
|
|
246
|
+
const safeRecord = record ? validateReplayRecord(resultsDir, runId, record) : undefined;
|
|
247
|
+
if (record && !safeRecord) {
|
|
248
|
+
fs.rmSync(filePath, { force: true });
|
|
249
|
+
} else if (safeRecord && safeRecord.expiresAt <= now) {
|
|
250
|
+
fs.rmSync(filePath, { force: true });
|
|
251
|
+
fs.rmSync(safeRecord.archivePath, { force: true });
|
|
252
|
+
} else if (!record && now - fs.statSync(filePath).mtimeMs > maxAgeMs) {
|
|
253
|
+
fs.rmSync(filePath, { force: true });
|
|
254
|
+
}
|
|
255
|
+
} catch { /* one bad entry must not block cleanup */ }
|
|
256
|
+
}
|
|
257
|
+
} catch { /* replay directory may not exist yet */ }
|
|
258
|
+
const archiveDir = path.join(resultsDir, ARCHIVE_DIR_NAME);
|
|
259
|
+
try {
|
|
260
|
+
for (const file of fs.readdirSync(archiveDir)) {
|
|
261
|
+
const filePath = path.join(archiveDir, file);
|
|
262
|
+
try {
|
|
263
|
+
if (now - fs.statSync(filePath).mtimeMs > maxAgeMs) fs.rmSync(filePath, { force: true });
|
|
264
|
+
} catch { /* one bad entry must not block cleanup */ }
|
|
265
|
+
}
|
|
266
|
+
} catch { /* archive directory may not exist yet */ }
|
|
267
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
23
|
import { recordWaitCompletion } from "./wait-completions.ts";
|
|
24
|
+
import { syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
|
|
24
25
|
import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
|
|
25
26
|
|
|
26
27
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
@@ -149,6 +150,11 @@ export function createResultWatcher(
|
|
|
149
150
|
const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
|
|
150
151
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
151
152
|
const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
|
|
153
|
+
try {
|
|
154
|
+
syncMissionFromAsyncCompletion({ ...data, runId });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`Mission completion sync failed for '${resultPath}':`, error);
|
|
157
|
+
}
|
|
152
158
|
try {
|
|
153
159
|
deps.observeCompletion?.({ ...data, runId });
|
|
154
160
|
} catch (error) {
|
|
@@ -156,10 +162,12 @@ export function createResultWatcher(
|
|
|
156
162
|
}
|
|
157
163
|
const epoch = deliveryEpoch;
|
|
158
164
|
if (!ownsSession(data.sessionId, epoch)) return;
|
|
159
|
-
// Recorded before dedupe and before the unlink below
|
|
160
|
-
// the
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
// Recorded before dedupe and before the unlink below so subagent_wait can
|
|
166
|
+
// use the in-memory record or its bounded durable replay after cleanup.
|
|
167
|
+
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
|
|
168
|
+
resultsDir,
|
|
169
|
+
sessionId: data.sessionId,
|
|
170
|
+
});
|
|
163
171
|
const hasExplicitNestedChildren = data.nestedChildren !== undefined;
|
|
164
172
|
let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
|
|
165
173
|
if (!nestedChildren?.length && !hasExplicitNestedChildren) {
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
|
|
4
4
|
import type { AsyncRunSummary } from "./async-status.ts";
|
|
5
|
+
import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
|
|
5
6
|
|
|
6
7
|
function asNonEmptyString(value: unknown): string | undefined {
|
|
7
8
|
return typeof value === "string" && value ? value : undefined;
|
|
@@ -68,12 +69,34 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
|
|
|
68
69
|
* file is deleted after delivery, so this record is the only in-process source once
|
|
69
70
|
* the watcher has consumed it.
|
|
70
71
|
*/
|
|
71
|
-
export function recordWaitCompletion(
|
|
72
|
+
export function recordWaitCompletion(
|
|
73
|
+
state: SubagentState,
|
|
74
|
+
runId: string,
|
|
75
|
+
data: Record<string, unknown>,
|
|
76
|
+
now: number,
|
|
77
|
+
ttlMs: number,
|
|
78
|
+
persistence?: { resultsDir: string; sessionId: string },
|
|
79
|
+
): void {
|
|
72
80
|
const store = state.completedResults ??= new Map();
|
|
73
81
|
for (const [key, entry] of store) {
|
|
74
82
|
if (now - entry.seenAt > ttlMs) store.delete(key);
|
|
75
83
|
}
|
|
76
|
-
|
|
84
|
+
let completion = toWaitCompletion(data, runId);
|
|
85
|
+
if (persistence) {
|
|
86
|
+
try {
|
|
87
|
+
completion = writeCompletionReplay({
|
|
88
|
+
...persistence,
|
|
89
|
+
runId,
|
|
90
|
+
completion,
|
|
91
|
+
data,
|
|
92
|
+
now,
|
|
93
|
+
ttlMs,
|
|
94
|
+
}).completion;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`Failed to persist completion replay for '${runId}':`, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
store.set(runId, { seenAt: now, completion });
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
/**
|
|
@@ -102,10 +125,21 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
|
|
|
102
125
|
});
|
|
103
126
|
}
|
|
104
127
|
// The watcher may have consumed the file between the store check and the
|
|
105
|
-
// read
|
|
106
|
-
//
|
|
128
|
+
// read. Prefer its in-memory record, then the durable replay written before
|
|
129
|
+
// result cleanup so watcher reloads do not lose completion details.
|
|
107
130
|
const late = state.completedResults?.get(run.id);
|
|
108
|
-
if (late)
|
|
131
|
+
if (late) {
|
|
132
|
+
completions.push(late.completion);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
|
|
137
|
+
if (replay) completions.push(replay.completion);
|
|
138
|
+
} catch (replayError) {
|
|
139
|
+
throw new Error(`Failed to read completion replay for '${run.id}': ${errorMessage(replayError)}`, {
|
|
140
|
+
cause: replayError instanceof Error ? replayError : undefined,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
109
143
|
}
|
|
110
144
|
}
|
|
111
145
|
return completions.length > 0 ? completions : undefined;
|