pi-subagents 0.46.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 +37 -0
- package/agents/reviewer.md +3 -4
- package/docs/agents.md +1 -1
- package/docs/configuration.md +14 -6
- package/docs/extension-api.md +1 -1
- package/docs/missions.md +5 -3
- package/docs/models.md +3 -1
- package/docs/observability.md +3 -3
- package/docs/tool-reference.md +2 -2
- package/docs/watchdog.md +1 -1
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +4 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
- package/src/extension/config.ts +13 -0
- package/src/extension/fanout-child.ts +5 -4
- package/src/extension/index.ts +39 -14
- package/src/extension/rpc.ts +7 -6
- package/src/extension/schemas.ts +27 -7
- package/src/extension/tool-description.ts +26 -8
- package/src/inspectors/herdr/project-panes.ts +2 -1
- package/src/intercom/native-supervisor-channel.ts +102 -4
- package/src/missions/store.ts +2 -1
- package/src/missions/workflow-state.ts +19 -13
- package/src/runs/background/active-run-index.ts +42 -0
- package/src/runs/background/async-execution.ts +10 -5
- package/src/runs/background/async-job-tracker.ts +293 -133
- package/src/runs/background/async-resume.ts +19 -3
- package/src/runs/background/async-status.ts +33 -5
- package/src/runs/background/control-channel.ts +91 -17
- package/src/runs/background/result-watcher.ts +102 -7
- package/src/runs/background/run-status.ts +18 -0
- package/src/runs/background/scheduled-runs.ts +25 -1
- package/src/runs/background/stale-run-reconciler.ts +12 -24
- package/src/runs/background/subagent-runner.ts +68 -10
- package/src/runs/foreground/async-dismiss-action.ts +85 -0
- package/src/runs/foreground/async-steering-action.ts +6 -7
- package/src/runs/foreground/chain-execution.ts +3 -0
- package/src/runs/foreground/execution.ts +3 -0
- package/src/runs/foreground/subagent-executor.ts +129 -20
- 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/subagent-prompt-runtime.ts +24 -5
- package/src/runs/shared/worktree.ts +3 -2
- package/src/shared/artifacts.ts +15 -15
- package/src/shared/display-text.ts +100 -0
- package/src/shared/file-coalescer.ts +9 -0
- package/src/shared/formatters.ts +4 -6
- package/src/shared/settings.ts +15 -2
- package/src/shared/types.ts +16 -2
- package/src/shared/utils.ts +43 -33
- package/src/slash/slash-commands.ts +3 -1
- package/src/tui/fleet-status.ts +14 -10
- package/src/tui/render.ts +30 -26
- package/src/watchdog/change-signature.ts +4 -3
- package/src/workflows/scripted-workflow.ts +4 -1
|
@@ -21,7 +21,18 @@ 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 };
|
|
35
|
+
const CONTROL_SAFETY_POLL_INTERVAL_MS = 5000;
|
|
25
36
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
26
37
|
|
|
27
38
|
export interface InterruptRequest {
|
|
@@ -205,6 +216,13 @@ export function writeSteerRequestToDir(dir: string, request: SteerRequest): stri
|
|
|
205
216
|
return requestPath;
|
|
206
217
|
}
|
|
207
218
|
|
|
219
|
+
export function writeSteerRequestToExistingDir(dir: string, request: SteerRequest): string {
|
|
220
|
+
if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
|
|
221
|
+
const requestPath = path.join(dir, steerRequestFileName(request));
|
|
222
|
+
writeJsonToExistingDir(requestPath, request);
|
|
223
|
+
return requestPath;
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
|
|
209
227
|
assertChildIndex(capability.index);
|
|
210
228
|
if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
|
|
@@ -385,6 +403,25 @@ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof f
|
|
|
385
403
|
return capabilities;
|
|
386
404
|
}
|
|
387
405
|
|
|
406
|
+
export function consumeSteerAckFromDir(
|
|
407
|
+
dir: string,
|
|
408
|
+
requestId: string,
|
|
409
|
+
fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
|
|
410
|
+
): SteerAck | undefined {
|
|
411
|
+
if (!fsImpl.existsSync(dir)) return undefined;
|
|
412
|
+
let entries: string[];
|
|
413
|
+
try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
|
|
414
|
+
for (const entry of entries) {
|
|
415
|
+
const target = path.join(dir, entry);
|
|
416
|
+
let ack: SteerAck | undefined;
|
|
417
|
+
try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
|
|
418
|
+
if (ack?.requestId !== requestId) continue;
|
|
419
|
+
try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
|
|
420
|
+
return ack;
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
388
425
|
export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
|
|
389
426
|
const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
|
|
390
427
|
if (!fsImpl.existsSync(root)) return [];
|
|
@@ -585,9 +622,9 @@ export function deliverCheckpointDecisionRequest(input: {
|
|
|
585
622
|
|
|
586
623
|
/**
|
|
587
624
|
* Runner side: watch the control inbox and route interrupt requests into
|
|
588
|
-
* `onInterrupt`. Uses `fs.watch` when available
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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.
|
|
591
628
|
*/
|
|
592
629
|
export function watchAsyncControlInbox(
|
|
593
630
|
asyncDir: string,
|
|
@@ -600,6 +637,7 @@ export function watchAsyncControlInbox(
|
|
|
600
637
|
onSteerCapability?: (capability: SteerCapability) => void;
|
|
601
638
|
onSteerAck?: (ack: SteerAck) => void;
|
|
602
639
|
pollIntervalMs?: number;
|
|
640
|
+
safetyPollIntervalMs?: number;
|
|
603
641
|
fs?: ControlChannelFs;
|
|
604
642
|
timers?: ControlChannelTimers;
|
|
605
643
|
},
|
|
@@ -633,27 +671,63 @@ export function watchAsyncControlInbox(
|
|
|
633
671
|
// Handle a request that may have arrived before the watcher started.
|
|
634
672
|
check();
|
|
635
673
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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();
|
|
641
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();
|
|
642
716
|
} catch {
|
|
643
|
-
|
|
717
|
+
startPolling();
|
|
644
718
|
}
|
|
645
719
|
|
|
646
|
-
const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
647
|
-
interval.unref?.();
|
|
648
|
-
|
|
649
720
|
return () => {
|
|
650
721
|
if (disposed) return;
|
|
651
722
|
disposed = true;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
723
|
+
for (const watcher of watchers) {
|
|
724
|
+
try {
|
|
725
|
+
watcher.close();
|
|
726
|
+
} catch {
|
|
727
|
+
// ignore
|
|
728
|
+
}
|
|
656
729
|
}
|
|
657
|
-
timers.clearInterval(interval);
|
|
730
|
+
if (interval) timers.clearInterval(interval);
|
|
731
|
+
if (safetyInterval) timers.clearInterval(safetyInterval);
|
|
658
732
|
};
|
|
659
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,11 +153,14 @@ 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;
|
|
163
|
+
let resultScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
129
164
|
// The sole in-memory ownership lease. It is acquired for one active session
|
|
130
165
|
// and revoked before the watcher, queues, or callbacks are torn down.
|
|
131
166
|
let activeSessionId: string | null = null;
|
|
@@ -142,12 +177,51 @@ export function createResultWatcher(
|
|
|
142
177
|
state.resultFileCoalescer.schedule(file, delayMs);
|
|
143
178
|
};
|
|
144
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
|
+
|
|
145
218
|
const handleResult = async (file: string, triggerTurn: boolean) => {
|
|
146
219
|
const resultPath = path.join(resultsDir, file);
|
|
147
220
|
if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
|
|
221
|
+
if (!shouldProcessResult(file)) return;
|
|
148
222
|
processing.add(file);
|
|
149
223
|
try {
|
|
150
|
-
const data =
|
|
224
|
+
const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
|
|
151
225
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
152
226
|
const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
|
|
153
227
|
try {
|
|
@@ -188,6 +262,7 @@ export function createResultWatcher(
|
|
|
188
262
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
189
263
|
try {
|
|
190
264
|
fsApi.unlinkSync(resultPath);
|
|
265
|
+
identityCache.delete(file);
|
|
191
266
|
} catch (error) {
|
|
192
267
|
if (!isNotFound(error)) {
|
|
193
268
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -313,6 +388,7 @@ export function createResultWatcher(
|
|
|
313
388
|
if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
|
|
314
389
|
try {
|
|
315
390
|
fsApi.unlinkSync(resultPath);
|
|
391
|
+
identityCache.delete(file);
|
|
316
392
|
} catch (error) {
|
|
317
393
|
if (!isNotFound(error)) {
|
|
318
394
|
console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
|
|
@@ -337,15 +413,23 @@ export function createResultWatcher(
|
|
|
337
413
|
const triggerTurn = options.triggerTurn !== false;
|
|
338
414
|
fsApi.readdirSync(resultsDir)
|
|
339
415
|
.filter((f) => f.endsWith(".json"))
|
|
340
|
-
.forEach((file) =>
|
|
416
|
+
.forEach((file) => {
|
|
417
|
+
if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
|
|
418
|
+
});
|
|
341
419
|
} catch (error) {
|
|
342
420
|
if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
|
|
343
421
|
}
|
|
344
422
|
};
|
|
345
423
|
|
|
424
|
+
const clearResultScan = () => {
|
|
425
|
+
if (resultScanTimer) timers.clearInterval(resultScanTimer);
|
|
426
|
+
resultScanTimer = null;
|
|
427
|
+
};
|
|
428
|
+
|
|
346
429
|
const startPolling = (reason: unknown) => {
|
|
347
430
|
state.watcher?.close();
|
|
348
431
|
state.watcher = null;
|
|
432
|
+
clearResultScan();
|
|
349
433
|
if (state.watcherRestartTimer) return;
|
|
350
434
|
console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
|
|
351
435
|
primeExistingResults();
|
|
@@ -354,6 +438,7 @@ export function createResultWatcher(
|
|
|
354
438
|
};
|
|
355
439
|
|
|
356
440
|
const scheduleRestart = () => {
|
|
441
|
+
clearResultScan();
|
|
357
442
|
if (state.watcherRestartTimer) return;
|
|
358
443
|
state.watcherRestartTimer = timers.setTimeout(() => {
|
|
359
444
|
state.watcherRestartTimer = null;
|
|
@@ -381,10 +466,16 @@ export function createResultWatcher(
|
|
|
381
466
|
}
|
|
382
467
|
try {
|
|
383
468
|
const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
|
|
384
|
-
state.watcher = fsApi.watch(watchDir, (
|
|
385
|
-
if (
|
|
469
|
+
state.watcher = fsApi.watch(watchDir, (_event, file) => {
|
|
470
|
+
if (!file) {
|
|
471
|
+
primeExistingResults();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
386
474
|
const fileName = file.toString();
|
|
387
|
-
if (fileName.endsWith(".json"))
|
|
475
|
+
if (fileName.endsWith(".json")) {
|
|
476
|
+
identityCache.delete(fileName);
|
|
477
|
+
scheduleResult(fileName, true);
|
|
478
|
+
}
|
|
388
479
|
});
|
|
389
480
|
state.watcher.on("error", (error) => {
|
|
390
481
|
if (shouldPoll(error)) return startPolling(error);
|
|
@@ -394,6 +485,8 @@ export function createResultWatcher(
|
|
|
394
485
|
scheduleRestart();
|
|
395
486
|
});
|
|
396
487
|
state.watcher.unref?.();
|
|
488
|
+
resultScanTimer = timers.setInterval(primeExistingResults, HEALTHY_SCAN_INTERVAL_MS);
|
|
489
|
+
resultScanTimer.unref?.();
|
|
397
490
|
} catch (error) {
|
|
398
491
|
if (shouldPoll(error)) return startPolling(error);
|
|
399
492
|
console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
|
|
@@ -413,9 +506,11 @@ export function createResultWatcher(
|
|
|
413
506
|
timers.clearInterval(state.watcherRestartTimer);
|
|
414
507
|
}
|
|
415
508
|
state.watcherRestartTimer = null;
|
|
509
|
+
clearResultScan();
|
|
416
510
|
state.resultFileCoalescer.clear();
|
|
417
511
|
pendingTriggerTurn.clear();
|
|
418
512
|
processing.clear();
|
|
513
|
+
identityCache.clear();
|
|
419
514
|
};
|
|
420
515
|
|
|
421
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");
|
|
@@ -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
|
}
|
|
@@ -405,6 +406,29 @@ export class ScheduledRunManager {
|
|
|
405
406
|
}
|
|
406
407
|
}
|
|
407
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
|
+
|
|
408
432
|
handleAsyncCompletion(payload: unknown): void {
|
|
409
433
|
if (!payload || typeof payload !== "object") return;
|
|
410
434
|
const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
|
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
4
6
|
import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
|
|
5
7
|
import { resolveEffectiveThinking } from "../../shared/model-info.ts";
|
|
6
8
|
import { normalizeParallelGroups } from "./parallel-groups.ts";
|
|
@@ -84,26 +86,6 @@ function appendJsonlBestEffort(filePath: string, payload: object): void {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
|
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
89
|
interface ResultChildOutcome {
|
|
108
90
|
agent?: string;
|
|
109
91
|
success?: boolean;
|
|
@@ -172,7 +154,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
|
|
|
172
154
|
modelAttempts: child?.modelAttempts ?? step.modelAttempts,
|
|
173
155
|
};
|
|
174
156
|
});
|
|
175
|
-
|
|
157
|
+
const terminalStatus: AsyncStatus = {
|
|
176
158
|
...status,
|
|
177
159
|
state: repair.state,
|
|
178
160
|
...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
|
|
@@ -184,6 +166,8 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
|
|
|
184
166
|
endedAt: status.endedAt ?? now,
|
|
185
167
|
steps,
|
|
186
168
|
};
|
|
169
|
+
delete terminalStatus.displayDismissedAt;
|
|
170
|
+
return terminalStatus;
|
|
187
171
|
}
|
|
188
172
|
|
|
189
173
|
function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, now: number): AsyncStatus {
|
|
@@ -277,6 +261,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
|
|
|
277
261
|
const repair = buildFailedRepair(status, asyncDir, now, reason);
|
|
278
262
|
writeAtomicJson(resultPath, repair.result);
|
|
279
263
|
writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
|
|
264
|
+
updateActiveRunIndex(asyncDir, repair.status.state);
|
|
280
265
|
appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
|
|
281
266
|
type: "subagent.run.repaired_stale",
|
|
282
267
|
ts: now,
|
|
@@ -348,7 +333,7 @@ export function checkPidLiveness(pid: number, kill: KillFn = process.kill): PidL
|
|
|
348
333
|
|
|
349
334
|
export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOptions = {}): ReconcileAsyncRunResult {
|
|
350
335
|
const now = options.now?.() ?? Date.now();
|
|
351
|
-
const status =
|
|
336
|
+
const status = readStatus(asyncDir);
|
|
352
337
|
const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
|
|
353
338
|
const effectiveStatus = status ?? startedStatus;
|
|
354
339
|
if (!effectiveStatus) return { status: null, repaired: false };
|
|
@@ -358,7 +343,6 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
|
|
|
358
343
|
if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
|
|
359
344
|
if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
|
|
360
345
|
}
|
|
361
|
-
|
|
362
346
|
const runId = effectiveStatus.runId || path.basename(asyncDir);
|
|
363
347
|
const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
|
|
364
348
|
if (fs.existsSync(resultPath)) {
|
|
@@ -367,9 +351,13 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
|
|
|
367
351
|
: undefined;
|
|
368
352
|
if (terminalStatus) {
|
|
369
353
|
writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
|
|
354
|
+
updateActiveRunIndex(asyncDir, terminalStatus.state);
|
|
370
355
|
return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
|
|
371
356
|
}
|
|
372
|
-
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 };
|
|
373
361
|
}
|
|
374
362
|
|
|
375
363
|
if (effectiveStatus.state !== "running" || typeof effectiveStatus.pid !== "number") {
|