brl-subagent 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ // Purpose: Boot-time recovery scan — decides whether a persisted running record is live, reaps orphaned children, and marks interrupted records.
2
+ /**
3
+ * Option B U1 — recovery boot orchestration.
4
+ *
5
+ * A run record is written BEFORE its effect (the spawn), so a crashed
6
+ * conductor leaves `running` records and — on the foreground path — an
7
+ * orphaned child process. This module runs the boot-time scan that decides, in
8
+ * a FRESH process, which of those records are genuinely live:
9
+ *
10
+ * - `interruptedAt` already set → never re-mark, but re-check for a leftover
11
+ * live marker child and reap it again (B2 revisit)
12
+ * - owner alive (pid + start token match) → leave untouched
13
+ * - owner dead → reap a marker-verified orphan child (SIGTERM → grace →
14
+ * SIGKILL), then mark `interruptedAt` on the record
15
+ *
16
+ * The decision logic itself is the pure engine in `src/recovery-engine.ts`
17
+ * (type/external surface unchanged); this module owns candidate sourcing and
18
+ * the scan loop. `recoverInflightRuns` sources candidates from the durable
19
+ * registry (`src/run-registry.ts`) — NOT from the current session's run
20
+ * entries, which a fresh process cannot see after a crash — and
21
+ * `recoverProduction` is the entry point that also enforces the platform guard.
22
+ *
23
+ * Reaping waits ONE grace window for the whole scan (`runBootScan` collects
24
+ * every orphan pid and escalates them together), so N orphans cost one grace
25
+ * period, not N.
26
+ *
27
+ * PLATFORM BOUNDARY (required): the `/proc` reads live in `src/proc.ts`.
28
+ * Where `/proc` is unavailable, `recoverProduction` SKIPS the whole scan with
29
+ * one log line — `process.kill(pid, 0)` liveness alone cannot rule out pid
30
+ * reuse, so we NEVER kill an unverified process and NEVER mark a record when
31
+ * ownership cannot be verified.
32
+ */
33
+
34
+ import { listInflightRuns, type InflightRun } from "./run-registry";
35
+ import { assertSafeAgentId } from "./sanitize";
36
+ import { isProcAvailable, defaultRecoveryDeps } from "./proc";
37
+ import { decideRecovery, reapPids, type RecoveryDeps, type RecoveryRecord } from "./recovery-engine";
38
+ import type { Logger } from "./logging";
39
+
40
+ // Re-export the pure engine and the production wiring so existing importers
41
+ // (`index.ts`, the recovery/registry suites) keep their single entry point.
42
+ export * from "./recovery-engine";
43
+ export * from "./proc";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Boot scan orchestration
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Aggregate counts from one boot scan (also the shape the log line carries). */
50
+ export interface BootScanSummary {
51
+ scanned: number;
52
+ skipped: number;
53
+ /** Records whose interruption mark was DURABLY written (issue #304). */
54
+ marked: number;
55
+ /** Records whose mark the engine decided but whose durable write failed (#304). */
56
+ markFailures: number;
57
+ reaped: number;
58
+ /** Already-marked records that had leftover children reaped again this boot. */
59
+ revisited: number;
60
+ skippedUnverifiable: number;
61
+ skippedNoOwner: number;
62
+ /** Registry entries skipped fail-closed because their id failed the write guard. */
63
+ skippedUnsafeIds: number;
64
+ }
65
+
66
+ /** The all-zero summary (a skipped scan / an empty registry). */
67
+ export function emptyBootScanSummary(): BootScanSummary {
68
+ return {
69
+ scanned: 0,
70
+ skipped: 0,
71
+ marked: 0,
72
+ markFailures: 0,
73
+ reaped: 0,
74
+ revisited: 0,
75
+ skippedUnverifiable: 0,
76
+ skippedNoOwner: 0,
77
+ skippedUnsafeIds: 0,
78
+ };
79
+ }
80
+
81
+ export interface BootScanOptions {
82
+ records: RecoveryRecord[];
83
+ deps: RecoveryDeps;
84
+ /**
85
+ * Persist the D3 mark for this record (registry entry; agent record for
86
+ * background). Returns `false` when the durable write did NOT land — the scan
87
+ * then counts a `markFailure` instead of claiming `marked` (issue #304). A
88
+ * `void` return keeps the historical assume-success contract.
89
+ */
90
+ mark(record: RecoveryRecord, interruptedAt: string): boolean | void;
91
+ now?: () => Date;
92
+ log?: Pick<Logger, "debug" | "info" | "warn">;
93
+ }
94
+
95
+ /**
96
+ * The warning context shared by the two mark outcomes (issue #306 nit 3): the
97
+ * record identity, why it was marked, which orphan pids were reaped, and which
98
+ * of those survived the escalation. One construction so the "marked" and
99
+ * "mark failed" warnings cannot drift apart.
100
+ */
101
+ function markWarningPayload(
102
+ record: RecoveryRecord,
103
+ reason: string,
104
+ reaped: number[],
105
+ survived: ReadonlySet<number>,
106
+ ): { id: string; kind: RecoveryRecord["kind"]; reason: string; reaped: number[]; survived: number[] } {
107
+ return {
108
+ id: record.id,
109
+ kind: record.kind,
110
+ reason,
111
+ reaped,
112
+ survived: reaped.filter((pid) => survived.has(pid)),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Run the decision engine over every candidate, reap markers, and mark the
118
+ * records whose conductor is dead. Every decision is logged; the UI is
119
+ * deliberately silent (the notice/offer surface lands in U3).
120
+ *
121
+ * Idempotent: a marked record has `interruptedAt` set in memory before the
122
+ * next iteration, so a second scan over the same array is a no-op.
123
+ *
124
+ * Wait-once: every orphan pid across every record is reaped in ONE
125
+ * SIGTERM → grace → SIGKILL window (the `reapActiveChildren` shape), so a boot
126
+ * with N orphans is not an N × graceMs stall.
127
+ */
128
+ export async function runBootScan(options: BootScanOptions): Promise<BootScanSummary> {
129
+ const { records, deps, mark } = options;
130
+ const nowIso = (options.now?.() ?? new Date()).toISOString();
131
+ const summary = emptyBootScanSummary();
132
+ summary.scanned = records.length;
133
+
134
+ // Plan every record first (pure), then reap ALL proposed pids together —
135
+ // including leftovers of already-marked records (never re-marked).
136
+ const planned = records.map((record) => ({ record, plan: decideRecovery(record, deps) }));
137
+ const allReaps = planned.flatMap(({ plan }) =>
138
+ plan.decision === "mark" || plan.decision === "reap" ? plan.reap : [],
139
+ );
140
+ const survived = new Set(await reapPids(allReaps, deps));
141
+
142
+ for (const { record, plan } of planned) {
143
+ if (plan.decision === "mark") {
144
+ const durable = mark(record, nowIso) !== false;
145
+ // In-memory mark first: guarantees the scan is idempotent even when
146
+ // the caller's write-back is append-only (run entries) or deferred.
147
+ record.interruptedAt = nowIso;
148
+ const reaped = [...new Set(plan.reap)];
149
+ summary.reaped += reaped.length;
150
+ const markWarning = markWarningPayload(record, plan.reason, reaped, survived);
151
+ if (durable) {
152
+ summary.marked++;
153
+ options.log?.warn("Recovery: marked interrupted record", markWarning);
154
+ } else {
155
+ // Issue #304: the mark did NOT land. Count the failure and warn with
156
+ // the id — this must be visible even when nothing was marked, so it
157
+ // does not hide behind the caller's `summary.marked > 0` gate.
158
+ summary.markFailures++;
159
+ options.log?.warn("Recovery: registry mark failed", markWarning);
160
+ }
161
+ } else if (plan.decision === "reap") {
162
+ // B2 revisit: the record was already marked (so it is never re-marked),
163
+ // but it still carried a live marker child — reap it again this boot.
164
+ const reaped = [...new Set(plan.reap)];
165
+ summary.revisited++;
166
+ summary.reaped += reaped.length;
167
+ options.log?.warn("Recovery: reaped a leftover child of an interrupted record", {
168
+ id: record.id,
169
+ kind: record.kind,
170
+ reaped,
171
+ survived: reaped.filter((pid) => survived.has(pid)),
172
+ });
173
+ } else {
174
+ summary.skipped++;
175
+ if (plan.reason === "ownership-unverifiable") summary.skippedUnverifiable++;
176
+ if (plan.reason === "no-owner") summary.skippedNoOwner++;
177
+ options.log?.debug("Recovery: left record untouched", {
178
+ id: record.id,
179
+ kind: record.kind,
180
+ reason: plan.reason,
181
+ });
182
+ }
183
+ }
184
+
185
+ return summary;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Registry-sourced scan (the production candidate source)
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** The subset of a persisted background agent record the scan reasons over. */
193
+ export interface PersistedAgentLike {
194
+ status: string;
195
+ interruptedAt?: string;
196
+ owner?: RecoveryRecord["owner"];
197
+ childMarker?: string;
198
+ }
199
+
200
+ export interface RegistryScanOptions {
201
+ deps: RecoveryDeps;
202
+ /**
203
+ * Persist the D3 mark for this record (registry entry; agent record for
204
+ * background). Returns `false` when the durable write did not land.
205
+ */
206
+ mark(record: RecoveryRecord, interruptedAt: string): boolean | void;
207
+ /**
208
+ * Read ONE background run's agent record by id (never the whole store).
209
+ * Returns null when the record is absent; absent defaults to an in-flight
210
+ * entry so a registry without an agent file is still marked, not skipped.
211
+ */
212
+ readAgentRecord?(id: string): PersistedAgentLike | null;
213
+ now?: () => Date;
214
+ log?: Pick<Logger, "debug" | "info" | "warn">;
215
+ }
216
+
217
+ /**
218
+ * The production boot scan: candidate runs come from the durable registry
219
+ * (`listInflightRuns`), NOT from the current session's entries and NOT from a
220
+ * full parse of `.pi/subagents/`. This is what makes detection work on pi's
221
+ * default fresh startup after a conductor crash.
222
+ *
223
+ * Foreground/unit entries become `kind: "run"` candidates straight from the
224
+ * registry; background entries become `kind: "agent"` candidates enriched by
225
+ * reading THAT ONE agent file (so a stale registry entry whose agent record is
226
+ * already terminal is dropped). Legacy records with no registry entry are
227
+ * invisible here — consistent with the maintained `no-owner` policy (don't act
228
+ * on what we cannot verify).
229
+ */
230
+ export async function recoverInflightRuns(options: RegistryScanOptions): Promise<BootScanSummary> {
231
+ // Issue #304 read/write asymmetry, fail-closed: `listInflightRuns` accepts
232
+ // any string id, but every write (and `markInterrupted`) resolves through
233
+ // `assertSafeAgentId`. Acting on an entry the writer could never have
234
+ // produced is unsafe, so SKIP those here — counted and reported in ONE log
235
+ // line — instead of attempting a mark that would silently no-op.
236
+ const addressable: InflightRun[] = [];
237
+ const unsafeIds: string[] = [];
238
+ for (const entry of listInflightRuns()) {
239
+ try {
240
+ assertSafeAgentId(entry.id);
241
+ addressable.push(entry);
242
+ } catch {
243
+ unsafeIds.push(entry.id);
244
+ }
245
+ }
246
+ if (unsafeIds.length > 0) {
247
+ options.log?.warn("Recovery: skipped registry entries whose id fails the UUID guard", {
248
+ count: unsafeIds.length,
249
+ ids: unsafeIds,
250
+ });
251
+ }
252
+
253
+ const records: RecoveryRecord[] = [];
254
+ for (const entry of addressable) {
255
+ if (entry.kind === "background") {
256
+ const agent = options.readAgentRecord?.(entry.id) ?? null;
257
+ if (agent && agent.status !== "running") continue;
258
+ records.push({
259
+ id: entry.id,
260
+ kind: "agent",
261
+ status: "running",
262
+ interruptedAt: agent?.interruptedAt ?? entry.interruptedAt,
263
+ owner: agent?.owner ?? entry.owner,
264
+ childMarker: agent?.childMarker ?? entry.childMarker,
265
+ source: entry,
266
+ });
267
+ } else {
268
+ records.push({
269
+ id: entry.id,
270
+ kind: "run",
271
+ status: "running",
272
+ interruptedAt: entry.interruptedAt,
273
+ owner: entry.owner,
274
+ childMarker: entry.childMarker,
275
+ source: entry,
276
+ });
277
+ }
278
+ }
279
+ const summary = await runBootScan({
280
+ records,
281
+ deps: options.deps,
282
+ mark: options.mark,
283
+ now: options.now,
284
+ log: options.log,
285
+ });
286
+ summary.skippedUnsafeIds = unsafeIds.length;
287
+ return summary;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Production entry (owns the /proc platform guard)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ export interface ProductionRecoveryOptions {
295
+ /** Process deps; defaults to the real /proc wiring (`defaultRecoveryDeps`). */
296
+ deps?: RecoveryDeps;
297
+ /** Persist the D3 mark for this record; `false` = the durable write failed. */
298
+ mark(record: RecoveryRecord, interruptedAt: string): boolean | void;
299
+ readAgentRecord?(id: string): PersistedAgentLike | null;
300
+ now?: () => Date;
301
+ log?: Pick<Logger, "debug" | "info" | "warn">;
302
+ }
303
+
304
+ /**
305
+ * The production boot-recovery entry. It owns the `/proc` platform guard so the
306
+ * guarantee "nothing is marked or killed without /proc ownership verification"
307
+ * is enforced — and testable — BELOW the extension entry point: on a platform
308
+ * without `/proc` it returns an empty summary without scanning, instead of
309
+ * relying on the caller's early return.
310
+ */
311
+ export async function recoverProduction(options: ProductionRecoveryOptions): Promise<BootScanSummary> {
312
+ if (!isProcAvailable()) {
313
+ options.log?.info(
314
+ "Boot recovery skipped: /proc start-token verification unavailable on this platform",
315
+ );
316
+ return emptyBootScanSummary();
317
+ }
318
+ return recoverInflightRuns({
319
+ deps: options.deps ?? defaultRecoveryDeps(),
320
+ mark: options.mark,
321
+ readAgentRecord: options.readAgentRecord,
322
+ now: options.now,
323
+ log: options.log,
324
+ });
325
+ }
@@ -0,0 +1,269 @@
1
+ // Purpose: Durable per-run in-flight registry + the single run-record persist choke point that mirrors into it.
2
+ /**
3
+ * Option B U1 — durable in-flight run registry.
4
+ *
5
+ * A foreground/chain/parallel/graph run's `SubagentRun` record lives ONLY as a
6
+ * session custom entry, so after a conductor crash a fresh process (pi's
7
+ * default `SessionManager.create` startup) cannot see it at all. The agent
8
+ * store (`.pi/subagents/<id>.json`) covers background runs only. This module
9
+ * closes that gap: one small JSON per IN-FLIGHT run at the cwd-relative path
10
+ * `.pi/run-registry/<runId>.json`, written before the run's effect and cleared
11
+ * when it reaches a terminal state.
12
+ *
13
+ * `persistRunRecord` is the single choke point every run-record write goes
14
+ * through (`state.persistRun` delegates here; the background path in
15
+ * session-manager calls it directly). It mirrors the record into the registry:
16
+ * a `running` record with a kind REGISTERS (or refreshes) an entry; a terminal
17
+ * record CLEARS it; a `running` record without a kind is left alone (the boot
18
+ * scan's `interruptedAt` mark is written through `markInterrupted`).
19
+ *
20
+ * WHERE THE INTERRUPTION MARK LIVES (commitment for U3): for foreground/unit
21
+ * runs the registry entry IS the durable interruption mark — the boot scan
22
+ * writes `interruptedAt` there, not into a session entry it cannot reach.
23
+ * Background runs additionally mark their `.pi/subagents/<id>.json` agent
24
+ * record (the existing double-mark). U3's interrupted-run surfacing reads the
25
+ * registry.
26
+ *
27
+ * Reads are parse-tolerant and never throw on a malformed or vanished entry;
28
+ * writes are atomic (temp file + rename) and owner-only (0o600), mirroring the
29
+ * agent store's F6 discipline.
30
+ */
31
+
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+ import type { ProcessOwner, SubagentRun } from "./types";
36
+ import { CUSTOM_ENTRY_TYPES } from "./types";
37
+ import { assertSafeAgentId } from "./sanitize";
38
+
39
+ /** Which execution path owns an in-flight run (drives the boot scan's mark). */
40
+ export type InflightRunKind = "foreground" | "unit" | "background";
41
+
42
+ /** The durable per-run registry entry (one small JSON file per run id). */
43
+ export interface InflightRun {
44
+ id: string;
45
+ dispatchId?: string;
46
+ attempt?: number;
47
+ kind: InflightRunKind;
48
+ owner?: ProcessOwner;
49
+ childMarker?: string;
50
+ startedAt: string;
51
+ cwd: string;
52
+ interruptedAt?: string;
53
+ }
54
+
55
+ /** Fields accepted when registering/updating an entry (cwd defaults to the process cwd). */
56
+ export interface InflightRunInput {
57
+ id: string;
58
+ dispatchId?: string;
59
+ attempt?: number;
60
+ kind: InflightRunKind;
61
+ owner?: ProcessOwner;
62
+ childMarker?: string;
63
+ startedAt: string;
64
+ cwd?: string;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Storage location
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Registry directory, cwd-relative like the agent store (`.pi/subagents`).
73
+ * Test-only override, mirroring `__setStorageDir` — the shared temp-lifecycle
74
+ * fixture points it at a throwaway dir.
75
+ */
76
+ let REGISTRY_DIR = ".pi/run-registry";
77
+
78
+ /** TEST-ONLY: point the registry at an alternate directory. */
79
+ export function __setRegistryDir(dir: string): void {
80
+ REGISTRY_DIR = dir;
81
+ }
82
+
83
+ /** The active registry directory (absolute or cwd-relative as configured). */
84
+ export function registryDir(): string {
85
+ return REGISTRY_DIR;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Paths + atomic IO
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Resolve an entry's file path, refusing anything that is not a safe UUID —
94
+ * ids reach this module from records loaded off disk, and `path.join` does not
95
+ * sanitize traversal segments (same F24 rule as the agent store).
96
+ */
97
+ function resolvePath(id: string): string | null {
98
+ try {
99
+ assertSafeAgentId(id);
100
+ } catch {
101
+ return null;
102
+ }
103
+ return path.join(REGISTRY_DIR, `${id}.json`);
104
+ }
105
+
106
+ function ensureDir(): void {
107
+ fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 });
108
+ }
109
+
110
+ /** True when `value` looks like a registry entry (id + kind + startedAt). */
111
+ function isInflightRun(value: unknown): value is InflightRun {
112
+ if (!value || typeof value !== "object") return false;
113
+ const v = value as Record<string, unknown>;
114
+ if (typeof v.id !== "string" || typeof v.startedAt !== "string") return false;
115
+ return v.kind === "foreground" || v.kind === "unit" || v.kind === "background";
116
+ }
117
+
118
+ /** Parse one entry file; null on missing/corrupt/malformed. */
119
+ function readEntry(file: string): InflightRun | null {
120
+ try {
121
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
122
+ return isInflightRun(parsed) ? parsed : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Atomic, owner-only write: temp file in the same directory, then rename. */
129
+ function writeAtomic(file: string, entry: InflightRun): boolean {
130
+ try {
131
+ ensureDir();
132
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
133
+ fs.writeFileSync(tmp, JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 0o600 });
134
+ fs.renameSync(tmp, file);
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /** Copy only defined fields from an input onto an entry (never clobber with undefined). */
142
+ function mergeInput(base: InflightRun, input: InflightRunInput): InflightRun {
143
+ const next: InflightRun = { ...base, id: input.id, kind: input.kind };
144
+ if (input.dispatchId !== undefined) next.dispatchId = input.dispatchId;
145
+ if (input.attempt !== undefined) next.attempt = input.attempt;
146
+ if (input.owner !== undefined) next.owner = input.owner;
147
+ if (input.childMarker !== undefined) next.childMarker = input.childMarker;
148
+ if (input.startedAt) next.startedAt = input.startedAt;
149
+ if (input.cwd !== undefined) next.cwd = input.cwd;
150
+ return next;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Public API
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Register (or refresh) an in-flight run. A re-register preserves an existing
159
+ * `interruptedAt` so a later write cannot erase a boot-scan mark.
160
+ */
161
+ export function registerInflightRun(input: InflightRunInput): boolean {
162
+ const file = resolvePath(input.id);
163
+ if (!file) return false;
164
+ const existing = readEntry(file);
165
+ const entry: InflightRun = {
166
+ ...mergeInput(existing ?? { id: input.id, kind: input.kind, startedAt: input.startedAt, cwd: input.cwd ?? process.cwd() }, input),
167
+ };
168
+ return writeAtomic(file, entry);
169
+ }
170
+
171
+ /**
172
+ * Merge a defined-field patch into an existing entry. A missing entry is left
173
+ * missing (registration is `registerInflightRun`'s job, so a stray update can
174
+ * never resurrect a cleared run).
175
+ */
176
+ export function updateInflightRun(input: InflightRunInput): boolean {
177
+ const file = resolvePath(input.id);
178
+ if (!file) return false;
179
+ const existing = readEntry(file);
180
+ if (!existing) return false;
181
+ return writeAtomic(file, mergeInput(existing, input));
182
+ }
183
+
184
+ /** Remove an entry (terminal finalize). Missing file is a no-op. */
185
+ export function clearInflightRun(id: string): void {
186
+ const file = resolvePath(id);
187
+ if (!file) return;
188
+ try {
189
+ fs.unlinkSync(file);
190
+ } catch {
191
+ // Already cleared (or never registered) — the desired end state.
192
+ }
193
+ }
194
+
195
+ /** Every parseable in-flight entry; [] when the registry is absent. */
196
+ export function listInflightRuns(): InflightRun[] {
197
+ let files: string[];
198
+ try {
199
+ files = fs.readdirSync(REGISTRY_DIR);
200
+ } catch {
201
+ return [];
202
+ }
203
+ const entries: InflightRun[] = [];
204
+ for (const file of files) {
205
+ if (!file.endsWith(".json")) continue;
206
+ const entry = readEntry(path.join(REGISTRY_DIR, file));
207
+ if (entry) entries.push(entry);
208
+ }
209
+ return entries;
210
+ }
211
+
212
+ /**
213
+ * Write the D3 `interruptedAt` mark onto an existing entry. Idempotent; returns
214
+ * false when the entry is absent, its id fails the write guard, or the write
215
+ * fails. Issue #304: this MUST never throw — the boot scan treats a false as a
216
+ * durable-write failure and counts it, whereas a thrown FS error would abort
217
+ * the whole scan via `performBootRecovery`'s catch-all. The boolean is the
218
+ * only truthful success signal, so callers must not assume the mark landed.
219
+ */
220
+ export function markInterrupted(id: string, interruptedAt: string): boolean {
221
+ try {
222
+ const file = resolvePath(id);
223
+ if (!file) return false;
224
+ const existing = readEntry(file);
225
+ if (!existing) return false;
226
+ if (existing.interruptedAt) return true;
227
+ return writeAtomic(file, { ...existing, interruptedAt });
228
+ } catch {
229
+ // Belt-and-braces: the inner read/write helpers already swallow FS errors,
230
+ // but the scan's no-throw contract must not depend on that staying true.
231
+ return false;
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // The persistence choke point
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** Project a run record into the registry input shape. */
240
+ function toRegistryInput(run: SubagentRun, kind: InflightRunKind): InflightRunInput {
241
+ return {
242
+ id: run.id,
243
+ dispatchId: run.dispatchId,
244
+ attempt: run.attempt,
245
+ kind,
246
+ owner: run.owner,
247
+ childMarker: run.childMarker,
248
+ startedAt: run.startedAt,
249
+ cwd: process.cwd(),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The ONE run-record writer: append the session entry AND keep the registry in
255
+ * lockstep. `state.persistRun` delegates here and the background path calls it
256
+ * directly, so every write site is mirrored by construction.
257
+ *
258
+ * `kind` is required only at dispatch (`running`); terminal writes clear the
259
+ * registry and ignore it. A `running` write without a kind is a refresh with
260
+ * nothing to register — the boot scan's mark is a separate `markInterrupted`.
261
+ */
262
+ export function persistRunRecord(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
263
+ if (run.status !== "running") {
264
+ clearInflightRun(run.id);
265
+ } else if (kind) {
266
+ registerInflightRun(toRegistryInput(run, kind));
267
+ }
268
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
269
+ }