brl-subagent 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +5 -5
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +58 -1
- package/src/index.ts +184 -32
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +37 -3
- package/src/preflight.ts +29 -14
- package/src/proc.ts +140 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +206 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +106 -23
- package/src/state.ts +51 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui.ts +228 -88
- package/src/types.ts +107 -0
- package/src/unit-run.ts +8 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Multi-agent orchestration for [pi](https://github.com/earendil-works/pi) — chain, parallel, and dependency-graph delegation to isolated subagents with per-step model routing, preset-driven tool scoping, thinking-level control, and background execution with live monitoring, real abort, and per-agent timeouts.
|
|
4
4
|
|
|
5
|
-
**Version:** 2.
|
|
5
|
+
**Version:** 2.5.0 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
- **True background execution** — live monitor, real abort (`stop_subagent`), per-agent timeouts, and a 30-minute default deadline for runs that don't set one. Nothing orphans; nothing leaks.
|
|
15
15
|
- **Preset-driven tool scoping** — every subagent runs with exactly the tools its job needs, restricted by preset or per-call `tools`/`excludeTools`, with auto-route that picks the right preset when you don't.
|
|
16
16
|
- **Templates with slots** — saved, file-backed task templates with `${param}` placeholders for workflows you run again and again.
|
|
17
|
-
- **Safety by default** — task-fence injection protection, sanitized error paths, owner-only persistence, and a
|
|
17
|
+
- **Safety by default** — task-fence injection protection, sanitized error paths, owner-only persistence, and a 1300+ test suite pinning every contract against the real pi SDK.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brl-subagent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Subagent delegation for pi — blocking or background runs, chain/parallel/graph modes, presets and task templates, live monitor, and dispatch capability guards.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@earendil-works/pi-agent-core": "^1.
|
|
41
|
-
"@earendil-works/pi-ai": "^1.
|
|
42
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
43
|
-
"@earendil-works/pi-tui": "^1.
|
|
40
|
+
"@earendil-works/pi-agent-core": "^1.1.0",
|
|
41
|
+
"@earendil-works/pi-ai": "^1.1.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^1.1.0",
|
|
43
|
+
"@earendil-works/pi-tui": "^1.1.0",
|
|
44
44
|
"jiti": "^2.7.0",
|
|
45
45
|
"typebox": "^1.3.34",
|
|
46
46
|
"typescript": "7.0.2",
|
package/src/event-bus.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Purpose: In-memory lifecycle pub/sub (`subagent:*` events) for extensions and the completion wake.
|
|
2
2
|
import type { SubagentEvent, SubagentEventType, SubagentEventListener } from './types';
|
|
3
|
+
import { createLogger } from './logging';
|
|
4
|
+
|
|
5
|
+
// Issue #265: listener failures route through the logger (file-only by default)
|
|
6
|
+
// so raw console.error writes cannot corrupt the pi TUI.
|
|
7
|
+
const log = createLogger('event-bus');
|
|
3
8
|
|
|
4
9
|
// Event listeners by type
|
|
5
10
|
const listeners = new Map<SubagentEventType, Set<SubagentEventListener>>();
|
|
@@ -49,7 +54,7 @@ export function emit(event: SubagentEvent): void {
|
|
|
49
54
|
listener(event);
|
|
50
55
|
} catch (err) {
|
|
51
56
|
// Don't let listener errors break the event loop
|
|
52
|
-
|
|
57
|
+
log.error(`Listener error for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
}
|
|
@@ -59,7 +64,7 @@ export function emit(event: SubagentEvent): void {
|
|
|
59
64
|
try {
|
|
60
65
|
listener(event);
|
|
61
66
|
} catch (err) {
|
|
62
|
-
|
|
67
|
+
log.error('Global listener error', { error: err instanceof Error ? err.message : String(err) });
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
}
|
package/src/git.ts
CHANGED
|
@@ -17,6 +17,11 @@ function gitOpts(cwd: string) {
|
|
|
17
17
|
cwd,
|
|
18
18
|
encoding: "utf-8" as const,
|
|
19
19
|
timeout: 10_000,
|
|
20
|
+
// Capture child stderr instead of inheriting it: expected git failures
|
|
21
|
+
// (e.g. deleting a checked-out branch) are caught and returned as
|
|
22
|
+
// {ok:false, error}; execFileSync still puts the captured stderr in the
|
|
23
|
+
// thrown error's message, so real failures stay diagnosable.
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -33,6 +38,46 @@ export function getCurrentBranch(cwd: string): string {
|
|
|
33
38
|
return output.trim();
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The starting state of HEAD, captured at spawn so teardown can restore it.
|
|
43
|
+
*
|
|
44
|
+
* `getCurrentBranch` alone is NOT enough to restore a detached HEAD: it
|
|
45
|
+
* returns the "HEAD" pseudo-ref, so a `git checkout HEAD` teardown is a no-op
|
|
46
|
+
* that stays on the work branch (stranding it and leaking the branch in CI PR
|
|
47
|
+
* builds, which check out a detached HEAD). The sha + attached-ness must be
|
|
48
|
+
* captured together.
|
|
49
|
+
*
|
|
50
|
+
* `detached` also governs DATA SAFETY at teardown (#308): a detached start has
|
|
51
|
+
* no branch of its own to retain a merged work branch, so the auto-approve path
|
|
52
|
+
* MUST preserve the work branch as the durable ref rather than delete it.
|
|
53
|
+
*/
|
|
54
|
+
export interface HeadState {
|
|
55
|
+
/** Commit HEAD pointed at when captured. */
|
|
56
|
+
sha: string;
|
|
57
|
+
/** True when HEAD was detached (no symbolic ref) at capture time. */
|
|
58
|
+
detached: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Branch name when attached; the literal "HEAD" pseudo-ref when detached.
|
|
61
|
+
* Attached callers can pass it straight to createWorkBranch/captureDiff.
|
|
62
|
+
*/
|
|
63
|
+
branch: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Capture the current HEAD state (sha + attached-ness) for later restoration.
|
|
68
|
+
* Throws if the cwd is not a git repository.
|
|
69
|
+
*/
|
|
70
|
+
export function getHeadState(cwd: string): HeadState {
|
|
71
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], gitOpts(cwd)).trim();
|
|
72
|
+
try {
|
|
73
|
+
// Exits non-zero when HEAD is detached — that is the discriminator.
|
|
74
|
+
const branch = execFileSync("git", ["symbolic-ref", "--short", "-q", "HEAD"], gitOpts(cwd)).trim();
|
|
75
|
+
return { sha, detached: false, branch };
|
|
76
|
+
} catch {
|
|
77
|
+
return { sha, detached: true, branch: "HEAD" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
/**
|
|
37
82
|
* Resolve the repository root that contains the given directory.
|
|
38
83
|
* Returns undefined when the directory is not inside a repository, or when
|
|
@@ -105,6 +150,28 @@ export function switchToBranch(
|
|
|
105
150
|
}
|
|
106
151
|
}
|
|
107
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Restore HEAD to the state captured by getHeadState.
|
|
155
|
+
* Detached starts are re-detached at the captured sha (`git checkout
|
|
156
|
+
* --detach <sha>`) instead of switching to the useless "HEAD" pseudo-ref;
|
|
157
|
+
* attached starts switch back to the original branch.
|
|
158
|
+
* Returns { ok: true } on success, { ok: false, error } on failure.
|
|
159
|
+
*/
|
|
160
|
+
export function restoreHead(
|
|
161
|
+
cwd: string,
|
|
162
|
+
state: HeadState,
|
|
163
|
+
): { ok: true } | { ok: false; error: string } {
|
|
164
|
+
try {
|
|
165
|
+
const args = state.detached
|
|
166
|
+
? ["checkout", "--detach", state.sha]
|
|
167
|
+
: ["checkout", state.branch];
|
|
168
|
+
execFileSync("git", args, gitOpts(cwd));
|
|
169
|
+
return { ok: true };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { ok: false, error: (err as Error).message };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
108
175
|
/**
|
|
109
176
|
* Merge a work branch into the current branch.
|
|
110
177
|
* Uses --no-edit to prevent opening an editor.
|
package/src/history.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { SubagentRun, SubagentResult } from "./types";
|
|
10
|
-
import { isSubagentError, getFinalOutput, isSubagentRunShape, classifyError, coherentFailureReason, CUSTOM_ENTRY_TYPES, MAX_RUN_HISTORY_ENTRIES } from "./types";
|
|
10
|
+
import { isSubagentError, getFinalOutput, isSubagentRunShape, classifyError, coherentFailureReason, CUSTOM_ENTRY_TYPES, MAX_RUN_HISTORY_ENTRIES, DEFAULT_OUTPUT_CAP_BYTES } from "./types";
|
|
11
|
+
import { isOutputTruncated } from "./sanitize";
|
|
12
|
+
import { transcriptDisplayPath } from "./transcript-path";
|
|
11
13
|
import type { ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
@@ -200,3 +202,58 @@ export function formatRunDuration(ms: number): string {
|
|
|
200
202
|
const secs = Math.round((ms % 60_000) / 1000);
|
|
201
203
|
return `${min}m ${secs}s`;
|
|
202
204
|
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Full-output honesty lines (issue #261)
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Human-readable size for an output cap, e.g. `100 * 1024` → "100 KB".
|
|
212
|
+
* Byte caps are whole-KB by construction; sub-KB values (mostly tests) render
|
|
213
|
+
* as plain bytes.
|
|
214
|
+
*/
|
|
215
|
+
function formatCapSize(capBytes: number): string {
|
|
216
|
+
if (capBytes > 0 && capBytes % 1024 === 0) return `${capBytes / 1024} KB`;
|
|
217
|
+
return `${capBytes} B`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Honest disclosure lines for a settled run's full-output view (issue #261).
|
|
222
|
+
*
|
|
223
|
+
* Always returns the transcript pointer. When the stored output hit the
|
|
224
|
+
* `capBytes` cap it also adds a disclosure that points at the transcript for
|
|
225
|
+
* the rest. Detection is deliberately two-signal:
|
|
226
|
+
*
|
|
227
|
+
* 1. `isOutputTruncated(run.fullOutput)` — the exact `capOutput` notice, true
|
|
228
|
+
* only when the stored text was genuinely truncated;
|
|
229
|
+
* 2. `Buffer.byteLength` at/over `capBytes` — the length fallback, so an
|
|
230
|
+
* UNCAPPED record (background/crash paths store raw `liveOutput`) that
|
|
231
|
+
* already sits at or over the cap budget is still disclosed instead of
|
|
232
|
+
* pretending the panel can show more than the cap's worth.
|
|
233
|
+
*
|
|
234
|
+
* Boundary precision: `capOutput` truncates only when the byte length is
|
|
235
|
+
* STRICTLY greater than the cap, so an output of exactly `capBytes` takes the
|
|
236
|
+
* length branch and is disclosed as "at or over" without carrying the notice.
|
|
237
|
+
* The notice branch alone is exact for "was truncated".
|
|
238
|
+
*
|
|
239
|
+
* Pure and pi-tui-free, so it is unit-testable in isolation.
|
|
240
|
+
*/
|
|
241
|
+
export function buildOutputHonestyLines(
|
|
242
|
+
run: Pick<SubagentRun, "id" | "fullOutput">,
|
|
243
|
+
capBytes: number = DEFAULT_OUTPUT_CAP_BYTES,
|
|
244
|
+
): string[] {
|
|
245
|
+
const transcriptPath = transcriptDisplayPath(run.id);
|
|
246
|
+
const lines = [`Transcript: ${transcriptPath}`];
|
|
247
|
+
const output = run.fullOutput;
|
|
248
|
+
const size = formatCapSize(capBytes);
|
|
249
|
+
if (isOutputTruncated(output)) {
|
|
250
|
+
lines.push(
|
|
251
|
+
`Output was truncated at the ${size} cap — full text in the transcript: ${transcriptPath}`,
|
|
252
|
+
);
|
|
253
|
+
} else if (output && Buffer.byteLength(output, "utf8") >= capBytes) {
|
|
254
|
+
lines.push(
|
|
255
|
+
`Stored output is at or over the ${size} cap — full transcript: ${transcriptPath}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return lines;
|
|
259
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -58,13 +58,14 @@ import { pkgPath } from "./paths";
|
|
|
58
58
|
|
|
59
59
|
import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
|
|
60
60
|
import {
|
|
61
|
-
getCurrentBranch,
|
|
62
61
|
hasUncommittedChanges,
|
|
63
62
|
createWorkBranch,
|
|
64
63
|
captureDiff,
|
|
65
|
-
|
|
64
|
+
getHeadState,
|
|
65
|
+
restoreHead,
|
|
66
66
|
deleteBranch,
|
|
67
67
|
mergeWorkBranch,
|
|
68
|
+
type HeadState,
|
|
68
69
|
} from "./git";
|
|
69
70
|
import { preflightCheck } from "./preflight";
|
|
70
71
|
import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
|
|
@@ -76,8 +77,15 @@ import { delegateTaskParamsSchema } from "./schema";
|
|
|
76
77
|
import { createSessionState } from "./state";
|
|
77
78
|
import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
|
|
78
79
|
import { buildSubagentPrompt, describePromptMode } from "./prompt";
|
|
79
|
-
import { runSubagent, cleanupTempDirs } from "./runner";
|
|
80
|
+
import { runSubagent, cleanupTempDirs, reapActiveChildren } from "./runner";
|
|
81
|
+
import {
|
|
82
|
+
recoverProduction,
|
|
83
|
+
currentProcessOwner,
|
|
84
|
+
newDispatchIdentity,
|
|
85
|
+
} from "./recovery";
|
|
86
|
+
import { markInterrupted } from "./run-registry";
|
|
80
87
|
import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
|
|
88
|
+
import { transcriptDisplayPath } from "./transcript-path";
|
|
81
89
|
import {
|
|
82
90
|
finalizeRunRecord,
|
|
83
91
|
resolveRetryParams,
|
|
@@ -700,7 +708,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
700
708
|
// foreground live entry (the #130 invariant gap that swept
|
|
701
709
|
// graph/chain entries immediately).
|
|
702
710
|
const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
|
|
703
|
-
state.persistRun(pi, run);
|
|
711
|
+
state.persistRun(pi, run, "unit");
|
|
704
712
|
|
|
705
713
|
// R2: Prune old run entries if history exceeds limit
|
|
706
714
|
pruneHistoryIfNeeded(state, ctx, log);
|
|
@@ -740,6 +748,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
740
748
|
getFinalOutput,
|
|
741
749
|
log,
|
|
742
750
|
currentDepth + 1,
|
|
751
|
+
undefined, // intercom
|
|
752
|
+
undefined, // subagentId
|
|
753
|
+
run.childMarker,
|
|
743
754
|
);
|
|
744
755
|
} catch (err) {
|
|
745
756
|
// Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
|
|
@@ -1152,7 +1163,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1152
1163
|
// monitor drill-in shows per-unit priority and a post-hoc audit
|
|
1153
1164
|
// trail exists per subtask (mirrors single-mode run persistence).
|
|
1154
1165
|
const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
|
|
1155
|
-
state.persistRun(pi, run);
|
|
1166
|
+
state.persistRun(pi, run, "unit");
|
|
1156
1167
|
|
|
1157
1168
|
// R2: Prune old run entries if history exceeds limit
|
|
1158
1169
|
pruneHistoryIfNeeded(state, ctx, log);
|
|
@@ -1210,6 +1221,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1210
1221
|
currentDepth + 1,
|
|
1211
1222
|
intercom,
|
|
1212
1223
|
subagentId,
|
|
1224
|
+
run.childMarker,
|
|
1213
1225
|
);
|
|
1214
1226
|
|
|
1215
1227
|
// Fill SubTaskResult
|
|
@@ -1788,7 +1800,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1788
1800
|
// foreground live entry (the #130 invariant gap that swept
|
|
1789
1801
|
// graph/chain entries immediately).
|
|
1790
1802
|
const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
|
|
1791
|
-
state.persistRun(pi, run);
|
|
1803
|
+
state.persistRun(pi, run, "unit");
|
|
1792
1804
|
|
|
1793
1805
|
// R2: Prune old run entries if history exceeds limit
|
|
1794
1806
|
pruneHistoryIfNeeded(state, ctx, log);
|
|
@@ -1825,6 +1837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1825
1837
|
currentDepth + 1,
|
|
1826
1838
|
intercom,
|
|
1827
1839
|
subagentId,
|
|
1840
|
+
run.childMarker,
|
|
1828
1841
|
);
|
|
1829
1842
|
|
|
1830
1843
|
const subTaskResult: SubTaskResult = {
|
|
@@ -2263,6 +2276,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2263
2276
|
originalParams?: SubagentRun["originalParams"];
|
|
2264
2277
|
/** cwd used to redact paths from crash notifications. */
|
|
2265
2278
|
sanitizeCwd: string;
|
|
2279
|
+
// Option B U1: durability identity threaded onto the background records.
|
|
2280
|
+
dispatchId?: string;
|
|
2281
|
+
resumeOf?: string;
|
|
2282
|
+
attempt?: number;
|
|
2266
2283
|
},
|
|
2267
2284
|
): Promise<BackgroundAgent> {
|
|
2268
2285
|
const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
|
|
@@ -2289,6 +2306,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2289
2306
|
// Issue #98/#108: snapshotOriginalParams is the single source of truth
|
|
2290
2307
|
// for the caller params a later retry restores.
|
|
2291
2308
|
originalParams: spawn.originalParams,
|
|
2309
|
+
// Option B U1: durability identity for the agent record + run entry.
|
|
2310
|
+
dispatchId: spawn.dispatchId,
|
|
2311
|
+
resumeOf: spawn.resumeOf,
|
|
2312
|
+
attempt: spawn.attempt,
|
|
2292
2313
|
});
|
|
2293
2314
|
|
|
2294
2315
|
// Register for live monitor
|
|
@@ -2522,6 +2543,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2522
2543
|
priority?: string;
|
|
2523
2544
|
},
|
|
2524
2545
|
singleTask: string,
|
|
2546
|
+
// Option B U1: the spawn entry a retryRunId resolved to, so a retried
|
|
2547
|
+
// background run inherits dispatchId/resumeOf/attempt (record identity,
|
|
2548
|
+
// not a delegate param — D4).
|
|
2549
|
+
retrySourceRun?: SubagentRun,
|
|
2525
2550
|
): Promise<ToolResult<undefined>> {
|
|
2526
2551
|
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2527
2552
|
// Resolve the preset and its model BEFORE the background branch so the
|
|
@@ -2602,6 +2627,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2602
2627
|
bgResolvedPreset?.promptGuideline,
|
|
2603
2628
|
);
|
|
2604
2629
|
|
|
2630
|
+
// Option B U1: durability identity (retry inherits the original dispatch).
|
|
2631
|
+
const bgIdentity = newDispatchIdentity(retrySourceRun);
|
|
2605
2632
|
const agent = await startBackgroundAgent(pi, ctx, {
|
|
2606
2633
|
task: singleTask,
|
|
2607
2634
|
type: params.preset || 'general-purpose',
|
|
@@ -2616,6 +2643,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2616
2643
|
gitMode: bgResolved.resolvedGitMode,
|
|
2617
2644
|
originalParams: snapshotOriginalParams(params),
|
|
2618
2645
|
sanitizeCwd: bgResolved.effectiveCwd,
|
|
2646
|
+
dispatchId: bgIdentity.dispatchId,
|
|
2647
|
+
resumeOf: bgIdentity.resumeOf,
|
|
2648
|
+
attempt: bgIdentity.attempt,
|
|
2619
2649
|
});
|
|
2620
2650
|
|
|
2621
2651
|
// B2: Surface auto-route decisions in background spawn result too —
|
|
@@ -2787,6 +2817,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2787
2817
|
onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
|
|
2788
2818
|
ctx: ExtensionContext,
|
|
2789
2819
|
) {
|
|
2820
|
+
// Option B U1: the run a retryRunId resolves to (spawn entry). Internal
|
|
2821
|
+
// record identity — dispatchId/resumeOf/attempt are threaded onto the new
|
|
2822
|
+
// record, NOT through the delegate-param surface (D4).
|
|
2823
|
+
let retrySourceRun: SubagentRun | undefined;
|
|
2790
2824
|
// Issue #99: warn on unknown params — typebox allows additional properties
|
|
2791
2825
|
// by default (pi's validateToolArguments passes unknown keys through), so a
|
|
2792
2826
|
// typo like `thinkinglevel` or a schema-drifted param is otherwise silently
|
|
@@ -2890,6 +2924,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2890
2924
|
if (params.retryRunId) {
|
|
2891
2925
|
const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
|
|
2892
2926
|
if (runEntry) {
|
|
2927
|
+
retrySourceRun = runEntry;
|
|
2893
2928
|
params = resolveRetryParams(params, runEntry) as DelegateTaskParams;
|
|
2894
2929
|
} else {
|
|
2895
2930
|
// Issue #98: a silent no-op here made retries of background runs
|
|
@@ -3009,7 +3044,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3009
3044
|
const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
|
|
3010
3045
|
|
|
3011
3046
|
if (params.background) {
|
|
3012
|
-
return spawnBackgroundRun(pi, ctx, params, singleTask);
|
|
3047
|
+
return spawnBackgroundRun(pi, ctx, params, singleTask, retrySourceRun);
|
|
3013
3048
|
}
|
|
3014
3049
|
|
|
3015
3050
|
// R5: Check session cost limit before spawning
|
|
@@ -3156,6 +3191,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3156
3191
|
|
|
3157
3192
|
// Create run record
|
|
3158
3193
|
const runId = crypto.randomUUID();
|
|
3194
|
+
// Option B U1: durability identity — a retry inherits the original
|
|
3195
|
+
// dispatchId and records resumeOf/attempt; a fresh marker keys the
|
|
3196
|
+
// child process for boot-time orphan discovery.
|
|
3197
|
+
const identity = newDispatchIdentity(retrySourceRun);
|
|
3159
3198
|
const run: SubagentRun = {
|
|
3160
3199
|
id: runId,
|
|
3161
3200
|
task,
|
|
@@ -3168,8 +3207,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3168
3207
|
priority: params.priority,
|
|
3169
3208
|
startedAt: new Date().toISOString(),
|
|
3170
3209
|
originalParams: snapshotOriginalParams(params),
|
|
3210
|
+
dispatchId: identity.dispatchId,
|
|
3211
|
+
resumeOf: identity.resumeOf,
|
|
3212
|
+
attempt: identity.attempt,
|
|
3213
|
+
owner: currentProcessOwner(),
|
|
3214
|
+
childMarker: crypto.randomUUID(),
|
|
3171
3215
|
};
|
|
3172
|
-
state.persistRun(pi, run);
|
|
3216
|
+
state.persistRun(pi, run, "foreground");
|
|
3173
3217
|
|
|
3174
3218
|
// R2: Prune old run entries if history exceeds limit
|
|
3175
3219
|
pruneHistoryIfNeeded(state, ctx, log);
|
|
@@ -3178,11 +3222,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3178
3222
|
registerLiveRun(state, run, ctx);
|
|
3179
3223
|
|
|
3180
3224
|
// P3: Git integration — set up work branch if gitMode is "branch"
|
|
3181
|
-
let
|
|
3225
|
+
let headState: HeadState | undefined;
|
|
3182
3226
|
let workBranchName: string | undefined;
|
|
3183
3227
|
if (resolvedGitMode === "branch") {
|
|
3184
3228
|
try {
|
|
3185
|
-
|
|
3229
|
+
headState = getHeadState(resolvedCwd);
|
|
3186
3230
|
|
|
3187
3231
|
if (hasUncommittedChanges(resolvedCwd)) {
|
|
3188
3232
|
log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
|
|
@@ -3190,40 +3234,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
3190
3234
|
});
|
|
3191
3235
|
}
|
|
3192
3236
|
|
|
3193
|
-
const branchResult = createWorkBranch(resolvedCwd,
|
|
3237
|
+
const branchResult = createWorkBranch(resolvedCwd, headState.sha);
|
|
3194
3238
|
if (branchResult.ok) {
|
|
3195
3239
|
workBranchName = branchResult.branch;
|
|
3196
3240
|
log.info("Created work branch for subagent", {
|
|
3197
3241
|
branch: workBranchName,
|
|
3198
|
-
base:
|
|
3242
|
+
base: headState.branch,
|
|
3199
3243
|
});
|
|
3200
3244
|
} else {
|
|
3201
3245
|
log.error("Failed to create work branch, falling back to 'none'", {
|
|
3202
3246
|
error: branchResult.error,
|
|
3203
3247
|
});
|
|
3204
|
-
|
|
3248
|
+
headState = undefined;
|
|
3205
3249
|
}
|
|
3206
3250
|
} catch (err) {
|
|
3207
3251
|
log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
|
|
3208
3252
|
error: (err as Error).message,
|
|
3209
3253
|
});
|
|
3210
|
-
|
|
3254
|
+
headState = undefined;
|
|
3211
3255
|
workBranchName = undefined;
|
|
3212
3256
|
}
|
|
3213
3257
|
}
|
|
3214
3258
|
|
|
3215
|
-
// Helper to
|
|
3259
|
+
// Helper to restore the starting HEAD and delete the work branch. The
|
|
3260
|
+
// delete is NOT gated on the restore succeeding: the branch must never
|
|
3261
|
+
// survive teardown. A still-checked-out branch simply makes deleteBranch
|
|
3262
|
+
// fail (logged) rather than silently leaking the ref.
|
|
3216
3263
|
const cleanupGitBranch = () => {
|
|
3217
|
-
if (workBranchName
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3264
|
+
if (!workBranchName) return;
|
|
3265
|
+
if (headState) {
|
|
3266
|
+
const restoreResult = restoreHead(resolvedCwd, headState);
|
|
3267
|
+
if (restoreResult.ok) {
|
|
3268
|
+
log.info("Restored original HEAD", {
|
|
3269
|
+
branch: headState.branch,
|
|
3270
|
+
detached: headState.detached,
|
|
3271
|
+
});
|
|
3272
|
+
} else {
|
|
3273
|
+
log.warn("Failed to restore original HEAD during cleanup", {
|
|
3274
|
+
branch: headState.branch,
|
|
3275
|
+
error: restoreResult.error,
|
|
3276
|
+
});
|
|
3225
3277
|
}
|
|
3226
3278
|
}
|
|
3279
|
+
// Attempt to delete the work branch (non-critical)
|
|
3280
|
+
const deleteResult = deleteBranch(resolvedCwd, workBranchName);
|
|
3281
|
+
if (!deleteResult.ok) {
|
|
3282
|
+
log.warn("Failed to delete work branch during cleanup", {
|
|
3283
|
+
branch: workBranchName,
|
|
3284
|
+
error: deleteResult.error,
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3227
3287
|
};
|
|
3228
3288
|
|
|
3229
3289
|
// R1: Circuit breaker check — reject if circuit is open
|
|
@@ -3321,6 +3381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3321
3381
|
childDepth,
|
|
3322
3382
|
undefined, // intercom
|
|
3323
3383
|
undefined, // subagentId
|
|
3384
|
+
run.childMarker,
|
|
3324
3385
|
);
|
|
3325
3386
|
|
|
3326
3387
|
// Auto-retry on timeout
|
|
@@ -3368,6 +3429,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3368
3429
|
childDepth,
|
|
3369
3430
|
undefined, // intercom
|
|
3370
3431
|
undefined, // subagentId
|
|
3432
|
+
run.childMarker,
|
|
3371
3433
|
);
|
|
3372
3434
|
}
|
|
3373
3435
|
|
|
@@ -3380,17 +3442,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
3380
3442
|
result.label = label;
|
|
3381
3443
|
|
|
3382
3444
|
// P3: Capture git diff if we created a work branch
|
|
3383
|
-
if (workBranchName &&
|
|
3384
|
-
|
|
3445
|
+
if (workBranchName && headState) {
|
|
3446
|
+
// Diff against the captured starting sha rather than the branch
|
|
3447
|
+
// name: a detached start's branch label is the "HEAD" pseudo-ref,
|
|
3448
|
+
// for which `git diff HEAD...HEAD` is always empty.
|
|
3449
|
+
const diffResult = captureDiff(resolvedCwd, headState.sha);
|
|
3385
3450
|
if (diffResult.ok) {
|
|
3386
3451
|
result.gitBranch = workBranchName;
|
|
3387
3452
|
result.gitDiff = diffResult.diff;
|
|
3388
3453
|
}
|
|
3389
3454
|
|
|
3390
|
-
//
|
|
3391
|
-
const
|
|
3392
|
-
if (
|
|
3393
|
-
log.info("
|
|
3455
|
+
// Restore the starting HEAD (branch, or detached sha)
|
|
3456
|
+
const restoreResult = restoreHead(resolvedCwd, headState);
|
|
3457
|
+
if (restoreResult.ok) {
|
|
3458
|
+
log.info("Restored original HEAD", {
|
|
3459
|
+
branch: headState.branch,
|
|
3460
|
+
detached: headState.detached,
|
|
3461
|
+
});
|
|
3394
3462
|
|
|
3395
3463
|
// P4: Change approval workflow — let user review the diff
|
|
3396
3464
|
const diffContent = (result.gitDiff ?? "").trim();
|
|
@@ -3434,13 +3502,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
3434
3502
|
error: mergeResult.error,
|
|
3435
3503
|
});
|
|
3436
3504
|
}
|
|
3437
|
-
|
|
3505
|
+
// #308: a DETACHED start has no branch of its own to hold the
|
|
3506
|
+
// merge result — teardown re-detached at the captured sha and
|
|
3507
|
+
// the merge fast-forwarded THAT detached HEAD, so deleting the
|
|
3508
|
+
// work branch would leave the committed work reachable only
|
|
3509
|
+
// from the detached HEAD (silently lost on the next checkout).
|
|
3510
|
+
// Keep the branch as the durable ref and log where the work
|
|
3511
|
+
// lives. An ATTACHED start merged onto its own branch, which
|
|
3512
|
+
// already retains the commit, so the temporary work branch is
|
|
3513
|
+
// safe to drop.
|
|
3514
|
+
if (headState.detached) {
|
|
3515
|
+
log.info("detached checkout: work preserved on branch", {
|
|
3516
|
+
branch: workBranchName,
|
|
3517
|
+
});
|
|
3518
|
+
} else {
|
|
3519
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3520
|
+
}
|
|
3438
3521
|
workBranchName = undefined;
|
|
3439
3522
|
} else {
|
|
3440
3523
|
// No changes — just delete the empty branch
|
|
3441
3524
|
deleteBranch(resolvedCwd, workBranchName);
|
|
3442
3525
|
workBranchName = undefined;
|
|
3443
3526
|
}
|
|
3527
|
+
} else {
|
|
3528
|
+
log.warn("Failed to restore original HEAD after work-branch run", {
|
|
3529
|
+
branch: headState.branch,
|
|
3530
|
+
error: restoreResult.error,
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
// Nothing may leave teardown with the work branch still present: the
|
|
3535
|
+
// approval flow above clears workBranchName on every terminal choice
|
|
3536
|
+
// (including "apply", where keeping the branch is intentional), so a
|
|
3537
|
+
// still-set name means the restore failed and the flow was skipped.
|
|
3538
|
+
if (workBranchName) {
|
|
3539
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3540
|
+
workBranchName = undefined;
|
|
3444
3541
|
}
|
|
3445
3542
|
}
|
|
3446
3543
|
|
|
@@ -3704,8 +3801,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3704
3801
|
}
|
|
3705
3802
|
|
|
3706
3803
|
// Include transcript path
|
|
3707
|
-
|
|
3708
|
-
resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
|
|
3804
|
+
resultText += `\nTranscript: ${transcriptDisplayPath(params.agent_id)}`;
|
|
3709
3805
|
|
|
3710
3806
|
return {
|
|
3711
3807
|
content: [{ type: "text" as const, text: resultText }],
|
|
@@ -3824,6 +3920,50 @@ export default function (pi: ExtensionAPI) {
|
|
|
3824
3920
|
// Session lifecycle — F7: Session-bound state initialization
|
|
3825
3921
|
// -------------------------------------------------------------------
|
|
3826
3922
|
|
|
3923
|
+
// -------------------------------------------------------------------
|
|
3924
|
+
// Option B U1: boot recovery — identity/liveness detection + child reap
|
|
3925
|
+
// -------------------------------------------------------------------
|
|
3926
|
+
// Runs AFTER restoreFromSession so config is ready. Candidate runs come from
|
|
3927
|
+
// the DURABLE registry (`.pi/run-registry/`), NOT from this session's run
|
|
3928
|
+
// entries: a crashed session's foreground/unit records are invisible to a
|
|
3929
|
+
// default fresh startup, and the full `.pi/subagents/` store is too costly to
|
|
3930
|
+
// parse (only the one agent record a background entry names is read). For
|
|
3931
|
+
// each in-flight run whose owner is dead, its orphaned marker-verified child
|
|
3932
|
+
// is reaped and the record is marked: the registry entry always, plus the
|
|
3933
|
+
// agent record for background runs. SILENT in the UI — the notice/offer
|
|
3934
|
+
// surface is U3. Platforms without /proc skip the whole scan (one log line):
|
|
3935
|
+
// pid-only liveness cannot rule out pid reuse, so nothing is killed or
|
|
3936
|
+
// marked unverified.
|
|
3937
|
+
async function performBootRecovery(): Promise<void> {
|
|
3938
|
+
try {
|
|
3939
|
+
const { getAgent, markAgentInterrupted } = await import("./session-manager");
|
|
3940
|
+
// `recoverProduction` owns the /proc platform guard (nothing is marked or
|
|
3941
|
+
// killed without start-token verification) — see src/recovery.ts.
|
|
3942
|
+
const summary = await recoverProduction({
|
|
3943
|
+
log,
|
|
3944
|
+
// Read THAT ONE agent record by id — never the whole store.
|
|
3945
|
+
readAgentRecord: (id) => getAgent(id),
|
|
3946
|
+
mark(record, interruptedAt) {
|
|
3947
|
+
// Registry entry = the durable mark for every kind. Issue #304: its
|
|
3948
|
+
// boolean is the durable-write result — do NOT drop it, or the boot
|
|
3949
|
+
// summary would claim a mark that never landed. The scan counts a
|
|
3950
|
+
// `false` as `markFailures` and warns with the id.
|
|
3951
|
+
const durable = markInterrupted(record.id, interruptedAt);
|
|
3952
|
+
if (record.kind === "agent") markAgentInterrupted(record.id, interruptedAt);
|
|
3953
|
+
return durable;
|
|
3954
|
+
},
|
|
3955
|
+
});
|
|
3956
|
+
// Report the scan whenever it did anything durable OR attempted a mark
|
|
3957
|
+
// that failed — a failure-only boot must not be silent (issue #304).
|
|
3958
|
+
if (summary.marked > 0 || summary.markFailures > 0) {
|
|
3959
|
+
log.warn("Boot recovery marked interrupted runs", { ...summary });
|
|
3960
|
+
}
|
|
3961
|
+
} catch (err) {
|
|
3962
|
+
// Recovery is best-effort hygiene — it must never break session start.
|
|
3963
|
+
log.warn("Boot recovery scan failed", { error: (err as Error).message });
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3827
3967
|
pi.on("session_start", async (_event, ctx) => {
|
|
3828
3968
|
// Issue #147: capture the session context for the completion-push subscriber.
|
|
3829
3969
|
sessionCtx = ctx;
|
|
@@ -3874,6 +4014,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3874
4014
|
// F5/F9: Safe state restoration with type guards
|
|
3875
4015
|
state.restoreFromSession(ctx);
|
|
3876
4016
|
|
|
4017
|
+
// Option B U1: reap a dead conductor's orphaned children and mark the
|
|
4018
|
+
// records interrupted BEFORE anything else touches their worktrees.
|
|
4019
|
+
await performBootRecovery();
|
|
4020
|
+
|
|
3877
4021
|
updateStatus(state, ctx);
|
|
3878
4022
|
});
|
|
3879
4023
|
|
|
@@ -3883,6 +4027,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
3883
4027
|
activeSubagents: state.activeSubagents,
|
|
3884
4028
|
});
|
|
3885
4029
|
|
|
4030
|
+
// Option B U1: reap in-flight foreground children first, so a clean exit
|
|
4031
|
+
// never orphans a subprocess (the crash case is covered by boot recovery).
|
|
4032
|
+
try {
|
|
4033
|
+
await reapActiveChildren(log);
|
|
4034
|
+
} catch (err) {
|
|
4035
|
+
log.warn("Shutdown reap failed", { error: (err as Error).message });
|
|
4036
|
+
}
|
|
4037
|
+
|
|
3886
4038
|
// Clear all live subagent sessions
|
|
3887
4039
|
state.subagentSessions.clear();
|
|
3888
4040
|
// Drop pending finalize claims too (PR #71 review: the claim set must
|