pi-subagents 0.47.0 → 0.47.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 +14 -0
- package/agents/reviewer.md +3 -4
- package/package.json +1 -1
- package/src/extension/config.ts +10 -0
- package/src/extension/index.ts +9 -11
- package/src/extension/rpc.ts +4 -0
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/native-supervisor-channel.ts +102 -4
- package/src/runs/background/active-run-index.ts +42 -0
- package/src/runs/background/async-job-tracker.ts +279 -134
- package/src/runs/background/async-status.ts +27 -4
- package/src/runs/background/control-channel.ts +55 -17
- package/src/runs/background/result-watcher.ts +87 -6
- package/src/runs/background/run-status.ts +18 -0
- package/src/runs/background/scheduled-runs.ts +23 -0
- package/src/runs/background/stale-run-reconciler.ts +10 -3
- package/src/runs/background/subagent-runner.ts +21 -4
- package/src/runs/foreground/async-dismiss-action.ts +85 -0
- package/src/runs/foreground/async-steering-action.ts +6 -7
- package/src/runs/foreground/subagent-executor.ts +34 -8
- package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
- package/src/shared/artifacts.ts +1 -1
- package/src/shared/file-coalescer.ts +9 -0
- package/src/shared/types.ts +5 -1
- package/src/workflows/scripted-workflow.ts +4 -1
|
@@ -32,6 +32,7 @@ function writeJsonToExistingDir(filePath: string, payload: object): void {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
35
|
+
const CONTROL_SAFETY_POLL_INTERVAL_MS = 5000;
|
|
35
36
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
36
37
|
|
|
37
38
|
export interface InterruptRequest {
|
|
@@ -621,9 +622,9 @@ export function deliverCheckpointDecisionRequest(input: {
|
|
|
621
622
|
|
|
622
623
|
/**
|
|
623
624
|
* Runner side: watch the control inbox and route interrupt requests into
|
|
624
|
-
* `onInterrupt`. Uses `fs.watch` when available
|
|
625
|
-
*
|
|
626
|
-
*
|
|
625
|
+
* `onInterrupt`. Uses `fs.watch` when available and starts interval polling
|
|
626
|
+
* only when native watching is unavailable or fails. Fires once per distinct
|
|
627
|
+
* request. Returns a disposer.
|
|
627
628
|
*/
|
|
628
629
|
export function watchAsyncControlInbox(
|
|
629
630
|
asyncDir: string,
|
|
@@ -636,6 +637,7 @@ export function watchAsyncControlInbox(
|
|
|
636
637
|
onSteerCapability?: (capability: SteerCapability) => void;
|
|
637
638
|
onSteerAck?: (ack: SteerAck) => void;
|
|
638
639
|
pollIntervalMs?: number;
|
|
640
|
+
safetyPollIntervalMs?: number;
|
|
639
641
|
fs?: ControlChannelFs;
|
|
640
642
|
timers?: ControlChannelTimers;
|
|
641
643
|
},
|
|
@@ -669,27 +671,63 @@ export function watchAsyncControlInbox(
|
|
|
669
671
|
// Handle a request that may have arrived before the watcher started.
|
|
670
672
|
check();
|
|
671
673
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
674
|
+
const watchers: fs.FSWatcher[] = [];
|
|
675
|
+
const watchedDirs = new Set<string>();
|
|
676
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
677
|
+
let safetyInterval: ReturnType<typeof setInterval> | undefined;
|
|
678
|
+
const startPolling = (): void => {
|
|
679
|
+
if (interval || disposed) return;
|
|
680
|
+
interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
681
|
+
interval.unref?.();
|
|
682
|
+
};
|
|
683
|
+
const startSafetyPolling = (): void => {
|
|
684
|
+
if (safetyInterval || disposed) return;
|
|
685
|
+
safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
|
|
686
|
+
safetyInterval.unref?.();
|
|
687
|
+
};
|
|
688
|
+
const watchDir = (target: string, create = false): void => {
|
|
689
|
+
if (disposed || watchedDirs.has(target)) return;
|
|
690
|
+
if (create) fsImpl.mkdirSync(target, { recursive: true });
|
|
691
|
+
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => {
|
|
692
|
+
watchExistingSteerAckDirs();
|
|
693
|
+
check();
|
|
677
694
|
});
|
|
695
|
+
watcher.on?.("error", startPolling);
|
|
696
|
+
watchers.push(watcher);
|
|
697
|
+
watchedDirs.add(target);
|
|
698
|
+
};
|
|
699
|
+
const watchExistingSteerAckDirs = (): void => {
|
|
700
|
+
const ackRoot = path.join(dir, STEER_ACKS_DIR);
|
|
701
|
+
let entries: string[];
|
|
702
|
+
try {
|
|
703
|
+
entries = fsImpl.readdirSync(ackRoot).filter((name) => /^\d+$/.test(name));
|
|
704
|
+
} catch {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
for (const entry of entries) watchDir(path.join(ackRoot, entry));
|
|
708
|
+
};
|
|
709
|
+
try {
|
|
710
|
+
watchDir(dir);
|
|
711
|
+
watchDir(steerRequestsDir(asyncDir), true);
|
|
712
|
+
watchDir(steerCapabilitiesDir(asyncDir), true);
|
|
713
|
+
watchDir(path.join(dir, STEER_ACKS_DIR), true);
|
|
714
|
+
watchExistingSteerAckDirs();
|
|
715
|
+
startSafetyPolling();
|
|
678
716
|
} catch {
|
|
679
|
-
|
|
717
|
+
startPolling();
|
|
680
718
|
}
|
|
681
719
|
|
|
682
|
-
const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
683
|
-
interval.unref?.();
|
|
684
|
-
|
|
685
720
|
return () => {
|
|
686
721
|
if (disposed) return;
|
|
687
722
|
disposed = true;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
723
|
+
for (const watcher of watchers) {
|
|
724
|
+
try {
|
|
725
|
+
watcher.close();
|
|
726
|
+
} catch {
|
|
727
|
+
// ignore
|
|
728
|
+
}
|
|
692
729
|
}
|
|
693
|
-
timers.clearInterval(interval);
|
|
730
|
+
if (interval) timers.clearInterval(interval);
|
|
731
|
+
if (safetyInterval) timers.clearInterval(safetyInterval);
|
|
694
732
|
};
|
|
695
733
|
}
|
|
@@ -21,14 +21,15 @@ import {
|
|
|
21
21
|
import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
23
|
import { recordWaitCompletion } from "./wait-completions.ts";
|
|
24
|
-
import { syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
|
|
24
|
+
import { MISSION_BINDING_FILE, syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
|
|
25
25
|
import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
|
|
26
26
|
|
|
27
27
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
28
28
|
const POLL_INTERVAL_MS = 3000;
|
|
29
|
+
const HEALTHY_SCAN_INTERVAL_MS = 60_000;
|
|
29
30
|
const RETRY_DELAY_MS = 100;
|
|
30
31
|
|
|
31
|
-
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
|
|
32
|
+
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "statSync" | "watch">;
|
|
32
33
|
|
|
33
34
|
type ResultWatcherTimers = {
|
|
34
35
|
setTimeout: typeof setTimeout;
|
|
@@ -43,6 +44,10 @@ type ResultWatcherDeps = {
|
|
|
43
44
|
notifier?: Pick<CompletionNotifier, "deliver">;
|
|
44
45
|
/** Receives persisted completions before active-session delivery filtering. */
|
|
45
46
|
observeCompletion?: (result: CompletionNotification & { runId: string }) => void;
|
|
47
|
+
/** Returns cross-session run ids that the completion observer currently owns. */
|
|
48
|
+
observedCompletionRunIds?: () => Iterable<string>;
|
|
49
|
+
/** Parses a relevant result payload after its lightweight identity check. */
|
|
50
|
+
parseResult?: (raw: string) => ResultFileData;
|
|
46
51
|
/** External grouped-result transport. Disable when native completion notifications own delivery. */
|
|
47
52
|
deliverIntercomResults?: boolean;
|
|
48
53
|
};
|
|
@@ -76,6 +81,33 @@ type ResultFileData = CompletionNotification & {
|
|
|
76
81
|
parallelHandoff?: ParallelHandoffReference;
|
|
77
82
|
};
|
|
78
83
|
|
|
84
|
+
type ResultFileIdentity = {
|
|
85
|
+
sessionId?: string;
|
|
86
|
+
runId?: string;
|
|
87
|
+
asyncDir?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function jsonStringProperty(raw: string, property: string): string | undefined {
|
|
91
|
+
const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
|
|
92
|
+
let encoded: string | undefined;
|
|
93
|
+
for (const match of matches) encoded = match[1];
|
|
94
|
+
if (!encoded) return undefined;
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(encoded) as unknown;
|
|
97
|
+
return typeof value === "string" && value ? value : undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resultFileIdentity(raw: string, file: string): ResultFileIdentity {
|
|
104
|
+
return {
|
|
105
|
+
sessionId: jsonStringProperty(raw, "sessionId"),
|
|
106
|
+
runId: file.replace(/\.json$/i, ""),
|
|
107
|
+
asyncDir: jsonStringProperty(raw, "asyncDir"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
function sanitizeNestedResultChildren(value: unknown, resultPath: string, label: string): NestedRunSummary[] | undefined {
|
|
80
112
|
if (value === undefined) return undefined;
|
|
81
113
|
if (!Array.isArray(value)) {
|
|
@@ -121,9 +153,11 @@ export function createResultWatcher(
|
|
|
121
153
|
const fsApi = deps.fs ?? fs;
|
|
122
154
|
const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
|
|
123
155
|
const notifier = deps.notifier ?? { deliver: async () => true };
|
|
156
|
+
const parseResult = deps.parseResult ?? ((raw: string) => JSON.parse(raw) as ResultFileData);
|
|
124
157
|
const deliverIntercomResults = deps.deliverIntercomResults !== false;
|
|
125
158
|
const pendingTriggerTurn = new Map<string, boolean>();
|
|
126
159
|
const processing = new Set<string>();
|
|
160
|
+
const identityCache = new Map<string, { signature: string; identity: ResultFileIdentity }>();
|
|
127
161
|
let deliveryActive = true;
|
|
128
162
|
let deliveryEpoch = 0;
|
|
129
163
|
let resultScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -143,12 +177,51 @@ export function createResultWatcher(
|
|
|
143
177
|
state.resultFileCoalescer.schedule(file, delayMs);
|
|
144
178
|
};
|
|
145
179
|
|
|
180
|
+
const inspectResult = (file: string): ResultFileIdentity | undefined => {
|
|
181
|
+
const resultPath = path.join(resultsDir, file);
|
|
182
|
+
try {
|
|
183
|
+
const stat = fsApi.statSync(resultPath);
|
|
184
|
+
const signature = `${stat.size}:${stat.mtimeMs}`;
|
|
185
|
+
const cached = identityCache.get(file);
|
|
186
|
+
if (cached?.signature === signature) return cached.identity;
|
|
187
|
+
const identity = resultFileIdentity(fsApi.readFileSync(resultPath, "utf-8"), file);
|
|
188
|
+
identityCache.set(file, { signature, identity });
|
|
189
|
+
return identity;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
identityCache.delete(file);
|
|
192
|
+
if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const observedRunIds = (): ReadonlySet<string> => {
|
|
198
|
+
try {
|
|
199
|
+
return new Set(deps.observedCompletionRunIds?.() ?? []);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error("Failed to inspect observed subagent completion ids:", error);
|
|
202
|
+
return new Set();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const shouldProcessResult = (file: string, observed?: ReadonlySet<string>): boolean => {
|
|
207
|
+
const identity = inspectResult(file);
|
|
208
|
+
if (!identity) return false;
|
|
209
|
+
// Missing identity stays on the normal parser path so malformed or legacy
|
|
210
|
+
// files keep their existing diagnostics and compatibility behavior.
|
|
211
|
+
if (!identity.sessionId) return true;
|
|
212
|
+
if (identity.sessionId === state.currentSessionId) return true;
|
|
213
|
+
if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
|
|
214
|
+
if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
|
|
215
|
+
return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
|
|
216
|
+
};
|
|
217
|
+
|
|
146
218
|
const handleResult = async (file: string, triggerTurn: boolean) => {
|
|
147
219
|
const resultPath = path.join(resultsDir, file);
|
|
148
220
|
if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
|
|
221
|
+
if (!shouldProcessResult(file)) return;
|
|
149
222
|
processing.add(file);
|
|
150
223
|
try {
|
|
151
|
-
const data =
|
|
224
|
+
const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
|
|
152
225
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
153
226
|
const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
|
|
154
227
|
try {
|
|
@@ -189,6 +262,7 @@ export function createResultWatcher(
|
|
|
189
262
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
190
263
|
try {
|
|
191
264
|
fsApi.unlinkSync(resultPath);
|
|
265
|
+
identityCache.delete(file);
|
|
192
266
|
} catch (error) {
|
|
193
267
|
if (!isNotFound(error)) {
|
|
194
268
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -314,6 +388,7 @@ export function createResultWatcher(
|
|
|
314
388
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
315
389
|
try {
|
|
316
390
|
fsApi.unlinkSync(resultPath);
|
|
391
|
+
identityCache.delete(file);
|
|
317
392
|
} catch (error) {
|
|
318
393
|
if (!isNotFound(error)) {
|
|
319
394
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -338,7 +413,9 @@ export function createResultWatcher(
|
|
|
338
413
|
const triggerTurn = options.triggerTurn !== false;
|
|
339
414
|
fsApi.readdirSync(resultsDir)
|
|
340
415
|
.filter((f) => f.endsWith(".json"))
|
|
341
|
-
.forEach((file) =>
|
|
416
|
+
.forEach((file) => {
|
|
417
|
+
if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
|
|
418
|
+
});
|
|
342
419
|
} catch (error) {
|
|
343
420
|
if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
|
|
344
421
|
}
|
|
@@ -395,7 +472,10 @@ export function createResultWatcher(
|
|
|
395
472
|
return;
|
|
396
473
|
}
|
|
397
474
|
const fileName = file.toString();
|
|
398
|
-
if (fileName.endsWith(".json"))
|
|
475
|
+
if (fileName.endsWith(".json")) {
|
|
476
|
+
identityCache.delete(fileName);
|
|
477
|
+
scheduleResult(fileName, true);
|
|
478
|
+
}
|
|
399
479
|
});
|
|
400
480
|
state.watcher.on("error", (error) => {
|
|
401
481
|
if (shouldPoll(error)) return startPolling(error);
|
|
@@ -405,7 +485,7 @@ export function createResultWatcher(
|
|
|
405
485
|
scheduleRestart();
|
|
406
486
|
});
|
|
407
487
|
state.watcher.unref?.();
|
|
408
|
-
resultScanTimer = timers.setInterval(primeExistingResults,
|
|
488
|
+
resultScanTimer = timers.setInterval(primeExistingResults, HEALTHY_SCAN_INTERVAL_MS);
|
|
409
489
|
resultScanTimer.unref?.();
|
|
410
490
|
} catch (error) {
|
|
411
491
|
if (shouldPoll(error)) return startPolling(error);
|
|
@@ -430,6 +510,7 @@ export function createResultWatcher(
|
|
|
430
510
|
state.resultFileCoalescer.clear();
|
|
431
511
|
pendingTriggerTurn.clear();
|
|
432
512
|
processing.clear();
|
|
513
|
+
identityCache.clear();
|
|
433
514
|
};
|
|
434
515
|
|
|
435
516
|
return { startResultWatcher, primeExistingResults, stopResultWatcher };
|
|
@@ -7,6 +7,7 @@ import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
|
7
7
|
import { formatModelThinking } from "../../shared/formatters.ts";
|
|
8
8
|
import { formatActivityLabel } from "../../shared/status-format.ts";
|
|
9
9
|
import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
|
|
10
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
10
11
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
11
12
|
import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
|
|
12
13
|
import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
|
|
@@ -315,6 +316,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
if (asyncDir) {
|
|
319
|
+
const diskStatus = readStatus(asyncDir);
|
|
318
320
|
let reconciliation;
|
|
319
321
|
try {
|
|
320
322
|
reconciliation = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
|
|
@@ -327,6 +329,22 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
327
329
|
};
|
|
328
330
|
}
|
|
329
331
|
const status = reconciliation.status;
|
|
332
|
+
if (!status && diskStatus?.displayDismissedAt !== undefined) {
|
|
333
|
+
if (params.view === "transcript") {
|
|
334
|
+
if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
|
|
337
|
+
isError: true,
|
|
338
|
+
details: { mode: "single", results: [] },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return { content: [{ type: "text", text: formatAsyncRunTranscript(diskStatus, asyncDir, { index: params.index, lines: params.lines, sessionRoots: deps.sessionRoots }) }], details: { mode: "single", results: [] } };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: `Run: ${diskStatus.runId}\nState: display-dismissed\nDismissed: ${new Date(diskStatus.displayDismissedAt).toISOString()}\nNo running work was terminated.` }],
|
|
345
|
+
details: { mode: "single", results: [] },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
330
348
|
const effectiveRunId = status?.runId ?? resolvedId ?? "unknown";
|
|
331
349
|
const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
|
|
332
350
|
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
@@ -406,6 +406,29 @@ export class ScheduledRunManager {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
observedCompletionRunIds(): Set<string> {
|
|
410
|
+
const runIds = new Set<string>();
|
|
411
|
+
for (const store of this.stores.values()) {
|
|
412
|
+
let ids: string[];
|
|
413
|
+
try {
|
|
414
|
+
ids = store.ids();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error(`Failed to inspect schedule store '${store.root}' during async completion discovery:`, error);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const id of ids) {
|
|
420
|
+
try {
|
|
421
|
+
for (const run of store.history(id)) {
|
|
422
|
+
if (run.state === "running" && run.asyncId) runIds.add(run.asyncId);
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion discovery:`, error);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return runIds;
|
|
430
|
+
}
|
|
431
|
+
|
|
409
432
|
handleAsyncCompletion(payload: unknown): void {
|
|
410
433
|
if (!payload || typeof payload !== "object") return;
|
|
411
434
|
const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
|
|
@@ -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 { updateActiveRunIndex } from "./active-run-index.ts";
|
|
4
5
|
import { readStatus } from "../../shared/utils.ts";
|
|
5
6
|
import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
|
|
6
7
|
import { resolveEffectiveThinking } from "../../shared/model-info.ts";
|
|
@@ -153,7 +154,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
|
|
|
153
154
|
modelAttempts: child?.modelAttempts ?? step.modelAttempts,
|
|
154
155
|
};
|
|
155
156
|
});
|
|
156
|
-
|
|
157
|
+
const terminalStatus: AsyncStatus = {
|
|
157
158
|
...status,
|
|
158
159
|
state: repair.state,
|
|
159
160
|
...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
|
|
@@ -165,6 +166,8 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
|
|
|
165
166
|
endedAt: status.endedAt ?? now,
|
|
166
167
|
steps,
|
|
167
168
|
};
|
|
169
|
+
delete terminalStatus.displayDismissedAt;
|
|
170
|
+
return terminalStatus;
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, now: number): AsyncStatus {
|
|
@@ -258,6 +261,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
|
|
|
258
261
|
const repair = buildFailedRepair(status, asyncDir, now, reason);
|
|
259
262
|
writeAtomicJson(resultPath, repair.result);
|
|
260
263
|
writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
|
|
264
|
+
updateActiveRunIndex(asyncDir, repair.status.state);
|
|
261
265
|
appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
|
|
262
266
|
type: "subagent.run.repaired_stale",
|
|
263
267
|
ts: now,
|
|
@@ -339,7 +343,6 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
|
|
|
339
343
|
if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
|
|
340
344
|
if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
|
|
341
345
|
}
|
|
342
|
-
|
|
343
346
|
const runId = effectiveStatus.runId || path.basename(asyncDir);
|
|
344
347
|
const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
|
|
345
348
|
if (fs.existsSync(resultPath)) {
|
|
@@ -348,9 +351,13 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
|
|
|
348
351
|
: undefined;
|
|
349
352
|
if (terminalStatus) {
|
|
350
353
|
writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
|
|
354
|
+
updateActiveRunIndex(asyncDir, terminalStatus.state);
|
|
351
355
|
return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
|
|
352
356
|
}
|
|
353
|
-
return { status: effectiveStatus, repaired: false, resultPath };
|
|
357
|
+
if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
|
|
358
|
+
}
|
|
359
|
+
if (effectiveStatus.displayDismissedAt !== undefined) {
|
|
360
|
+
return { status: null, repaired: false, resultPath };
|
|
354
361
|
}
|
|
355
362
|
|
|
356
363
|
if (effectiveStatus.state !== "running" || typeof effectiveStatus.pid !== "number") {
|
|
@@ -5,6 +5,8 @@ import * as path from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import type { Message } from "@earendil-works/pi-ai";
|
|
7
7
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
8
|
+
import { createFileCoalescer } from "../../shared/file-coalescer.ts";
|
|
9
|
+
import { isActiveAsyncState, updateActiveRunIndex } from "./active-run-index.ts";
|
|
8
10
|
import { createChildTranscriptWriter, type ChildTranscriptWriter } from "../../shared/child-transcript.ts";
|
|
9
11
|
import { closeSteerInbox, consumeInterruptRequest, consumeSteerRequests, deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, enqueueStepSteer, steerAcksDir, steerCapabilityPath, stepSteerInboxDir, watchAsyncControlInbox, type SteerAck, type SteerCapability, type SteerRequest } from "./control-channel.ts";
|
|
10
12
|
import { appendJsonl as appendRawJsonl, formatOutputArtifactContent, getArtifactPaths, writeArtifact, writeMetadata } from "../../shared/artifacts.ts";
|
|
@@ -2180,6 +2182,7 @@ async function runSubagent(
|
|
|
2180
2182
|
|
|
2181
2183
|
fs.mkdirSync(asyncDir, { recursive: true });
|
|
2182
2184
|
writeAtomicJson(statusPath, statusPayload);
|
|
2185
|
+
updateActiveRunIndex(asyncDir, statusPayload.state);
|
|
2183
2186
|
let pendingParallelUsageCost: CostSummary = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
2184
2187
|
const currentUsageTotals = (): CostSummary => {
|
|
2185
2188
|
const cost = results.reduce<CostSummary>((sum, result) => ({
|
|
@@ -2252,11 +2255,25 @@ async function runSubagent(
|
|
|
2252
2255
|
for (const node of graph.nodes) updateNode(node);
|
|
2253
2256
|
statusPayload.workflowGraph = graph;
|
|
2254
2257
|
};
|
|
2255
|
-
|
|
2258
|
+
let lastIndexedActiveState = isActiveAsyncState(statusPayload.state);
|
|
2259
|
+
const writeStatusPayloadNow = (): void => {
|
|
2256
2260
|
refreshWorkflowGraph();
|
|
2257
2261
|
writeAtomicJson(statusPath, statusPayload);
|
|
2262
|
+
const activeState = isActiveAsyncState(statusPayload.state);
|
|
2263
|
+
if (activeState !== lastIndexedActiveState) {
|
|
2264
|
+
updateActiveRunIndex(asyncDir, statusPayload.state);
|
|
2265
|
+
lastIndexedActiveState = activeState;
|
|
2266
|
+
}
|
|
2258
2267
|
emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
|
|
2259
2268
|
};
|
|
2269
|
+
const statusWriteCoalescer = createFileCoalescer(writeStatusPayloadNow, 100);
|
|
2270
|
+
const writeStatusPayload = (immediate = true): void => {
|
|
2271
|
+
if (immediate || statusPayload.state !== "running" || statusPayload.activityState !== undefined) {
|
|
2272
|
+
if (!statusWriteCoalescer.flush(statusPath)) writeStatusPayloadNow();
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
statusWriteCoalescer.schedule(statusPath);
|
|
2276
|
+
};
|
|
2260
2277
|
const updateExternalProcess = (index: number, process: ExternalProcessStatus): void => {
|
|
2261
2278
|
requiredStatusStep(statusPayload, index).externalProcess = process;
|
|
2262
2279
|
statusPayload.lastUpdate = Date.now();
|
|
@@ -2783,7 +2800,7 @@ async function runSubagent(
|
|
|
2783
2800
|
step.lastActivityAt = now;
|
|
2784
2801
|
statusPayload.lastActivityAt = now;
|
|
2785
2802
|
statusPayload.lastUpdate = now;
|
|
2786
|
-
writeStatusPayload();
|
|
2803
|
+
writeStatusPayload(false);
|
|
2787
2804
|
return;
|
|
2788
2805
|
}
|
|
2789
2806
|
if (event.type === "tool_execution_start" && event.toolName) {
|
|
@@ -2919,7 +2936,7 @@ async function runSubagent(
|
|
|
2919
2936
|
statusPayload.lastActivityAt = now;
|
|
2920
2937
|
statusPayload.lastUpdate = now;
|
|
2921
2938
|
maybeEmitActiveLongRunning(flatIndex, now);
|
|
2922
|
-
writeStatusPayload();
|
|
2939
|
+
writeStatusPayload(false);
|
|
2923
2940
|
};
|
|
2924
2941
|
const updateRunnerActivityState = (now: number): boolean => {
|
|
2925
2942
|
if (!controlConfig.enabled) return false;
|
|
@@ -4480,6 +4497,7 @@ async function runSubagent(
|
|
|
4480
4497
|
statusPayload.error = `Step failed: ${failedStep.agent}`;
|
|
4481
4498
|
}
|
|
4482
4499
|
}
|
|
4500
|
+
writeStatusPayload();
|
|
4483
4501
|
try {
|
|
4484
4502
|
writeAtomicJson(resultPath, {
|
|
4485
4503
|
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
@@ -4633,7 +4651,6 @@ async function runSubagent(
|
|
|
4633
4651
|
console.error(`Failed to write process-terminal candidate for '${id}':`, error);
|
|
4634
4652
|
}
|
|
4635
4653
|
}
|
|
4636
|
-
writeStatusPayload();
|
|
4637
4654
|
}
|
|
4638
4655
|
|
|
4639
4656
|
async function waitForStartupControl(
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
|
+
import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
|
|
6
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { updateActiveRunIndex } from "../background/active-run-index.ts";
|
|
8
|
+
import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
|
|
9
|
+
|
|
10
|
+
export function dismissRecoveredWorkflow(
|
|
11
|
+
state: SubagentState,
|
|
12
|
+
location: { asyncDir: string | null; resolvedId?: string },
|
|
13
|
+
): AgentToolResult<Details> {
|
|
14
|
+
const asyncDir = location.asyncDir;
|
|
15
|
+
const runId = location.resolvedId ?? (asyncDir ? path.basename(asyncDir) : "requested");
|
|
16
|
+
if (!asyncDir) {
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text", text: `Recovered workflow '${runId}' has no disk status to dismiss.` }],
|
|
19
|
+
isError: true,
|
|
20
|
+
details: { mode: "management", results: [] },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const status = readStatus(asyncDir);
|
|
24
|
+
if (!status || status.mode !== "workflow") {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: `Run '${runId}' is not a recovered workflow.` }],
|
|
27
|
+
isError: true,
|
|
28
|
+
details: { mode: "management", results: [] },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!state.currentSessionId || status.sessionId !== state.currentSessionId) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: "text", text: `Recovered workflow '${runId}' was not found in the active session.` }],
|
|
34
|
+
isError: true,
|
|
35
|
+
details: { mode: "management", results: [] },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (state.workflowControllers?.has(status.runId) || state.workflowControllers?.has(runId)) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: `Workflow '${runId}' still has a live controller and cannot be dismissed.` }],
|
|
41
|
+
isError: true,
|
|
42
|
+
details: { mode: "management", results: [] },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (status.state !== "running") {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: `Recovered workflow '${runId}' is ${status.state}, not running.` }],
|
|
48
|
+
isError: true,
|
|
49
|
+
details: { mode: "management", results: [] },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let latestStatus = status;
|
|
53
|
+
const resultPath = path.join(DIRS.results, `${status.runId}.json`);
|
|
54
|
+
if (fs.existsSync(resultPath)) {
|
|
55
|
+
const reconciled = reconcileAsyncRun(asyncDir).status;
|
|
56
|
+
if (reconciled && reconciled.state !== "running") {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: `Recovered workflow '${runId}' is ${reconciled.state}, not running.` }],
|
|
59
|
+
isError: true,
|
|
60
|
+
details: { mode: "management", results: [] },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (reconciled) latestStatus = reconciled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const dismissed = { ...latestStatus, displayDismissedAt: Date.now() };
|
|
67
|
+
writeAtomicJson(path.join(asyncDir, "status.json"), dismissed);
|
|
68
|
+
const repaired = reconcileAsyncRun(asyncDir).status;
|
|
69
|
+
if (repaired && repaired.state !== "running") {
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: "text", text: `Recovered workflow '${runId}' is ${repaired.state}, not running.` }],
|
|
72
|
+
isError: true,
|
|
73
|
+
details: { mode: "management", results: [] },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
updateActiveRunIndex(asyncDir, "complete");
|
|
77
|
+
state.asyncJobs.delete(status.runId);
|
|
78
|
+
state.asyncJobs.delete(runId);
|
|
79
|
+
state.fleetJobs?.delete(status.runId);
|
|
80
|
+
state.fleetJobs?.delete(runId);
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: `Dismissed recovered workflow ${status.runId} from the display. No running work was terminated.` }],
|
|
83
|
+
details: { mode: "management", results: [] },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -219,15 +219,14 @@ export async function steerAsyncRun(input: {
|
|
|
219
219
|
const limits = remainingSteeringRecoveryLimits(recoveryTarget.recoveryDescriptor, paused);
|
|
220
220
|
const revived = await input.recover(limits);
|
|
221
221
|
if (revived.isError || !revived.details.asyncId) throw new Error(revived.content[0]?.type === "text" ? revived.content[0].text : "Replacement launch failed; source run remains paused.");
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const stepSteering = sourceStatus.steps?.[targetIndex]?.steering;
|
|
222
|
+
const targetIndex = input.index ?? paused.steering?.recent.find((request) => request.id === requestId)?.targets[0]?.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
|
|
223
|
+
if (paused.steering && targetIndex >= 0) {
|
|
224
|
+
updateSteeringTarget(paused.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
|
|
225
|
+
const stepSteering = paused.steps?.[targetIndex]?.steering;
|
|
227
226
|
if (stepSteering) updateSteeringTarget(stepSteering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
|
|
228
|
-
writeAtomicJson(path.join(asyncDir, "status.json"),
|
|
227
|
+
writeAtomicJson(path.join(asyncDir, "status.json"), paused);
|
|
229
228
|
}
|
|
230
|
-
const recovered =
|
|
229
|
+
const recovered = paused.steering ? actionResultFromSteeringStatus(paused.steering, status.runId, requestId, revived.details.asyncId) : undefined;
|
|
231
230
|
appendSteeringNotice("recovered", `Steering recovered for run ${status.runId}; replacement ${revived.details.asyncId} launched.`);
|
|
232
231
|
return { content: [{ type: "text", text: `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.` }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
|
|
233
232
|
} catch (error) {
|