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/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +77 -10
- package/src/index.ts +355 -63
- package/src/kill-escalation.ts +426 -0
- package/src/logging.ts +37 -3
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/preflight.ts +29 -14
- package/src/proc.ts +390 -0
- package/src/recovery-engine.ts +348 -0
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +343 -0
- package/src/run-registry.ts +330 -0
- package/src/runner.ts +409 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +234 -29
- package/src/state.ts +81 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +323 -103
- package/src/types.ts +173 -1
- package/src/unit-run.ts +16 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3270
|
+
let headState: HeadState | undefined;
|
|
3182
3271
|
let workBranchName: string | undefined;
|
|
3183
3272
|
if (resolvedGitMode === "branch") {
|
|
3184
3273
|
try {
|
|
3185
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3299
|
+
headState = undefined;
|
|
3211
3300
|
workBranchName = undefined;
|
|
3212
3301
|
}
|
|
3213
3302
|
}
|
|
3214
3303
|
|
|
3215
|
-
// Helper to
|
|
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
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
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 &&
|
|
3384
|
-
|
|
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
|
-
//
|
|
3391
|
-
const
|
|
3392
|
-
if (
|
|
3393
|
-
log.info("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3909
|
-
// pathological double-emit path (first terminal
|
|
3910
|
-
// capped simply (cleared when it
|
|
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 (!
|
|
4221
|
+
if (!claimTerminalNotice(id)) return;
|
|
3930
4222
|
const ctx = sessionCtx;
|
|
3931
4223
|
if (!ctx) return;
|
|
3932
4224
|
try {
|