brl-subagent 2.5.0 → 2.5.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.
@@ -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
- const survived = new Set(await reapPids(allReaps, deps));
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") {
@@ -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
- return isInflightRun(parsed) ? parsed : null;
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
  };