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.
- package/README.md +2 -2
- package/package.json +5 -5
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +58 -1
- package/src/index.ts +184 -32
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +37 -3
- package/src/preflight.ts +29 -14
- package/src/proc.ts +140 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +206 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +106 -23
- package/src/state.ts +51 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui.ts +228 -88
- package/src/types.ts +107 -0
- package/src/unit-run.ts +8 -0
package/src/recovery.ts
ADDED
|
@@ -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
|
+
}
|