brl-subagent 2.4.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.
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  DEFAULT_PRIORITY,
52
52
  type GitMode,
53
53
  type BackgroundAgent,
54
+ type RecoveryMode,
54
55
  } from "./types";
55
56
  import { validateGraph, topologicalSort } from "./scheduler";
56
57
  import { resolveTemplate, loadAllTemplates, loadBuiltinTemplates, validateTemplatePresetRefs, extractParamNames } from "./templates";
@@ -58,13 +59,14 @@ import { pkgPath } from "./paths";
58
59
 
59
60
  import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
60
61
  import {
61
- getCurrentBranch,
62
62
  hasUncommittedChanges,
63
63
  createWorkBranch,
64
64
  captureDiff,
65
- switchToBranch,
65
+ getHeadState,
66
+ restoreHead,
66
67
  deleteBranch,
67
68
  mergeWorkBranch,
69
+ type HeadState,
68
70
  } from "./git";
69
71
  import { preflightCheck } from "./preflight";
70
72
  import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
@@ -73,11 +75,18 @@ import { modelIsAvailable } from "./model-availability";
73
75
  import { validatePreTask, diagnoseFailure, DEFAULT_BACKGROUND_DEADLINE_MS } from "./validate";
74
76
  import { findUnknownParams, KNOWN_DELEGATE_KEYS, resolveSubagentParams, snapshotOriginalParams } from "./params";
75
77
  import { delegateTaskParamsSchema } from "./schema";
76
- import { createSessionState } from "./state";
78
+ import { createSessionState, resolveTerminalRunEntry } from "./state";
77
79
  import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
78
80
  import { buildSubagentPrompt, describePromptMode } from "./prompt";
79
- import { runSubagent, cleanupTempDirs } from "./runner";
81
+ import { runSubagent, cleanupTempDirs, reapActiveChildren } from "./runner";
82
+ import {
83
+ recoverProduction,
84
+ currentProcessOwner,
85
+ newDispatchIdentity,
86
+ } from "./recovery";
87
+ import { markInterrupted, listInflightRuns, clearInflightRun } from "./run-registry";
80
88
  import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
89
+ import { transcriptDisplayPath } from "./transcript-path";
81
90
  import {
82
91
  finalizeRunRecord,
83
92
  resolveRetryParams,
@@ -95,6 +104,7 @@ import {
95
104
  showApprovalModeSelector,
96
105
  showApprovalDialog,
97
106
  showCompletionNotifySelector,
107
+ showRecoverySelector,
98
108
  showPresetManager,
99
109
  showTemplateManager,
100
110
  showSLAConfig,
@@ -109,13 +119,22 @@ import {
109
119
  renderDelegateResult,
110
120
  } from "./tui";
111
121
  import { createLogger, setLogCwd, type Logger } from "./logging";
122
+ import {
123
+ collectInterruptedRuns,
124
+ offeredInterruptedRuns,
125
+ buildRecoveryNotice,
126
+ resolveRecoveryNoticeDelivery,
127
+ abandonInterruptedRun,
128
+ visibleRunEntriesForSession,
129
+ type RecoveryAgentLookup,
130
+ } from "./recovery-surface";
112
131
  import { Intercom } from "./messaging";
113
132
  import * as eventBus from "./event-bus";
114
133
  import {
115
134
  buildCompletionMessage,
116
135
  resolveDelivery,
117
136
  sendCompletionNotification,
118
- markTerminalSeen,
137
+ claimTerminalNotice,
119
138
  resolveRunEntry,
120
139
  } from "./notify-completion";
121
140
 
@@ -189,6 +208,12 @@ export default function (pi: ExtensionAPI) {
189
208
  // receives no ctx, so capture it in session_start where it is available.
190
209
  let sessionCtx: ExtensionContext | undefined;
191
210
 
211
+ // Option B U3 (D2): "one recovery notice per boot". The boot scan may be
212
+ // followed by more session_starts in the same process (reload); without this
213
+ // guard every one would re-list the same interrupted runs. Reset on shutdown
214
+ // so a genuinely new session notifies again.
215
+ let recoveryNoticeDelivered = false;
216
+
192
217
  // -------------------------------------------------------------------
193
218
  // Config change callback
194
219
  // -------------------------------------------------------------------
@@ -699,8 +724,8 @@ export default function (pi: ExtensionAPI) {
699
724
  // so sweepStaleLiveSubagents finds a 'running' record for every
700
725
  // foreground live entry (the #130 invariant gap that swept
701
726
  // graph/chain entries immediately).
702
- const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
703
- state.persistRun(pi, run);
727
+ const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name, state.config.recovery);
728
+ state.persistRun(pi, run, "unit");
704
729
 
705
730
  // R2: Prune old run entries if history exceeds limit
706
731
  pruneHistoryIfNeeded(state, ctx, log);
@@ -740,6 +765,9 @@ export default function (pi: ExtensionAPI) {
740
765
  getFinalOutput,
741
766
  log,
742
767
  currentDepth + 1,
768
+ undefined, // intercom
769
+ undefined, // subagentId
770
+ run.childMarker,
743
771
  );
744
772
  } catch (err) {
745
773
  // Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
@@ -1151,8 +1179,8 @@ export default function (pi: ExtensionAPI) {
1151
1179
  // Issue #119: per-subtask run entry — created at spawn so the
1152
1180
  // monitor drill-in shows per-unit priority and a post-hoc audit
1153
1181
  // trail exists per subtask (mirrors single-mode run persistence).
1154
- const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
1155
- state.persistRun(pi, run);
1182
+ const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name, state.config.recovery);
1183
+ state.persistRun(pi, run, "unit");
1156
1184
 
1157
1185
  // R2: Prune old run entries if history exceeds limit
1158
1186
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1210,6 +1238,7 @@ export default function (pi: ExtensionAPI) {
1210
1238
  currentDepth + 1,
1211
1239
  intercom,
1212
1240
  subagentId,
1241
+ run.childMarker,
1213
1242
  );
1214
1243
 
1215
1244
  // Fill SubTaskResult
@@ -1787,8 +1816,8 @@ export default function (pi: ExtensionAPI) {
1787
1816
  // so sweepStaleLiveSubagents finds a 'running' record for every
1788
1817
  // foreground live entry (the #130 invariant gap that swept
1789
1818
  // graph/chain entries immediately).
1790
- const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
1791
- state.persistRun(pi, run);
1819
+ const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name, state.config.recovery);
1820
+ state.persistRun(pi, run, "unit");
1792
1821
 
1793
1822
  // R2: Prune old run entries if history exceeds limit
1794
1823
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1825,6 +1854,7 @@ export default function (pi: ExtensionAPI) {
1825
1854
  currentDepth + 1,
1826
1855
  intercom,
1827
1856
  subagentId,
1857
+ run.childMarker,
1828
1858
  );
1829
1859
 
1830
1860
  const subTaskResult: SubTaskResult = {
@@ -2197,6 +2227,7 @@ export default function (pi: ExtensionAPI) {
2197
2227
  preset: globalParams.resolvedPreset?.name,
2198
2228
  }),
2199
2229
  sanitizeCwd: merged.effectiveCwd,
2230
+ recoveryPosture: state.config.recovery,
2200
2231
  });
2201
2232
  } catch (err) {
2202
2233
  const message = sanitizeErrorMessage(
@@ -2263,6 +2294,12 @@ export default function (pi: ExtensionAPI) {
2263
2294
  originalParams?: SubagentRun["originalParams"];
2264
2295
  /** cwd used to redact paths from crash notifications. */
2265
2296
  sanitizeCwd: string;
2297
+ // Option B U1: durability identity threaded onto the background records.
2298
+ dispatchId?: string;
2299
+ resumeOf?: string;
2300
+ attempt?: number;
2301
+ // Option B U3 (D4): recovery posture snapshot for the background run.
2302
+ recoveryPosture?: RecoveryMode;
2266
2303
  },
2267
2304
  ): Promise<BackgroundAgent> {
2268
2305
  const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
@@ -2289,8 +2326,28 @@ export default function (pi: ExtensionAPI) {
2289
2326
  // Issue #98/#108: snapshotOriginalParams is the single source of truth
2290
2327
  // for the caller params a later retry restores.
2291
2328
  originalParams: spawn.originalParams,
2329
+ // Option B U1: durability identity for the agent record + run entry.
2330
+ dispatchId: spawn.dispatchId,
2331
+ resumeOf: spawn.resumeOf,
2332
+ attempt: spawn.attempt,
2333
+ // Option B U3 (D4): recovery posture snapshot for the background run.
2334
+ recoveryPosture: spawn.recoveryPosture,
2292
2335
  });
2293
2336
 
2337
+ // Issue #318 (from #316 review): the two poller crash sites share one
2338
+ // claimed notification envelope. Content is the only variable; the
2339
+ // claim guard, display, details and deliverAs live here once.
2340
+ const sendCrashNotice = (content: string): void => {
2341
+ if (claimTerminalNotice(agent.id)) {
2342
+ pi.sendMessage({
2343
+ customType: "subagent-notification",
2344
+ content,
2345
+ display: true,
2346
+ details: { agentId: agent.id }
2347
+ }, { deliverAs: "followUp" });
2348
+ }
2349
+ };
2350
+
2294
2351
  // Register for live monitor
2295
2352
  state.registerLiveSubagent(agent.id, {
2296
2353
  id: agent.id,
@@ -2338,12 +2395,7 @@ export default function (pi: ExtensionAPI) {
2338
2395
  }
2339
2396
  state.failedSubagents++;
2340
2397
  updateProgressStatus(state, ctx);
2341
- pi.sendMessage({
2342
- customType: "subagent-notification",
2343
- content: `Background agent "${agent.description}" crashed.`,
2344
- display: true,
2345
- details: { agentId: agent.id }
2346
- }, { deliverAs: "followUp" });
2398
+ sendCrashNotice(`Background agent "${agent.description}" crashed.`);
2347
2399
  return;
2348
2400
  }
2349
2401
 
@@ -2390,12 +2442,10 @@ export default function (pi: ExtensionAPI) {
2390
2442
  if (agent.status === 'failed') {
2391
2443
  state.failedSubagents++;
2392
2444
  updateProgressStatus(state, ctx);
2393
- pi.sendMessage({
2394
- customType: "subagent-notification",
2395
- content: `Background agent "${agent.description}" failed.`,
2396
- display: true,
2397
- details: { agentId: agent.id }
2398
- }, { deliverAs: "followUp" });
2445
+ // Issue #315: the terminal wake (`subagent-completion`, emitted by
2446
+ // the session-manager settle) is the ONE notice for a failed run.
2447
+ // The status here was set by that settle, so the wake already
2448
+ // delivered — this legacy poller notice would duplicate it.
2399
2449
  } else if (agent.status === 'stopped') {
2400
2450
  // User-initiated stop (stop_subagent) or deadline abort
2401
2451
  // (timeout/deadline) — not a failure.
@@ -2423,12 +2473,7 @@ export default function (pi: ExtensionAPI) {
2423
2473
  }
2424
2474
  state.failedSubagents++;
2425
2475
  updateProgressStatus(state, ctx);
2426
- pi.sendMessage({
2427
- customType: "subagent-notification",
2428
- content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, spawn.sanitizeCwd)}`,
2429
- display: true,
2430
- details: { agentId: agent.id }
2431
- }, { deliverAs: "followUp" });
2476
+ sendCrashNotice(`Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, spawn.sanitizeCwd)}`);
2432
2477
  }
2433
2478
  }
2434
2479
  }, 2000);
@@ -2522,6 +2567,10 @@ export default function (pi: ExtensionAPI) {
2522
2567
  priority?: string;
2523
2568
  },
2524
2569
  singleTask: string,
2570
+ // Option B U1: the spawn entry a retryRunId resolved to, so a retried
2571
+ // background run inherits dispatchId/resumeOf/attempt (record identity,
2572
+ // not a delegate param — D4).
2573
+ retrySourceRun?: SubagentRun,
2525
2574
  ): Promise<ToolResult<undefined>> {
2526
2575
  // Phase 6.5: Background execution — spawn session and return ID immediately.
2527
2576
  // Resolve the preset and its model BEFORE the background branch so the
@@ -2602,6 +2651,8 @@ export default function (pi: ExtensionAPI) {
2602
2651
  bgResolvedPreset?.promptGuideline,
2603
2652
  );
2604
2653
 
2654
+ // Option B U1: durability identity (retry inherits the original dispatch).
2655
+ const bgIdentity = newDispatchIdentity(retrySourceRun);
2605
2656
  const agent = await startBackgroundAgent(pi, ctx, {
2606
2657
  task: singleTask,
2607
2658
  type: params.preset || 'general-purpose',
@@ -2616,8 +2667,18 @@ export default function (pi: ExtensionAPI) {
2616
2667
  gitMode: bgResolved.resolvedGitMode,
2617
2668
  originalParams: snapshotOriginalParams(params),
2618
2669
  sanitizeCwd: bgResolved.effectiveCwd,
2670
+ dispatchId: bgIdentity.dispatchId,
2671
+ resumeOf: bgIdentity.resumeOf,
2672
+ attempt: bgIdentity.attempt,
2673
+ recoveryPosture: state.config.recovery,
2619
2674
  });
2620
2675
 
2676
+ // Retire-on-retry (O3 fold, decision 2026-10-11): the source interrupted
2677
+ // registry entry is superseded once the new background run is actually
2678
+ // spawned (a throwing spawn is caught below and leaves the source intact
2679
+ // and retryable).
2680
+ if (retrySourceRun) clearInflightRun(retrySourceRun.id);
2681
+
2621
2682
  // B2: Surface auto-route decisions in background spawn result too —
2622
2683
  // preset name + matched-keyword evidence + restriction, derived from
2623
2684
  // the RESOLVED toolOptions (reflects per-call overrides).
@@ -2662,7 +2723,7 @@ export default function (pi: ExtensionAPI) {
2662
2723
  description: "Configure subagent model and thinking level",
2663
2724
  getArgumentCompletions: (prefix: string) => {
2664
2725
  const options = [
2665
- "history", "historyentries", "monitor", "dashboard", "preset", "templates", "retry", "completionnotify",
2726
+ "history", "historyentries", "monitor", "dashboard", "preset", "templates", "retry", "completionnotify", "recovery",
2666
2727
  ];
2667
2728
  const filtered = options.filter((o) => o.startsWith(prefix));
2668
2729
  return filtered.length > 0
@@ -2679,6 +2740,7 @@ export default function (pi: ExtensionAPI) {
2679
2740
  depth: () => showDepthInput(ctx, state, applyConfig),
2680
2741
  approval: () => showApprovalModeSelector(ctx, state, applyConfig),
2681
2742
  completionnotify: () => showCompletionNotifySelector(ctx, state, applyConfig),
2743
+ recovery: () => showRecoverySelector(ctx, state, applyConfig),
2682
2744
  costlimit: () => showCostLimitInput(ctx, state, applyConfig),
2683
2745
  reset: () => resetState(ctx),
2684
2746
  history: () => showRunHistory(ctx, state, () => state.persistState(pi)),
@@ -2687,7 +2749,7 @@ export default function (pi: ExtensionAPI) {
2687
2749
  dashboard: () => showDashboard(ctx, state),
2688
2750
  preset: () => showPresetManager(ctx, state),
2689
2751
  templates: () => showTemplateManager(ctx, state),
2690
- retry: () => showRetryMenu(ctx, state),
2752
+ retry: () => showRetryMenu(ctx, state, (id) => abandonProductionRun(ctx, id)),
2691
2753
  sla: () => showSLAConfig(ctx, state, applyConfig),
2692
2754
  "sla-stats": () => showSLAStats(ctx, state),
2693
2755
  // E5: the compliance report menu (file access / secrets exposure /
@@ -2787,6 +2849,10 @@ export default function (pi: ExtensionAPI) {
2787
2849
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
2788
2850
  ctx: ExtensionContext,
2789
2851
  ) {
2852
+ // Option B U1: the run a retryRunId resolves to (spawn entry). Internal
2853
+ // record identity — dispatchId/resumeOf/attempt are threaded onto the new
2854
+ // record, NOT through the delegate-param surface (D4).
2855
+ let retrySourceRun: SubagentRun | undefined;
2790
2856
  // Issue #99: warn on unknown params — typebox allows additional properties
2791
2857
  // by default (pi's validateToolArguments passes unknown keys through), so a
2792
2858
  // typo like `thinkinglevel` or a schema-drifted param is otherwise silently
@@ -2888,8 +2954,20 @@ export default function (pi: ExtensionAPI) {
2888
2954
  // resolving templates first keeps a combined template + retryRunId call
2889
2955
  // from silently disabling template resolution (issue #175 review).
2890
2956
  if (params.retryRunId) {
2891
- const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
2957
+ // Fix A: prefer the session spawn entry (it carries `originalParams`);
2958
+ // a run that exists ONLY in the durable registry after a fresh boot
2959
+ // resolves through the visibility bridge instead of being rejected as
2960
+ // not found — the id the boot notice names must resolve here.
2961
+ let runEntry = state.findSpawnRunById(ctx, params.retryRunId);
2962
+ if (!runEntry) {
2963
+ const { getAgent } = await import("./session-manager");
2964
+ runEntry = resolveTerminalRunEntry(
2965
+ visibleRunsFor(ctx, getAgent),
2966
+ params.retryRunId,
2967
+ );
2968
+ }
2892
2969
  if (runEntry) {
2970
+ retrySourceRun = runEntry;
2893
2971
  params = resolveRetryParams(params, runEntry) as DelegateTaskParams;
2894
2972
  } else {
2895
2973
  // Issue #98: a silent no-op here made retries of background runs
@@ -3009,7 +3087,7 @@ export default function (pi: ExtensionAPI) {
3009
3087
  const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
3010
3088
 
3011
3089
  if (params.background) {
3012
- return spawnBackgroundRun(pi, ctx, params, singleTask);
3090
+ return spawnBackgroundRun(pi, ctx, params, singleTask, retrySourceRun);
3013
3091
  }
3014
3092
 
3015
3093
  // R5: Check session cost limit before spawning
@@ -3156,6 +3234,10 @@ export default function (pi: ExtensionAPI) {
3156
3234
 
3157
3235
  // Create run record
3158
3236
  const runId = crypto.randomUUID();
3237
+ // Option B U1: durability identity — a retry inherits the original
3238
+ // dispatchId and records resumeOf/attempt; a fresh marker keys the
3239
+ // child process for boot-time orphan discovery.
3240
+ const identity = newDispatchIdentity(retrySourceRun);
3159
3241
  const run: SubagentRun = {
3160
3242
  id: runId,
3161
3243
  task,
@@ -3168,8 +3250,15 @@ export default function (pi: ExtensionAPI) {
3168
3250
  priority: params.priority,
3169
3251
  startedAt: new Date().toISOString(),
3170
3252
  originalParams: snapshotOriginalParams(params),
3253
+ dispatchId: identity.dispatchId,
3254
+ resumeOf: identity.resumeOf,
3255
+ attempt: identity.attempt,
3256
+ owner: currentProcessOwner(),
3257
+ childMarker: crypto.randomUUID(),
3258
+ // U3 (D4): the session recovery posture at dispatch.
3259
+ recoveryPosture: state.config.recovery,
3171
3260
  };
3172
- state.persistRun(pi, run);
3261
+ state.persistRun(pi, run, "foreground");
3173
3262
 
3174
3263
  // R2: Prune old run entries if history exceeds limit
3175
3264
  pruneHistoryIfNeeded(state, ctx, log);
@@ -3178,11 +3267,11 @@ export default function (pi: ExtensionAPI) {
3178
3267
  registerLiveRun(state, run, ctx);
3179
3268
 
3180
3269
  // P3: Git integration — set up work branch if gitMode is "branch"
3181
- let originalBranch: string | undefined;
3270
+ let headState: HeadState | undefined;
3182
3271
  let workBranchName: string | undefined;
3183
3272
  if (resolvedGitMode === "branch") {
3184
3273
  try {
3185
- originalBranch = getCurrentBranch(resolvedCwd);
3274
+ headState = getHeadState(resolvedCwd);
3186
3275
 
3187
3276
  if (hasUncommittedChanges(resolvedCwd)) {
3188
3277
  log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
@@ -3190,40 +3279,56 @@ export default function (pi: ExtensionAPI) {
3190
3279
  });
3191
3280
  }
3192
3281
 
3193
- const branchResult = createWorkBranch(resolvedCwd, originalBranch);
3282
+ const branchResult = createWorkBranch(resolvedCwd, headState.sha);
3194
3283
  if (branchResult.ok) {
3195
3284
  workBranchName = branchResult.branch;
3196
3285
  log.info("Created work branch for subagent", {
3197
3286
  branch: workBranchName,
3198
- base: originalBranch,
3287
+ base: headState.branch,
3199
3288
  });
3200
3289
  } else {
3201
3290
  log.error("Failed to create work branch, falling back to 'none'", {
3202
3291
  error: branchResult.error,
3203
3292
  });
3204
- originalBranch = undefined;
3293
+ headState = undefined;
3205
3294
  }
3206
3295
  } catch (err) {
3207
3296
  log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
3208
3297
  error: (err as Error).message,
3209
3298
  });
3210
- originalBranch = undefined;
3299
+ headState = undefined;
3211
3300
  workBranchName = undefined;
3212
3301
  }
3213
3302
  }
3214
3303
 
3215
- // Helper to switch back to original branch and optionally delete work branch
3304
+ // Helper to restore the starting HEAD and delete the work branch. The
3305
+ // delete is NOT gated on the restore succeeding: the branch must never
3306
+ // survive teardown. A still-checked-out branch simply makes deleteBranch
3307
+ // fail (logged) rather than silently leaking the ref.
3216
3308
  const cleanupGitBranch = () => {
3217
- if (workBranchName && originalBranch) {
3218
- try {
3219
- switchToBranch(resolvedCwd, originalBranch);
3220
- log.info("Switched back to original branch", { branch: originalBranch });
3221
- // Attempt to delete the work branch (non-critical)
3222
- deleteBranch(resolvedCwd, workBranchName);
3223
- } catch {
3224
- // Non-fatal: best-effort cleanup
3309
+ if (!workBranchName) return;
3310
+ if (headState) {
3311
+ const restoreResult = restoreHead(resolvedCwd, headState);
3312
+ if (restoreResult.ok) {
3313
+ log.info("Restored original HEAD", {
3314
+ branch: headState.branch,
3315
+ detached: headState.detached,
3316
+ });
3317
+ } else {
3318
+ log.warn("Failed to restore original HEAD during cleanup", {
3319
+ branch: headState.branch,
3320
+ error: restoreResult.error,
3321
+ });
3225
3322
  }
3226
3323
  }
3324
+ // Attempt to delete the work branch (non-critical)
3325
+ const deleteResult = deleteBranch(resolvedCwd, workBranchName);
3326
+ if (!deleteResult.ok) {
3327
+ log.warn("Failed to delete work branch during cleanup", {
3328
+ branch: workBranchName,
3329
+ error: deleteResult.error,
3330
+ });
3331
+ }
3227
3332
  };
3228
3333
 
3229
3334
  // R1: Circuit breaker check — reject if circuit is open
@@ -3262,6 +3367,17 @@ export default function (pi: ExtensionAPI) {
3262
3367
 
3263
3368
  let success = false;
3264
3369
 
3370
+ // Retire-on-retry (O3 fold, decision 2026-10-11): the retry has passed
3371
+ // every pre-spawn gate and is about to start, so the SOURCE interrupted
3372
+ // registry entry is superseded — clear it here so the old run is not
3373
+ // re-offered at every subsequent boot. A failed pre-start (circuit open,
3374
+ // slot cancelled) returns ABOVE this point and leaves the source intact
3375
+ // and retryable — but note it is NOT "one row only": the pre-existing
3376
+ // pre-start path (`persistRun`, which precedes the circuit/slot gates)
3377
+ // still leaves a dangling `running` NEW row behind until the backlogged
3378
+ // dangling-row fix. `retrySourceRun` is set only on a `retryRunId` call.
3379
+ if (retrySourceRun) clearInflightRun(retrySourceRun.id);
3380
+
3265
3381
  // F7 (issue #65): import BEFORE the try — the crash-path catch calls
3266
3382
  // completeTranscript, and block-scoped consts declared inside the try
3267
3383
  // are invisible in its catch (pre-fix, a crash here threw
@@ -3321,6 +3437,7 @@ export default function (pi: ExtensionAPI) {
3321
3437
  childDepth,
3322
3438
  undefined, // intercom
3323
3439
  undefined, // subagentId
3440
+ run.childMarker,
3324
3441
  );
3325
3442
 
3326
3443
  // Auto-retry on timeout
@@ -3368,6 +3485,7 @@ export default function (pi: ExtensionAPI) {
3368
3485
  childDepth,
3369
3486
  undefined, // intercom
3370
3487
  undefined, // subagentId
3488
+ run.childMarker,
3371
3489
  );
3372
3490
  }
3373
3491
 
@@ -3380,17 +3498,23 @@ export default function (pi: ExtensionAPI) {
3380
3498
  result.label = label;
3381
3499
 
3382
3500
  // P3: Capture git diff if we created a work branch
3383
- if (workBranchName && originalBranch) {
3384
- const diffResult = captureDiff(resolvedCwd, originalBranch);
3501
+ if (workBranchName && headState) {
3502
+ // Diff against the captured starting sha rather than the branch
3503
+ // name: a detached start's branch label is the "HEAD" pseudo-ref,
3504
+ // for which `git diff HEAD...HEAD` is always empty.
3505
+ const diffResult = captureDiff(resolvedCwd, headState.sha);
3385
3506
  if (diffResult.ok) {
3386
3507
  result.gitBranch = workBranchName;
3387
3508
  result.gitDiff = diffResult.diff;
3388
3509
  }
3389
3510
 
3390
- // Switch back to the original branch
3391
- const switchResult = switchToBranch(resolvedCwd, originalBranch);
3392
- if (switchResult.ok) {
3393
- log.info("Switched back to original branch", { branch: originalBranch });
3511
+ // Restore the starting HEAD (branch, or detached sha)
3512
+ const restoreResult = restoreHead(resolvedCwd, headState);
3513
+ if (restoreResult.ok) {
3514
+ log.info("Restored original HEAD", {
3515
+ branch: headState.branch,
3516
+ detached: headState.detached,
3517
+ });
3394
3518
 
3395
3519
  // P4: Change approval workflow — let user review the diff
3396
3520
  const diffContent = (result.gitDiff ?? "").trim();
@@ -3434,13 +3558,42 @@ export default function (pi: ExtensionAPI) {
3434
3558
  error: mergeResult.error,
3435
3559
  });
3436
3560
  }
3437
- deleteBranch(resolvedCwd, workBranchName);
3561
+ // #308: a DETACHED start has no branch of its own to hold the
3562
+ // merge result — teardown re-detached at the captured sha and
3563
+ // the merge fast-forwarded THAT detached HEAD, so deleting the
3564
+ // work branch would leave the committed work reachable only
3565
+ // from the detached HEAD (silently lost on the next checkout).
3566
+ // Keep the branch as the durable ref and log where the work
3567
+ // lives. An ATTACHED start merged onto its own branch, which
3568
+ // already retains the commit, so the temporary work branch is
3569
+ // safe to drop.
3570
+ if (headState.detached) {
3571
+ log.info("detached checkout: work preserved on branch", {
3572
+ branch: workBranchName,
3573
+ });
3574
+ } else {
3575
+ deleteBranch(resolvedCwd, workBranchName);
3576
+ }
3438
3577
  workBranchName = undefined;
3439
3578
  } else {
3440
3579
  // No changes — just delete the empty branch
3441
3580
  deleteBranch(resolvedCwd, workBranchName);
3442
3581
  workBranchName = undefined;
3443
3582
  }
3583
+ } else {
3584
+ log.warn("Failed to restore original HEAD after work-branch run", {
3585
+ branch: headState.branch,
3586
+ error: restoreResult.error,
3587
+ });
3588
+ }
3589
+
3590
+ // Nothing may leave teardown with the work branch still present: the
3591
+ // approval flow above clears workBranchName on every terminal choice
3592
+ // (including "apply", where keeping the branch is intentional), so a
3593
+ // still-set name means the restore failed and the flow was skipped.
3594
+ if (workBranchName) {
3595
+ deleteBranch(resolvedCwd, workBranchName);
3596
+ workBranchName = undefined;
3444
3597
  }
3445
3598
  }
3446
3599
 
@@ -3704,8 +3857,7 @@ export default function (pi: ExtensionAPI) {
3704
3857
  }
3705
3858
 
3706
3859
  // Include transcript path
3707
- const { getTranscriptPath } = await import('./session-manager');
3708
- resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
3860
+ resultText += `\nTranscript: ${transcriptDisplayPath(params.agent_id)}`;
3709
3861
 
3710
3862
  return {
3711
3863
  content: [{ type: "text" as const, text: resultText }],
@@ -3824,6 +3976,128 @@ export default function (pi: ExtensionAPI) {
3824
3976
  // Session lifecycle — F7: Session-bound state initialization
3825
3977
  // -------------------------------------------------------------------
3826
3978
 
3979
+ // -------------------------------------------------------------------
3980
+ // Option B U1: boot recovery — identity/liveness detection + child reap
3981
+ // -------------------------------------------------------------------
3982
+ // Runs AFTER restoreFromSession so config is ready. Candidate runs come from
3983
+ // the DURABLE registry (`.pi/run-registry/`), NOT from this session's run
3984
+ // entries: a crashed session's foreground/unit records are invisible to a
3985
+ // default fresh startup, and the full `.pi/subagents/` store is too costly to
3986
+ // parse (only the one agent record a background entry names is read). For
3987
+ // each in-flight run whose owner is dead, its orphaned marker-verified child
3988
+ // is reaped and the record is marked: the registry entry always, plus the
3989
+ // agent record for background runs. The scan itself is SILENT in the UI —
3990
+ // U3's `maybeSendRecoveryNotice` (below) is the one notice surface.
3991
+ // Platforms without /proc skip the whole scan (one log line):
3992
+ // pid-only liveness cannot rule out pid reuse, so nothing is killed or
3993
+ // marked unverified.
3994
+ async function performBootRecovery(): Promise<void> {
3995
+ try {
3996
+ const { getAgent, markAgentInterrupted } = await import("./session-manager");
3997
+ // `recoverProduction` owns the /proc platform guard (nothing is marked or
3998
+ // killed without start-token verification) — see src/recovery.ts.
3999
+ const summary = await recoverProduction({
4000
+ log,
4001
+ // Read THAT ONE agent record by id — never the whole store.
4002
+ readAgentRecord: (id) => getAgent(id),
4003
+ mark(record, interruptedAt) {
4004
+ // Registry entry = the durable mark for every kind. Issue #304: its
4005
+ // boolean is the durable-write result — do NOT drop it, or the boot
4006
+ // summary would claim a mark that never landed. The scan counts a
4007
+ // `false` as `markFailures` and warns with the id.
4008
+ const durable = markInterrupted(record.id, interruptedAt);
4009
+ if (record.kind === "agent") markAgentInterrupted(record.id, interruptedAt);
4010
+ return durable;
4011
+ },
4012
+ });
4013
+ // Report the scan whenever it did anything durable OR attempted a mark
4014
+ // that failed — a failure-only boot must not be silent (issue #304).
4015
+ if (summary.marked > 0 || summary.markFailures > 0) {
4016
+ log.warn("Boot recovery marked interrupted runs", { ...summary });
4017
+ }
4018
+ } catch (err) {
4019
+ // Recovery is best-effort hygiene — it must never break session start.
4020
+ log.warn("Boot recovery scan failed", { error: (err as Error).message });
4021
+ }
4022
+ }
4023
+
4024
+ /**
4025
+ * Option B U3 Fix A: the recovery visibility bridge, wired for the two
4026
+ * extension consumers that resolve a run id (the retryRunId branch and the
4027
+ * abandon action). Session run entries UNIONED with synthetic durable
4028
+ * interrupted records the session has never seen (a genuine fresh boot), so
4029
+ * an id named by the boot notice resolves here instead of being rejected as
4030
+ * not found. `getAgent` is injected because the callers already load it
4031
+ * dynamically — this keeps the registry/agent reads in ONE place.
4032
+ */
4033
+ function visibleRunsFor(
4034
+ ctx: ExtensionContext,
4035
+ getAgent: RecoveryAgentLookup,
4036
+ ): SubagentRun[] {
4037
+ return visibleRunEntriesForSession(state.getRunEntries(ctx), getAgent);
4038
+ }
4039
+
4040
+ /**
4041
+ * Option B U3 (D2): after the boot scan, send ONE notice at the next turn
4042
+ * boundary listing every unresolved interrupted run whose dispatch snapshot
4043
+ * (and the session posture) says "offer". Delivery is `nextTurn` — the notice
4044
+ * can never steer or arrive mid-turn (see `resolveRecoveryNoticeDelivery`).
4045
+ * Silent when there is nothing to offer; at most once per session start.
4046
+ */
4047
+ async function maybeSendRecoveryNotice(ctx: ExtensionContext): Promise<void> {
4048
+ if (recoveryNoticeDelivered) return;
4049
+ recoveryNoticeDelivered = true;
4050
+ // Session posture off silences the whole surface, regardless of per-run
4051
+ // snapshots (a user opting out expects quiet).
4052
+ if (state.config.recovery === "off") return;
4053
+ try {
4054
+ const { getAgent } = await import("./session-manager");
4055
+ const offered = offeredInterruptedRuns(
4056
+ collectInterruptedRuns({
4057
+ listInflight: () => listInflightRuns(),
4058
+ getAgent: (id) => getAgent(id),
4059
+ findRun: (id) => state.findTerminalRunById(ctx, id),
4060
+ }),
4061
+ );
4062
+ if (offered.length === 0) return;
4063
+ pi.sendMessage(buildRecoveryNotice(offered), resolveRecoveryNoticeDelivery());
4064
+ log.info("Recovery notice delivered", { count: offered.length });
4065
+ } catch (err) {
4066
+ // The notice is best-effort — it must never break session start.
4067
+ log.warn("Recovery notice failed", { error: (err as Error).message });
4068
+ }
4069
+ }
4070
+
4071
+ /**
4072
+ * Option B U3 (D5): finalize ONE interrupted run as the existing terminal
4073
+ * `stopped` outcome and prune its U2 session dir. Wired through injected deps
4074
+ * so the pure core (`recovery-surface.ts`) stays testable; the run record is
4075
+ * written terminal (the run store's `status` union has no `stopped` — a
4076
+ * stopped outcome is represented as `failed` + `errorCategory: "aborted"`,
4077
+ * exactly as every other stop path does) and the background agent record is
4078
+ * flipped to `stopped`.
4079
+ */
4080
+ async function abandonProductionRun(ctx: ExtensionContext, id: string): Promise<void> {
4081
+ const { updateAgentStatus, pruneBackgroundSession, getAgent } = await import("./session-manager");
4082
+ // Fix A: the abandon lookup routes through the visibility bridge, so a run
4083
+ // that exists ONLY in the durable registry after a fresh boot is
4084
+ // abandonable instead of resolving to undefined (the silent `false`).
4085
+ const visible = visibleRunsFor(ctx, getAgent);
4086
+ abandonInterruptedRun(id, {
4087
+ getRun: (runId) => resolveTerminalRunEntry(visible, runId),
4088
+ writeRun: (run) => {
4089
+ state.persistRun(pi, run);
4090
+ pruneHistoryIfNeeded(state, ctx, log);
4091
+ },
4092
+ markAgentStopped: (agentId) => {
4093
+ const agent = updateAgentStatus(agentId, "stopped");
4094
+ if (agent) log.info("Abandoned background agent record", { agentId });
4095
+ },
4096
+ retireRegistry: (runId) => clearInflightRun(runId),
4097
+ pruneSession: (runId) => pruneBackgroundSession(runId),
4098
+ });
4099
+ }
4100
+
3827
4101
  pi.on("session_start", async (_event, ctx) => {
3828
4102
  // Issue #147: capture the session context for the completion-push subscriber.
3829
4103
  sessionCtx = ctx;
@@ -3874,6 +4148,14 @@ export default function (pi: ExtensionAPI) {
3874
4148
  // F5/F9: Safe state restoration with type guards
3875
4149
  state.restoreFromSession(ctx);
3876
4150
 
4151
+ // Option B U1: reap a dead conductor's orphaned children and mark the
4152
+ // records interrupted BEFORE anything else touches their worktrees.
4153
+ await performBootRecovery();
4154
+
4155
+ // Option B U3 (D2): after the scan, surface every unresolved interrupted
4156
+ // run ONCE, at the next turn boundary (never mid-turn).
4157
+ await maybeSendRecoveryNotice(ctx);
4158
+
3877
4159
  updateStatus(state, ctx);
3878
4160
  });
3879
4161
 
@@ -3883,6 +4165,14 @@ export default function (pi: ExtensionAPI) {
3883
4165
  activeSubagents: state.activeSubagents,
3884
4166
  });
3885
4167
 
4168
+ // Option B U1: reap in-flight foreground children first, so a clean exit
4169
+ // never orphans a subprocess (the crash case is covered by boot recovery).
4170
+ try {
4171
+ await reapActiveChildren(log);
4172
+ } catch (err) {
4173
+ log.warn("Shutdown reap failed", { error: (err as Error).message });
4174
+ }
4175
+
3886
4176
  // Clear all live subagent sessions
3887
4177
  state.subagentSessions.clear();
3888
4178
  // Drop pending finalize claims too (PR #71 review: the claim set must
@@ -3896,6 +4186,8 @@ export default function (pi: ExtensionAPI) {
3896
4186
  state.unseenSubagents = 0;
3897
4187
  // Clear pending queue
3898
4188
  state.pendingQueue.length = 0;
4189
+ // U3: a new session boot may notify again.
4190
+ recoveryNoticeDelivered = false;
3899
4191
  });
3900
4192
 
3901
4193
  // -------------------------------------------------------------------
@@ -3905,9 +4197,10 @@ export default function (pi: ExtensionAPI) {
3905
4197
  // subagent:failed / subagent:stopped), so the conductor is woken via
3906
4198
  // pi.sendMessage + triggerTurn when a background run reaches a terminal state.
3907
4199
  // Delivery is always-on (minimum nextTurn); the completionNotify knob controls
3908
- // the WAKE (D1+D2 — see notify-completion.ts). The dedupe set defends the
3909
- // pathological double-emit path (first terminal event per id wins); it is
3910
- // capped simply (cleared when it exceeds 200 entries).
4200
+ // the WAKE (D1+D2 — see notify-completion.ts). The shared terminal claim
4201
+ // (issue #315) defends the pathological double-emit path (first terminal
4202
+ // event or crash notice per id wins); it is capped simply (cleared when it
4203
+ // exceeds 200 entries).
3911
4204
  //
3912
4205
  // Stopped-run degradation — ACCEPTED BY DESIGN (review #2, adjudicated
3913
4206
  // 2026-09-01): subagent:stopped fires at stop time (updateAgentStatus) BEFORE
@@ -3916,7 +4209,6 @@ export default function (pi: ExtensionAPI) {
3916
4209
  // resolveRunEntry returns may be the pre-finalize spawn entry. The wake is
3917
4210
  // the point; details are best-effort — the notification must never throw or
3918
4211
  // block on their absence.
3919
- const terminalSeen = new Set<string>();
3920
4212
  const terminalTypes = ["subagent:completed", "subagent:failed", "subagent:stopped"] as const;
3921
4213
  for (const type of terminalTypes) {
3922
4214
  eventBus.on(type, (event) => {
@@ -3926,7 +4218,7 @@ export default function (pi: ExtensionAPI) {
3926
4218
 
3927
4219
  async function deliverCompletionAlert(event: SubagentEvent): Promise<void> {
3928
4220
  const id = event.agentId;
3929
- if (!markTerminalSeen(terminalSeen, id)) return;
4221
+ if (!claimTerminalNotice(id)) return;
3930
4222
  const ctx = sessionCtx;
3931
4223
  if (!ctx) return;
3932
4224
  try {