pi-subagents 0.45.2 → 0.47.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 +47 -0
- package/README.md +2 -0
- package/docs/agents.md +342 -0
- package/docs/configuration.md +328 -0
- package/docs/extension-api.md +308 -0
- package/docs/missions.md +119 -0
- package/docs/models.md +192 -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 +6 -6
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
- 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 +18 -1
- package/src/extension/fanout-child.ts +5 -4
- package/src/extension/index.ts +66 -19
- package/src/extension/rpc.ts +3 -6
- package/src/extension/schemas.ts +28 -7
- package/src/extension/subagent-guide.ts +39 -0
- package/src/extension/tool-description.ts +30 -12
- package/src/inspectors/herdr/project-panes.ts +459 -63
- package/src/missions/actions.ts +25 -2
- package/src/missions/lifecycle.ts +21 -2
- package/src/missions/store.ts +79 -2
- package/src/missions/types.ts +33 -0
- package/src/missions/workflow-state.ts +19 -13
- package/src/runs/background/async-execution.ts +17 -6
- package/src/runs/background/async-job-tracker.ts +15 -0
- package/src/runs/background/async-resume.ts +19 -3
- package/src/runs/background/async-status.ts +6 -1
- package/src/runs/background/completion-replay.ts +267 -0
- package/src/runs/background/control-channel.ts +36 -0
- package/src/runs/background/result-watcher.ts +28 -6
- package/src/runs/background/scheduled-runs.ts +2 -1
- package/src/runs/background/stale-run-reconciler.ts +2 -21
- package/src/runs/background/subagent-runner.ts +47 -6
- package/src/runs/background/wait-completions.ts +39 -5
- package/src/runs/background/wait-subscriptions.ts +18 -3
- package/src/runs/foreground/async-steering-action.ts +1 -1
- package/src/runs/foreground/chain-execution.ts +3 -0
- package/src/runs/foreground/execution.ts +7 -0
- package/src/runs/foreground/foreground-history.ts +137 -0
- package/src/runs/foreground/subagent-executor.ts +403 -54
- package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +8 -4
- package/src/runs/shared/model-scope.ts +12 -2
- package/src/runs/shared/parallel-utils.ts +1 -0
- package/src/runs/shared/worktree.ts +3 -2
- package/src/shared/artifacts.ts +14 -14
- package/src/shared/display-text.ts +100 -0
- package/src/shared/fork-context.ts +13 -0
- package/src/shared/formatters.ts +4 -6
- package/src/shared/prompt-resources.ts +51 -0
- package/src/shared/settings.ts +15 -2
- package/src/shared/types.ts +41 -2
- package/src/shared/utf8.ts +11 -0
- package/src/shared/utils.ts +43 -33
- package/src/slash/prompt-workflows.ts +2 -15
- package/src/slash/slash-commands.ts +22 -2
- package/src/tui/fleet-status.ts +22 -12
- package/src/tui/fleet.ts +135 -25
- package/src/tui/render.ts +150 -33
- package/src/watchdog/change-signature.ts +4 -3
- package/src/workflows/scripted-workflow.ts +167 -10
|
@@ -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,16 @@ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
|
|
|
21
21
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
22
22
|
|
|
23
23
|
export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
|
|
24
|
+
|
|
25
|
+
function writeJsonToExistingDir(filePath: string, payload: object): void {
|
|
26
|
+
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { encoding: "utf-8", flag: "wx" });
|
|
29
|
+
fs.renameSync(tempPath, filePath);
|
|
30
|
+
} finally {
|
|
31
|
+
fs.rmSync(tempPath, { force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
25
35
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
26
36
|
|
|
@@ -205,6 +215,13 @@ export function writeSteerRequestToDir(dir: string, request: SteerRequest): stri
|
|
|
205
215
|
return requestPath;
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
export function writeSteerRequestToExistingDir(dir: string, request: SteerRequest): string {
|
|
219
|
+
if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
|
|
220
|
+
const requestPath = path.join(dir, steerRequestFileName(request));
|
|
221
|
+
writeJsonToExistingDir(requestPath, request);
|
|
222
|
+
return requestPath;
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
|
|
209
226
|
assertChildIndex(capability.index);
|
|
210
227
|
if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
|
|
@@ -385,6 +402,25 @@ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof f
|
|
|
385
402
|
return capabilities;
|
|
386
403
|
}
|
|
387
404
|
|
|
405
|
+
export function consumeSteerAckFromDir(
|
|
406
|
+
dir: string,
|
|
407
|
+
requestId: string,
|
|
408
|
+
fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
|
|
409
|
+
): SteerAck | undefined {
|
|
410
|
+
if (!fsImpl.existsSync(dir)) return undefined;
|
|
411
|
+
let entries: string[];
|
|
412
|
+
try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
const target = path.join(dir, entry);
|
|
415
|
+
let ack: SteerAck | undefined;
|
|
416
|
+
try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
|
|
417
|
+
if (ack?.requestId !== requestId) continue;
|
|
418
|
+
try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
|
|
419
|
+
return ack;
|
|
420
|
+
}
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
388
424
|
export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
|
|
389
425
|
const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
|
|
390
426
|
if (!fsImpl.existsSync(root)) return [];
|
|
@@ -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;
|
|
@@ -125,6 +126,7 @@ export function createResultWatcher(
|
|
|
125
126
|
const processing = new Set<string>();
|
|
126
127
|
let deliveryActive = true;
|
|
127
128
|
let deliveryEpoch = 0;
|
|
129
|
+
let resultScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
128
130
|
// The sole in-memory ownership lease. It is acquired for one active session
|
|
129
131
|
// and revoked before the watcher, queues, or callbacks are torn down.
|
|
130
132
|
let activeSessionId: string | null = null;
|
|
@@ -149,6 +151,11 @@ export function createResultWatcher(
|
|
|
149
151
|
const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
|
|
150
152
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
151
153
|
const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
|
|
154
|
+
try {
|
|
155
|
+
syncMissionFromAsyncCompletion({ ...data, runId });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`Mission completion sync failed for '${resultPath}':`, error);
|
|
158
|
+
}
|
|
152
159
|
try {
|
|
153
160
|
deps.observeCompletion?.({ ...data, runId });
|
|
154
161
|
} catch (error) {
|
|
@@ -156,10 +163,12 @@ export function createResultWatcher(
|
|
|
156
163
|
}
|
|
157
164
|
const epoch = deliveryEpoch;
|
|
158
165
|
if (!ownsSession(data.sessionId, epoch)) return;
|
|
159
|
-
// Recorded before dedupe and before the unlink below
|
|
160
|
-
// the
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
// Recorded before dedupe and before the unlink below so subagent_wait can
|
|
167
|
+
// use the in-memory record or its bounded durable replay after cleanup.
|
|
168
|
+
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
|
|
169
|
+
resultsDir,
|
|
170
|
+
sessionId: data.sessionId,
|
|
171
|
+
});
|
|
163
172
|
const hasExplicitNestedChildren = data.nestedChildren !== undefined;
|
|
164
173
|
let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
|
|
165
174
|
if (!nestedChildren?.length && !hasExplicitNestedChildren) {
|
|
@@ -335,9 +344,15 @@ export function createResultWatcher(
|
|
|
335
344
|
}
|
|
336
345
|
};
|
|
337
346
|
|
|
347
|
+
const clearResultScan = () => {
|
|
348
|
+
if (resultScanTimer) timers.clearInterval(resultScanTimer);
|
|
349
|
+
resultScanTimer = null;
|
|
350
|
+
};
|
|
351
|
+
|
|
338
352
|
const startPolling = (reason: unknown) => {
|
|
339
353
|
state.watcher?.close();
|
|
340
354
|
state.watcher = null;
|
|
355
|
+
clearResultScan();
|
|
341
356
|
if (state.watcherRestartTimer) return;
|
|
342
357
|
console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
|
|
343
358
|
primeExistingResults();
|
|
@@ -346,6 +361,7 @@ export function createResultWatcher(
|
|
|
346
361
|
};
|
|
347
362
|
|
|
348
363
|
const scheduleRestart = () => {
|
|
364
|
+
clearResultScan();
|
|
349
365
|
if (state.watcherRestartTimer) return;
|
|
350
366
|
state.watcherRestartTimer = timers.setTimeout(() => {
|
|
351
367
|
state.watcherRestartTimer = null;
|
|
@@ -373,8 +389,11 @@ export function createResultWatcher(
|
|
|
373
389
|
}
|
|
374
390
|
try {
|
|
375
391
|
const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
|
|
376
|
-
state.watcher = fsApi.watch(watchDir, (
|
|
377
|
-
if (
|
|
392
|
+
state.watcher = fsApi.watch(watchDir, (_event, file) => {
|
|
393
|
+
if (!file) {
|
|
394
|
+
primeExistingResults();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
378
397
|
const fileName = file.toString();
|
|
379
398
|
if (fileName.endsWith(".json")) scheduleResult(fileName, true);
|
|
380
399
|
});
|
|
@@ -386,6 +405,8 @@ export function createResultWatcher(
|
|
|
386
405
|
scheduleRestart();
|
|
387
406
|
});
|
|
388
407
|
state.watcher.unref?.();
|
|
408
|
+
resultScanTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
|
|
409
|
+
resultScanTimer.unref?.();
|
|
389
410
|
} catch (error) {
|
|
390
411
|
if (shouldPoll(error)) return startPolling(error);
|
|
391
412
|
console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
|
|
@@ -405,6 +426,7 @@ export function createResultWatcher(
|
|
|
405
426
|
timers.clearInterval(state.watcherRestartTimer);
|
|
406
427
|
}
|
|
407
428
|
state.watcherRestartTimer = null;
|
|
429
|
+
clearResultScan();
|
|
408
430
|
state.resultFileCoalescer.clear();
|
|
409
431
|
pendingTriggerTurn.clear();
|
|
410
432
|
processing.clear();
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
|
|
6
7
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
7
8
|
import { shortenPath } from "../../shared/formatters.ts";
|
|
8
9
|
import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
|
|
@@ -87,7 +88,7 @@ export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
|
|
90
|
-
if (!root) return path.join(path.resolve(cwd), "
|
|
91
|
+
if (!root) return path.join(getProjectSubagentsDir(path.resolve(cwd)), "schedules");
|
|
91
92
|
const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
|
|
92
93
|
return path.join(root, projectKey);
|
|
93
94
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
4
5
|
import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
|
|
5
6
|
import { resolveEffectiveThinking } from "../../shared/model-info.ts";
|
|
6
7
|
import { normalizeParallelGroups } from "./parallel-groups.ts";
|
|
@@ -84,26 +85,6 @@ function appendJsonlBestEffort(filePath: string, payload: object): void {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function readStatusFile(asyncDir: string): AsyncStatus | null {
|
|
88
|
-
const statusPath = path.join(asyncDir, "status.json");
|
|
89
|
-
let content: string;
|
|
90
|
-
try {
|
|
91
|
-
content = fs.readFileSync(statusPath, "utf-8");
|
|
92
|
-
} catch (error) {
|
|
93
|
-
if (isNotFoundError(error)) return null;
|
|
94
|
-
throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, {
|
|
95
|
-
cause: error instanceof Error ? error : undefined,
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
try {
|
|
99
|
-
return JSON.parse(content) as AsyncStatus;
|
|
100
|
-
} catch (error) {
|
|
101
|
-
throw new Error(`Failed to parse async status file '${statusPath}': ${getErrorMessage(error)}`, {
|
|
102
|
-
cause: error instanceof Error ? error : undefined,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
88
|
interface ResultChildOutcome {
|
|
108
89
|
agent?: string;
|
|
109
90
|
success?: boolean;
|
|
@@ -348,7 +329,7 @@ export function checkPidLiveness(pid: number, kill: KillFn = process.kill): PidL
|
|
|
348
329
|
|
|
349
330
|
export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOptions = {}): ReconcileAsyncRunResult {
|
|
350
331
|
const now = options.now?.() ?? Date.now();
|
|
351
|
-
const status =
|
|
332
|
+
const status = readStatus(asyncDir);
|
|
352
333
|
const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
|
|
353
334
|
const effectiveStatus = status ?? startedStatus;
|
|
354
335
|
if (!effectiveStatus) return { status: null, repaired: false };
|
|
@@ -1955,6 +1955,47 @@ function combinedAbortSignal(signals: Array<AbortSignal | undefined>): AbortSign
|
|
|
1955
1955
|
return controller.signal;
|
|
1956
1956
|
}
|
|
1957
1957
|
|
|
1958
|
+
async function runSingleStepWithTimeout(
|
|
1959
|
+
step: SubagentStep,
|
|
1960
|
+
ctx: SingleStepContext,
|
|
1961
|
+
parentDeadlineAt?: number,
|
|
1962
|
+
): Promise<SingleStepResult> {
|
|
1963
|
+
if (step.timeoutMs === undefined) return runSingleStep(step, ctx);
|
|
1964
|
+
|
|
1965
|
+
const parentRemainingMs = parentDeadlineAt === undefined ? undefined : Math.max(0, parentDeadlineAt - Date.now());
|
|
1966
|
+
const timeoutMs = parentRemainingMs === undefined ? step.timeoutMs : Math.min(step.timeoutMs, parentRemainingMs);
|
|
1967
|
+
const timeoutMessage = parentRemainingMs !== undefined && parentRemainingMs <= step.timeoutMs
|
|
1968
|
+
? ctx.timeoutMessage
|
|
1969
|
+
: `Subagent timed out after ${step.timeoutMs}ms.`;
|
|
1970
|
+
const timeoutController = new AbortController();
|
|
1971
|
+
let timeoutAction: (() => void) | undefined;
|
|
1972
|
+
let timeoutTriggered = false;
|
|
1973
|
+
const triggerTimeout = (): void => {
|
|
1974
|
+
if (timeoutTriggered) return;
|
|
1975
|
+
timeoutTriggered = true;
|
|
1976
|
+
timeoutController.abort();
|
|
1977
|
+
timeoutAction?.();
|
|
1978
|
+
};
|
|
1979
|
+
const registerTimeout = (action: (() => void) | undefined): void => {
|
|
1980
|
+
timeoutAction = action;
|
|
1981
|
+
ctx.registerTimeout?.(action ? triggerTimeout : undefined);
|
|
1982
|
+
if (action && timeoutTriggered) action();
|
|
1983
|
+
};
|
|
1984
|
+
const timer = setTimeout(triggerTimeout, timeoutMs);
|
|
1985
|
+
timer.unref?.();
|
|
1986
|
+
try {
|
|
1987
|
+
return await runSingleStep(step, {
|
|
1988
|
+
...ctx,
|
|
1989
|
+
registerTimeout,
|
|
1990
|
+
timeoutSignal: combinedAbortSignal([ctx.timeoutSignal, timeoutController.signal]),
|
|
1991
|
+
timeoutMessage,
|
|
1992
|
+
});
|
|
1993
|
+
} finally {
|
|
1994
|
+
clearTimeout(timer);
|
|
1995
|
+
ctx.registerTimeout?.(undefined);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1958
1999
|
async function runSubagent(
|
|
1959
2000
|
config: SubagentRunConfig,
|
|
1960
2001
|
onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void,
|
|
@@ -3449,7 +3490,7 @@ async function runSubagent(
|
|
|
3449
3490
|
writeStatusPayload();
|
|
3450
3491
|
appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
|
|
3451
3492
|
flushPendingStepSteers(fi);
|
|
3452
|
-
const singleResult = await
|
|
3493
|
+
const singleResult = await runSingleStepWithTimeout(task, compactOptional<SingleStepContext>({
|
|
3453
3494
|
previousOutput, placeholder, cwd, sessionEnabled,
|
|
3454
3495
|
outputs,
|
|
3455
3496
|
sessionDir: config.sessionDir ? path.join(config.sessionDir, `dynamic-${stepIndex}-${taskIdx}`) : undefined,
|
|
@@ -3479,7 +3520,7 @@ async function runSubagent(
|
|
|
3479
3520
|
onWriterProcess,
|
|
3480
3521
|
onExternalProcess: (process) => updateExternalProcess(fi, process),
|
|
3481
3522
|
skipAcceptance: () => timedOut || stopped,
|
|
3482
|
-
}));
|
|
3523
|
+
}), config.deadlineAt);
|
|
3483
3524
|
const taskEndTime = Date.now();
|
|
3484
3525
|
const childInterrupted = singleResult.interrupted === true;
|
|
3485
3526
|
const childStopped = singleResult.stopped === true;
|
|
@@ -3831,7 +3872,7 @@ async function runSubagent(
|
|
|
3831
3872
|
const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx);
|
|
3832
3873
|
flushPendingStepSteers(fi);
|
|
3833
3874
|
|
|
3834
|
-
const singleResult = await
|
|
3875
|
+
const singleResult = await runSingleStepWithTimeout(taskForRun, compactOptional<SingleStepContext>({
|
|
3835
3876
|
previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
|
|
3836
3877
|
outputs,
|
|
3837
3878
|
sessionDir: taskSessionDir,
|
|
@@ -3861,7 +3902,7 @@ async function runSubagent(
|
|
|
3861
3902
|
onWriterProcess,
|
|
3862
3903
|
onExternalProcess: (process) => updateExternalProcess(fi, process),
|
|
3863
3904
|
skipAcceptance: () => timedOut || stopped,
|
|
3864
|
-
}));
|
|
3905
|
+
}), config.deadlineAt);
|
|
3865
3906
|
if (task.sessionFile) {
|
|
3866
3907
|
latestSessionFile = task.sessionFile;
|
|
3867
3908
|
}
|
|
@@ -4120,7 +4161,7 @@ async function runSubagent(
|
|
|
4120
4161
|
}));
|
|
4121
4162
|
|
|
4122
4163
|
flushPendingStepSteers(flatIndex);
|
|
4123
|
-
const singleResult = await
|
|
4164
|
+
const singleResult = await runSingleStepWithTimeout(seqStep, compactOptional<SingleStepContext>({
|
|
4124
4165
|
previousOutput, placeholder, cwd, sessionEnabled,
|
|
4125
4166
|
outputs: statusPayload.mode === "single" ? undefined : outputs,
|
|
4126
4167
|
sessionDir: config.sessionDir,
|
|
@@ -4150,7 +4191,7 @@ async function runSubagent(
|
|
|
4150
4191
|
onWriterProcess,
|
|
4151
4192
|
onExternalProcess: (process) => updateExternalProcess(flatIndex, process),
|
|
4152
4193
|
skipAcceptance: () => timedOut || stopped,
|
|
4153
|
-
}));
|
|
4194
|
+
}), config.deadlineAt);
|
|
4154
4195
|
if (seqStep.sessionFile) {
|
|
4155
4196
|
latestSessionFile = seqStep.sessionFile;
|
|
4156
4197
|
}
|
|
@@ -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;
|