brl-subagent 2.5.0 → 2.5.2
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/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/history.ts +19 -9
- package/src/index.ts +206 -35
- package/src/kill-escalation.ts +338 -6
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/proc.ts +270 -20
- package/src/recovery-engine.ts +100 -14
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +19 -1
- package/src/run-registry.ts +64 -3
- package/src/runner.ts +216 -13
- package/src/session-manager.ts +129 -7
- package/src/state.ts +32 -2
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +102 -22
- package/src/types.ts +66 -1
- package/src/unit-run.ts +8 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Purpose: U3 recovery surface — interrupted-run projection, one-per-boot notice, and the abandon action.
|
|
2
|
+
/**
|
|
3
|
+
* Option B U3 — interrupted-run recovery surface.
|
|
4
|
+
*
|
|
5
|
+
* U1 marks a crashed conductor's in-flight records; U2 persists their session
|
|
6
|
+
* files. U3 makes them VISIBLE and recoverable by a human:
|
|
7
|
+
*
|
|
8
|
+
* - `collectInterruptedRuns` projects the durable registry (`interruptedAt`
|
|
9
|
+
* set) into display rows, enriched with the run/agent record's label + task;
|
|
10
|
+
* - `buildRecoveryNotice` renders ONE turn-boundary notice listing every
|
|
11
|
+
* unresolved interrupted run (id8 · label · task excerpt · age);
|
|
12
|
+
* - `resolveRecoveryNoticeDelivery` pins the notice to `nextTurn` — the boot
|
|
13
|
+
* notice must NEVER steer or arrive mid-turn;
|
|
14
|
+
* - `abandonInterruptedRun` finalizes a marked run as the existing terminal
|
|
15
|
+
* `stopped` outcome (agent record `stopped`; run record `failed` +
|
|
16
|
+
* `errorCategory: "aborted"`, mirroring every other stop path), retires its
|
|
17
|
+
* registry entry, prunes its U2 session dir, and CLAIMS the run's single
|
|
18
|
+
* terminal notice so the abandon never wakes the conductor (Fix B item 1);
|
|
19
|
+
* - `visibleRunEntries` is the Fix A bridge: session run entries UNIONED with
|
|
20
|
+
* a synthetic record for every durable interrupted run the session has
|
|
21
|
+
* never seen (a genuine fresh boot). Every session-sourced recovery surface
|
|
22
|
+
* routes through it, so a crashed run is listed in Run History, offered in
|
|
23
|
+
* the retry menu, abandonable, and resolvable through `retryRunId` — the
|
|
24
|
+
* four consumers that previously read session entries the crash left empty.
|
|
25
|
+
*
|
|
26
|
+
* Pure over injected deps so the matrix is unit-testable without the SDK, the
|
|
27
|
+
* filesystem, or the process table. NO resume/re-drive (U4), NO auto mode.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { listInflightRuns, type InflightRun, type InflightRunKind } from "./run-registry";
|
|
31
|
+
import type { SubagentRun } from "./types";
|
|
32
|
+
import { isInterruptedRun, runRecoveryPosture, type RecoveryMode } from "./types";
|
|
33
|
+
import { resolveTerminalRunEntry } from "./state";
|
|
34
|
+
import { sortNewestFirst } from "./history";
|
|
35
|
+
import { claimTerminalNotice, type DeliveryResolution } from "./notify-completion";
|
|
36
|
+
|
|
37
|
+
/** The abandon stamp recorded on the run + agent records. */
|
|
38
|
+
export const SUBAGENT_ABANDONED_MESSAGE = "Subagent run abandoned by user";
|
|
39
|
+
|
|
40
|
+
/** Custom-entry type of the one-per-boot recovery notice. */
|
|
41
|
+
export const RECOVERY_NOTICE_CUSTOM_TYPE = "subagent-recovery";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Collection
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** One unresolved interrupted run, projected for display. */
|
|
48
|
+
export interface InterruptedRunView {
|
|
49
|
+
id: string;
|
|
50
|
+
kind: InflightRunKind;
|
|
51
|
+
// Issue #329: the durable dispatch identity, projected so a bridged
|
|
52
|
+
// (post-crash) retry inherits the recorded lineage instead of minting a
|
|
53
|
+
// fresh `dispatchId`. Optional: pre-enrichment entries lack both.
|
|
54
|
+
dispatchId?: string;
|
|
55
|
+
attempt?: number;
|
|
56
|
+
label?: string;
|
|
57
|
+
task?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
thinkingLevel?: string;
|
|
60
|
+
/** The durable `originalParams` snapshot (absent for pre-enrichment entries). */
|
|
61
|
+
originalParams?: SubagentRun["originalParams"];
|
|
62
|
+
startedAt: string;
|
|
63
|
+
interruptedAt: string;
|
|
64
|
+
/** The posture snapshotted at dispatch (absent ⇒ "offer"). */
|
|
65
|
+
recoveryPosture: RecoveryMode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CollectInterruptedDeps {
|
|
69
|
+
/** The durable in-flight registry entries. */
|
|
70
|
+
listInflight(): InflightRun[];
|
|
71
|
+
/** ONE background agent record by id, or null. */
|
|
72
|
+
getAgent(id: string): {
|
|
73
|
+
description?: string;
|
|
74
|
+
task?: string;
|
|
75
|
+
model?: string;
|
|
76
|
+
thinkingLevel?: string;
|
|
77
|
+
} | null;
|
|
78
|
+
/** The session run entry (terminal-preferring), or undefined. */
|
|
79
|
+
findRun(id: string): SubagentRun | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Project every registry entry carrying an `interruptedAt` mark into a display
|
|
84
|
+
* view. Label/task prefer the session run entry (present on a same-session
|
|
85
|
+
* reload), then the background agent record; when neither exists (a fresh
|
|
86
|
+
* process after a foreground crash) the entry's own durable summary is the
|
|
87
|
+
* fallback, and only a pre-enrichment entry lacks one entirely.
|
|
88
|
+
*/
|
|
89
|
+
export function collectInterruptedRuns(deps: CollectInterruptedDeps): InterruptedRunView[] {
|
|
90
|
+
const views: InterruptedRunView[] = [];
|
|
91
|
+
for (const entry of deps.listInflight()) {
|
|
92
|
+
if (!entry.interruptedAt) continue;
|
|
93
|
+
const run = deps.findRun(entry.id);
|
|
94
|
+
// Fix B (item 8): a THROWING agent-store read degrades to the
|
|
95
|
+
// missing-agent path (null) exactly like an absent record. Enrichment is
|
|
96
|
+
// best-effort — a bad single read must not take down the whole recovery
|
|
97
|
+
// surface — and the durable registry still yields the row (id + age).
|
|
98
|
+
let agent: ReturnType<CollectInterruptedDeps["getAgent"]> = null;
|
|
99
|
+
try {
|
|
100
|
+
agent = deps.getAgent(entry.id);
|
|
101
|
+
} catch {
|
|
102
|
+
agent = null;
|
|
103
|
+
}
|
|
104
|
+
views.push({
|
|
105
|
+
id: entry.id,
|
|
106
|
+
kind: entry.kind,
|
|
107
|
+
dispatchId: entry.dispatchId,
|
|
108
|
+
attempt: entry.attempt,
|
|
109
|
+
label: run?.label ?? agent?.description ?? entry.label,
|
|
110
|
+
task: run?.task ?? agent?.task ?? entry.task,
|
|
111
|
+
model: run?.model ?? agent?.model ?? entry.model,
|
|
112
|
+
thinkingLevel: run?.thinkingLevel ?? agent?.thinkingLevel ?? entry.thinkingLevel,
|
|
113
|
+
originalParams: entry.originalParams,
|
|
114
|
+
startedAt: entry.startedAt,
|
|
115
|
+
interruptedAt: entry.interruptedAt,
|
|
116
|
+
// The registry entry is the durable dispatch snapshot; the session
|
|
117
|
+
// record is the fallback for entries written before U3 added the field.
|
|
118
|
+
recoveryPosture: runRecoveryPosture({
|
|
119
|
+
recoveryPosture: entry.recoveryPosture ?? run?.recoveryPosture,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return views;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Keep only the runs whose snapshotted posture is "offer". */
|
|
127
|
+
export function offeredInterruptedRuns(views: InterruptedRunView[]): InterruptedRunView[] {
|
|
128
|
+
return views.filter((v) => v.recoveryPosture === "offer");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Visibility bridge (Fix A)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the synthetic session-visible record for a durable interrupted run the
|
|
137
|
+
* current session has never seen. Every field a session-sourced surface reads
|
|
138
|
+
* (`status`/`interruptedAt`/`label`/`task`/`model`/`thinkingLevel`) comes from
|
|
139
|
+
* the durable view, and `originalParams` restores the dispatch snapshot the
|
|
140
|
+
* registry persisted at dispatch (falling back to the pre-enrichment subset
|
|
141
|
+
* `{model, thinkingLevel, background?}` for old entries) so a `retryRunId`
|
|
142
|
+
* re-dispatch keeps the recorded shape — including `force` (decision
|
|
143
|
+
* 2026-10-11: full parity with a same-session retry). The record is NEVER
|
|
144
|
+
* persisted — it exists only inside `visibleRunEntries`' returned list.
|
|
145
|
+
*/
|
|
146
|
+
function interruptedViewToRun(view: InterruptedRunView): SubagentRun {
|
|
147
|
+
const originalParams: NonNullable<SubagentRun["originalParams"]> =
|
|
148
|
+
view.originalParams ?? {
|
|
149
|
+
model: view.model,
|
|
150
|
+
thinkingLevel: view.thinkingLevel,
|
|
151
|
+
...(view.kind === "background" ? { background: true } : {}),
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
id: view.id,
|
|
155
|
+
task: view.task ?? "",
|
|
156
|
+
...(view.label ? { label: view.label } : {}),
|
|
157
|
+
// Issue #329: carry the recorded identity only when present — never an
|
|
158
|
+
// `undefined`-valued property, so a pre-enrichment entry stays identical
|
|
159
|
+
// to the pre-fix synthetic and `newDispatchIdentity` still mints fresh.
|
|
160
|
+
...(view.dispatchId !== undefined ? { dispatchId: view.dispatchId } : {}),
|
|
161
|
+
...(view.attempt !== undefined ? { attempt: view.attempt } : {}),
|
|
162
|
+
status: "running",
|
|
163
|
+
model: view.model?.trim() || "unknown",
|
|
164
|
+
thinkingLevel: view.thinkingLevel?.trim() || "unknown",
|
|
165
|
+
startedAt: view.startedAt,
|
|
166
|
+
interruptedAt: view.interruptedAt,
|
|
167
|
+
recoveryPosture: view.recoveryPosture,
|
|
168
|
+
originalParams,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* THE recovery visibility bridge (Fix A). Session-sourced run entries UNIONED
|
|
174
|
+
* with one synthetic record per durable interrupted run the session has no
|
|
175
|
+
* terminal or already-interrupted representation for — the genuine fresh-boot
|
|
176
|
+
* case, where the crash's record lives ONLY in the durable registry + agent
|
|
177
|
+
* record and the current session has no entry at all.
|
|
178
|
+
*
|
|
179
|
+
* Deduped by id and terminal-preferred: when the session already resolves the
|
|
180
|
+
* id to a terminal entry (a settled/abandoned run) or a marked entry (a
|
|
181
|
+
* re-loaded session's interrupted clone), NO synthetic is added, so the bridge
|
|
182
|
+
* is idempotent and cannot stack rows across reads. The session list is
|
|
183
|
+
* returned by reference when nothing is added, so callers that never hit the
|
|
184
|
+
* fresh-boot case behave byte-for-byte as before.
|
|
185
|
+
*
|
|
186
|
+
* When synthetics ARE added (issue #330) the union is returned ordered
|
|
187
|
+
* NEWEST-FIRST by `startedAt` (the shared `sortNewestFirst` helper), so a
|
|
188
|
+
* bridged row lands in its RECENCY position rather than being appended last.
|
|
189
|
+
*
|
|
190
|
+
* This is the ONE bridge every session-sourced recovery surface routes
|
|
191
|
+
* through (Run History, the retry menu, the abandon lookup, `retryRunId`
|
|
192
|
+
* resolution), so all four agree by construction.
|
|
193
|
+
*/
|
|
194
|
+
export function visibleRunEntries(
|
|
195
|
+
sessionEntries: SubagentRun[],
|
|
196
|
+
deps: CollectInterruptedDeps,
|
|
197
|
+
): SubagentRun[] {
|
|
198
|
+
const synthetic: SubagentRun[] = [];
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
for (const view of collectInterruptedRuns(deps)) {
|
|
201
|
+
if (seen.has(view.id)) continue;
|
|
202
|
+
seen.add(view.id);
|
|
203
|
+
const resolved = deps.findRun(view.id);
|
|
204
|
+
// A terminal or already-interrupted session entry is authoritative —
|
|
205
|
+
// never augment, or a retried-then-settled run would show a second row.
|
|
206
|
+
if (resolved && (resolved.status !== "running" || isInterruptedRun(resolved))) continue;
|
|
207
|
+
synthetic.push(interruptedViewToRun(view));
|
|
208
|
+
}
|
|
209
|
+
return synthetic.length === 0
|
|
210
|
+
? sessionEntries
|
|
211
|
+
: sortNewestFirst([...sessionEntries, ...synthetic]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The agent-record lookup the bridge needs (structurally the BackgroundAgent subset). */
|
|
215
|
+
export type RecoveryAgentLookup = (
|
|
216
|
+
id: string,
|
|
217
|
+
) => { description?: string; task?: string; model?: string; thinkingLevel?: string } | null;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The bridge bound to its production deps: the durable registry reader and the
|
|
221
|
+
* terminal-preferred session lookup, with the same `sessionEntries` array the
|
|
222
|
+
* caller passes. ONE wiring for the TUI and the extension, so a call site
|
|
223
|
+
* cannot accidentally read the registry while resolving session entries from a
|
|
224
|
+
* different list. The pure core (`visibleRunEntries`) stays deps-injected for
|
|
225
|
+
* unit tests.
|
|
226
|
+
*/
|
|
227
|
+
export function visibleRunEntriesForSession(
|
|
228
|
+
sessionEntries: SubagentRun[],
|
|
229
|
+
getAgent: RecoveryAgentLookup,
|
|
230
|
+
): SubagentRun[] {
|
|
231
|
+
return visibleRunEntries(sessionEntries, {
|
|
232
|
+
listInflight: () => listInflightRuns(),
|
|
233
|
+
getAgent,
|
|
234
|
+
findRun: (id) => resolveTerminalRunEntry(sessionEntries, id),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Notice
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export interface RecoveryNoticePayload {
|
|
243
|
+
customType: typeof RECOVERY_NOTICE_CUSTOM_TYPE;
|
|
244
|
+
content: string;
|
|
245
|
+
display: true;
|
|
246
|
+
details: { count: number; ids: string[] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The boot notice's delivery discipline: `nextTurn` ALWAYS queues to the
|
|
251
|
+
* pending-next-turn buffer and can never steer or arrive mid-turn — the exact
|
|
252
|
+
* opposite of `resolveDelivery`'s failed/stopped "steer". `triggerTurn` is kept
|
|
253
|
+
* for shape parity (the SDK ignores it for `nextTurn`).
|
|
254
|
+
*/
|
|
255
|
+
export function resolveRecoveryNoticeDelivery(): DeliveryResolution {
|
|
256
|
+
return { deliverAs: "nextTurn", triggerTurn: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Compact age label ("30s", "5m", "3h", "2d"); "unknown" for a bad timestamp. */
|
|
260
|
+
export function formatRecoveryAge(ms: number): string {
|
|
261
|
+
if (!Number.isFinite(ms) || ms < 0) return "unknown";
|
|
262
|
+
const seconds = Math.floor(ms / 1000);
|
|
263
|
+
if (seconds < 60) return `${seconds}s`;
|
|
264
|
+
const minutes = Math.floor(seconds / 60);
|
|
265
|
+
if (minutes < 60) return `${minutes}m`;
|
|
266
|
+
const hours = Math.floor(minutes / 60);
|
|
267
|
+
if (hours < 24) return `${hours}h`;
|
|
268
|
+
return `${Math.floor(hours / 24)}d`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shortId(id: string): string {
|
|
272
|
+
return id.slice(0, 8);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function taskExcerpt(task: string | undefined): string {
|
|
276
|
+
const trimmed = task?.replace(/\s+/g, " ").trim();
|
|
277
|
+
if (!trimmed) return "(task unavailable)";
|
|
278
|
+
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* PURE builder — the ONE turn-boundary boot notice for a non-empty list of
|
|
283
|
+
* interrupted runs. `now` is injected for deterministic tests; the age is
|
|
284
|
+
* measured from the recorded `interruptedAt` (how long the run has been
|
|
285
|
+
* interrupted). The caller must never invoke this with an empty list.
|
|
286
|
+
*/
|
|
287
|
+
export function buildRecoveryNotice(
|
|
288
|
+
items: InterruptedRunView[],
|
|
289
|
+
now: Date = new Date(),
|
|
290
|
+
): RecoveryNoticePayload {
|
|
291
|
+
const nowMs = now.getTime();
|
|
292
|
+
const lines = items.map((item) => {
|
|
293
|
+
const label = item.label?.trim() || "(unlabeled)";
|
|
294
|
+
const age = formatRecoveryAge(nowMs - Date.parse(item.interruptedAt));
|
|
295
|
+
return ` \u2022 ${shortId(item.id)} \u00b7 ${label} \u00b7 ${taskExcerpt(item.task)} \u00b7 interrupted ${age} ago`;
|
|
296
|
+
});
|
|
297
|
+
const content =
|
|
298
|
+
`Interrupted subagent run${items.length === 1 ? "" : "s"} detected (${items.length}):\n\n` +
|
|
299
|
+
`${lines.join("\n")}\n\n` +
|
|
300
|
+
`Review with /brl-subagent history, re-dispatch with retryRunId, or abandon via /brl-subagent retry. ` +
|
|
301
|
+
`A row showing "(task unavailable)" is a registry entry written before durable task capture — ` +
|
|
302
|
+
`pass \`task\` explicitly on the retryRunId call.`;
|
|
303
|
+
return {
|
|
304
|
+
customType: RECOVERY_NOTICE_CUSTOM_TYPE,
|
|
305
|
+
content,
|
|
306
|
+
display: true,
|
|
307
|
+
details: { count: items.length, ids: items.map((i) => i.id) },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Abandon
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export interface AbandonDeps {
|
|
316
|
+
/** Terminal-preferring run lookup (session store). */
|
|
317
|
+
getRun(id: string): SubagentRun | undefined;
|
|
318
|
+
/** Persist the terminal run record. */
|
|
319
|
+
writeRun(run: SubagentRun): void;
|
|
320
|
+
/** Mark the background agent record stopped; an absent record is a no-op. */
|
|
321
|
+
markAgentStopped(id: string): void;
|
|
322
|
+
/** Remove the durable registry entry (the U1 interruption mark). */
|
|
323
|
+
retireRegistry(id: string): void;
|
|
324
|
+
/** Prune the run's U2 session directory (no-op when absent). */
|
|
325
|
+
pruneSession(id: string): void;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Finalize ONE interrupted run as the existing terminal `stopped` outcome:
|
|
330
|
+
* the run record is written terminal with `stopReason`/`errorCategory`
|
|
331
|
+
* "aborted" (the run store's representation of a stopped outcome — see the
|
|
332
|
+
* `SubagentRun.status` union), the background agent record is flipped to
|
|
333
|
+
* `stopped`, the registry entry is retired, and the U2 session dir is pruned.
|
|
334
|
+
*
|
|
335
|
+
* Idempotent: after the first call the run lookup returns the terminal record,
|
|
336
|
+
* whose `isInterruptedRun` is false, so a repeat is a no-op. Safe when the run
|
|
337
|
+
* is absent, when it is not interrupted, and when no session dir exists.
|
|
338
|
+
*
|
|
339
|
+
* Returns true when THIS call abandoned the run.
|
|
340
|
+
*/
|
|
341
|
+
export function abandonInterruptedRun(
|
|
342
|
+
id: string,
|
|
343
|
+
deps: AbandonDeps,
|
|
344
|
+
now: Date = new Date(),
|
|
345
|
+
): boolean {
|
|
346
|
+
const run = deps.getRun(id);
|
|
347
|
+
if (!run || !isInterruptedRun(run)) return false;
|
|
348
|
+
|
|
349
|
+
const finishedAt = now.toISOString();
|
|
350
|
+
const startedMs = Date.parse(run.startedAt);
|
|
351
|
+
const durationMs = Number.isFinite(startedMs) ? now.getTime() - startedMs : undefined;
|
|
352
|
+
const terminal: SubagentRun = {
|
|
353
|
+
...run,
|
|
354
|
+
status: "failed",
|
|
355
|
+
stopReason: "aborted",
|
|
356
|
+
errorCategory: "aborted",
|
|
357
|
+
errorMessage: SUBAGENT_ABANDONED_MESSAGE,
|
|
358
|
+
finishedAt,
|
|
359
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
360
|
+
};
|
|
361
|
+
deps.writeRun(terminal);
|
|
362
|
+
// Fix B (item 1): an abandoned run must NOT wake the conductor. The
|
|
363
|
+
// production `markAgentStopped` flips the agent record and emits
|
|
364
|
+
// `subagent:stopped` — the same terminal event a normal stop raises — and the
|
|
365
|
+
// completion wake's gate is `claimTerminalNotice`. Claim the id HERE, before
|
|
366
|
+
// the mark, so on a fresh boot (where the shared claim set is empty and
|
|
367
|
+
// #315's dedupe has nothing to suppress) the wake loses the race. Normal
|
|
368
|
+
// stops/marks never pre-claim, so they keep waking exactly as before.
|
|
369
|
+
claimTerminalNotice(id);
|
|
370
|
+
deps.markAgentStopped(id);
|
|
371
|
+
deps.retireRegistry(id);
|
|
372
|
+
deps.pruneSession(id);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
package/src/recovery.ts
CHANGED
|
@@ -137,7 +137,25 @@ export async function runBootScan(options: BootScanOptions): Promise<BootScanSum
|
|
|
137
137
|
const allReaps = planned.flatMap(({ plan }) =>
|
|
138
138
|
plan.decision === "mark" || plan.decision === "reap" ? plan.reap : [],
|
|
139
139
|
);
|
|
140
|
-
|
|
140
|
+
// #299 review Major 1: resolve each scanned pid back to the marker that
|
|
141
|
+
// verified it, so the signal-time guard can re-read that marker before every
|
|
142
|
+
// signal. First writer wins (a pid found by two records is still one identity).
|
|
143
|
+
const markerByPid = new Map<number, string>();
|
|
144
|
+
for (const { record, plan } of planned) {
|
|
145
|
+
if (plan.decision !== "mark" && plan.decision !== "reap") continue;
|
|
146
|
+
if (!record.childMarker) continue;
|
|
147
|
+
for (const pid of plan.reap) if (!markerByPid.has(pid)) markerByPid.set(pid, record.childMarker);
|
|
148
|
+
}
|
|
149
|
+
const survived = new Set(
|
|
150
|
+
await reapPids(allReaps, deps, (pid) => markerByPid.get(pid), (pid, signal, phase) =>
|
|
151
|
+
options.log?.warn("Recovery: skipped a marker signal (pid no longer carries the run marker)", {
|
|
152
|
+
pid,
|
|
153
|
+
signal,
|
|
154
|
+
phase,
|
|
155
|
+
marker: markerByPid.get(pid),
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
141
159
|
|
|
142
160
|
for (const { record, plan } of planned) {
|
|
143
161
|
if (plan.decision === "mark") {
|
package/src/run-registry.ts
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
import * as fs from "node:fs";
|
|
33
33
|
import * as path from "node:path";
|
|
34
34
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
|
-
import type { ProcessOwner, SubagentRun } from "./types";
|
|
36
|
-
import { CUSTOM_ENTRY_TYPES } from "./types";
|
|
35
|
+
import type { ProcessOwner, RecoveryMode, SubagentRun } from "./types";
|
|
36
|
+
import { CUSTOM_ENTRY_TYPES, RECOVERY_MODES } from "./types";
|
|
37
37
|
import { assertSafeAgentId } from "./sanitize";
|
|
38
38
|
|
|
39
39
|
/** Which execution path owns an in-flight run (drives the boot scan's mark). */
|
|
@@ -47,6 +47,22 @@ export interface InflightRun {
|
|
|
47
47
|
kind: InflightRunKind;
|
|
48
48
|
owner?: ProcessOwner;
|
|
49
49
|
childMarker?: string;
|
|
50
|
+
// Option B U3 (D4): the recovery posture at dispatch, mirrored from the run
|
|
51
|
+
// record so the durable mark and the boot notice agree. Additive (absent ⇒
|
|
52
|
+
// "offer" on read).
|
|
53
|
+
recoveryPosture?: RecoveryMode;
|
|
54
|
+
// Foreground task persistence (O3): the run summary the U3 bridge restores
|
|
55
|
+
// after a crash + fresh boot, when neither a session run entry nor a
|
|
56
|
+
// background agent record exists. Additive — an entry written before this
|
|
57
|
+
// lacks them and reads as absent (the notice's "(task unavailable)" path).
|
|
58
|
+
task?: string;
|
|
59
|
+
label?: string;
|
|
60
|
+
model?: string;
|
|
61
|
+
thinkingLevel?: string;
|
|
62
|
+
// The exact `originalParams` snapshot `snapshotOriginalParams` put on the
|
|
63
|
+
// run record, so a post-crash retry restores the same 17-field shape a
|
|
64
|
+
// same-session retry does (including `force`; decision 2026-10-11).
|
|
65
|
+
originalParams?: SubagentRun["originalParams"];
|
|
50
66
|
startedAt: string;
|
|
51
67
|
cwd: string;
|
|
52
68
|
interruptedAt?: string;
|
|
@@ -60,6 +76,12 @@ export interface InflightRunInput {
|
|
|
60
76
|
kind: InflightRunKind;
|
|
61
77
|
owner?: ProcessOwner;
|
|
62
78
|
childMarker?: string;
|
|
79
|
+
recoveryPosture?: RecoveryMode;
|
|
80
|
+
task?: string;
|
|
81
|
+
label?: string;
|
|
82
|
+
model?: string;
|
|
83
|
+
thinkingLevel?: string;
|
|
84
|
+
originalParams?: SubagentRun["originalParams"];
|
|
63
85
|
startedAt: string;
|
|
64
86
|
cwd?: string;
|
|
65
87
|
}
|
|
@@ -112,14 +134,41 @@ function isInflightRun(value: unknown): value is InflightRun {
|
|
|
112
134
|
if (!value || typeof value !== "object") return false;
|
|
113
135
|
const v = value as Record<string, unknown>;
|
|
114
136
|
if (typeof v.id !== "string" || typeof v.startedAt !== "string") return false;
|
|
137
|
+
if (v.recoveryPosture !== undefined) {
|
|
138
|
+
if (!RECOVERY_MODES.includes(v.recoveryPosture as RecoveryMode)) return false;
|
|
139
|
+
}
|
|
115
140
|
return v.kind === "foreground" || v.kind === "unit" || v.kind === "background";
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Normalize the additive enrichment fields leniently. A wrong-typed field
|
|
145
|
+
* degrades to ABSENT on the parsed entry; the entry itself is never rejected
|
|
146
|
+
* wholesale — dropping it would lose the interruption mark. This is the
|
|
147
|
+
* deliberate opposite trade from the strict `recoveryPosture` check above
|
|
148
|
+
* (a bad posture is semantically safe to drop; a bad task is not).
|
|
149
|
+
*/
|
|
150
|
+
function normalizeEnrichment(value: InflightRun, raw: Record<string, unknown>): InflightRun {
|
|
151
|
+
const entry: InflightRun = { ...value };
|
|
152
|
+
for (const field of ["task", "label", "model", "thinkingLevel"] as const) {
|
|
153
|
+
if (typeof raw[field] === "string") entry[field] = raw[field] as string;
|
|
154
|
+
else delete entry[field];
|
|
155
|
+
}
|
|
156
|
+
if (raw.originalParams && typeof raw.originalParams === "object" && !Array.isArray(raw.originalParams)) {
|
|
157
|
+
entry.originalParams = raw.originalParams as SubagentRun["originalParams"];
|
|
158
|
+
} else {
|
|
159
|
+
delete entry.originalParams;
|
|
160
|
+
}
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
|
|
118
164
|
/** Parse one entry file; null on missing/corrupt/malformed. */
|
|
119
165
|
function readEntry(file: string): InflightRun | null {
|
|
120
166
|
try {
|
|
121
167
|
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
122
|
-
|
|
168
|
+
if (!isInflightRun(parsed)) return null;
|
|
169
|
+
// `isInflightRun` narrows to InflightRun, but the raw object may carry
|
|
170
|
+
// extra/mistyped additive fields — read them through a record view.
|
|
171
|
+
return normalizeEnrichment(parsed, parsed as unknown as Record<string, unknown>);
|
|
123
172
|
} catch {
|
|
124
173
|
return null;
|
|
125
174
|
}
|
|
@@ -143,10 +192,16 @@ function mergeInput(base: InflightRun, input: InflightRunInput): InflightRun {
|
|
|
143
192
|
const next: InflightRun = { ...base, id: input.id, kind: input.kind };
|
|
144
193
|
if (input.dispatchId !== undefined) next.dispatchId = input.dispatchId;
|
|
145
194
|
if (input.attempt !== undefined) next.attempt = input.attempt;
|
|
195
|
+
if (input.recoveryPosture !== undefined) next.recoveryPosture = input.recoveryPosture;
|
|
146
196
|
if (input.owner !== undefined) next.owner = input.owner;
|
|
147
197
|
if (input.childMarker !== undefined) next.childMarker = input.childMarker;
|
|
148
198
|
if (input.startedAt) next.startedAt = input.startedAt;
|
|
149
199
|
if (input.cwd !== undefined) next.cwd = input.cwd;
|
|
200
|
+
if (input.task !== undefined) next.task = input.task;
|
|
201
|
+
if (input.label !== undefined) next.label = input.label;
|
|
202
|
+
if (input.model !== undefined) next.model = input.model;
|
|
203
|
+
if (input.thinkingLevel !== undefined) next.thinkingLevel = input.thinkingLevel;
|
|
204
|
+
if (input.originalParams !== undefined) next.originalParams = input.originalParams;
|
|
150
205
|
return next;
|
|
151
206
|
}
|
|
152
207
|
|
|
@@ -243,8 +298,14 @@ function toRegistryInput(run: SubagentRun, kind: InflightRunKind): InflightRunIn
|
|
|
243
298
|
dispatchId: run.dispatchId,
|
|
244
299
|
attempt: run.attempt,
|
|
245
300
|
kind,
|
|
301
|
+
recoveryPosture: run.recoveryPosture,
|
|
246
302
|
owner: run.owner,
|
|
247
303
|
childMarker: run.childMarker,
|
|
304
|
+
task: run.task,
|
|
305
|
+
label: run.label,
|
|
306
|
+
model: run.model,
|
|
307
|
+
thinkingLevel: run.thinkingLevel,
|
|
308
|
+
originalParams: run.originalParams,
|
|
248
309
|
startedAt: run.startedAt,
|
|
249
310
|
cwd: process.cwd(),
|
|
250
311
|
};
|