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.
@@ -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 plus an interval poll as a
625
- * portable safety net (covers filesystems/platforms where `fs.watch` is
626
- * unreliable). Fires once per distinct request. Returns a disposer.
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
- let watcher: fs.FSWatcher | undefined;
673
- try {
674
- watcher = fsImpl.watch(resolveWatchPath(dir, fsImpl.realpathSync.native), () => check());
675
- watcher.on?.("error", () => {
676
- // fs.watch can emit on transient FS errors; the interval poll keeps us live.
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
- watcher = undefined;
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
- try {
689
- watcher?.close();
690
- } catch {
691
- // ignore
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 = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
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) => scheduleResult(file, triggerTurn));
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")) scheduleResult(fileName, true);
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, POLL_INTERVAL_MS);
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
- return {
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
- const writeStatusPayload = (): void => {
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 sourceStatus = readStatus(asyncDir);
223
- const targetIndex = input.index ?? sourceStatus?.steering?.recent.find((request) => request.id === requestId)?.targets[0]?.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
224
- if (sourceStatus?.state === "paused" && sourceStatus.steering && targetIndex >= 0) {
225
- updateSteeringTarget(sourceStatus.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
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"), sourceStatus);
227
+ writeAtomicJson(path.join(asyncDir, "status.json"), paused);
229
228
  }
230
- const recovered = sourceStatus?.steering ? actionResultFromSteeringStatus(sourceStatus.steering, status.runId, requestId, revived.details.asyncId) : undefined;
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) {