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/session-manager.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
|
|
2
2
|
import { randomUUID } from 'crypto';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
5
|
-
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
6
|
-
import { EMPTY_USAGE,
|
|
3
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'fs';
|
|
5
|
+
import type { BackgroundAgent, AgentStatus, GitMode, RecoveryMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
6
|
+
import { EMPTY_USAGE, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
7
7
|
import { accumulateUsage } from './runner';
|
|
8
8
|
import * as eventBus from './event-bus';
|
|
9
9
|
import * as transcript from './transcript';
|
|
@@ -11,8 +11,11 @@ import { createEvent } from './event-bus';
|
|
|
11
11
|
import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
|
|
12
12
|
import { wrapTask } from './prompt';
|
|
13
13
|
import { createLogger } from './logging';
|
|
14
|
-
import { getCurrentBranch, createWorkBranch, captureDiff,
|
|
14
|
+
import { getCurrentBranch, getHeadState, restoreHead, createWorkBranch, captureDiff, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
|
|
15
|
+
import type { HeadState } from './git';
|
|
15
16
|
import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
|
|
17
|
+
import { currentProcessOwner } from './recovery';
|
|
18
|
+
import { persistRunRecord } from './run-registry';
|
|
16
19
|
|
|
17
20
|
const log = createLogger('brl-subagent');
|
|
18
21
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -75,6 +78,90 @@ function ensureStorageDir(): void {
|
|
|
75
78
|
mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Option B U2 (D1/D2): file-backed background sessions + retention.
|
|
83
|
+
//
|
|
84
|
+
// Background runs are persisted by the SDK's SessionManager-created files, one
|
|
85
|
+
// per-run directory BESIDE the agent records (`.pi/subagents/sessions/<id>/`).
|
|
86
|
+
// The directory NAME is the run→file mapping — no new record field is needed,
|
|
87
|
+
// and no resume/re-drive/UI logic lives here (that is U3/U4). The per-run dir is
|
|
88
|
+
// removed when the run reaches a TERMINAL state; a run marked interrupted (U1's
|
|
89
|
+
// boot scan, D3) KEEPS its file until it is resolved by a later unit.
|
|
90
|
+
//
|
|
91
|
+
// Boundaries (stated once, referenced by the PR): no resume (U4); no UI/notice/
|
|
92
|
+
// abandon (U3); background is in-process only; a session file has a single
|
|
93
|
+
// writer (the settled run's own process).
|
|
94
|
+
//
|
|
95
|
+
// D5 measurement (one week of use):
|
|
96
|
+
// du -sh .pi/subagents/sessions
|
|
97
|
+
// find .pi/subagents/sessions -name '*.jsonl' | wc -l
|
|
98
|
+
// The expected steady state is EMPTY between runs (terminal runs prune their
|
|
99
|
+
// dir) plus one dir per currently-interrupted run — a non-empty count of
|
|
100
|
+
// settled runs means a prune path regressed. There is no runtime metric.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** Subdirectory of the agent-record store that holds per-run session dirs. */
|
|
104
|
+
const SESSIONS_SUBDIR = 'sessions';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The per-run session directory for `id`: `<STORAGE_DIR>/sessions/<id>`.
|
|
108
|
+
* The id is validated first — this path is fed to SessionManager.create() and
|
|
109
|
+
* to rmSync(), so a traversal id must never reach either.
|
|
110
|
+
*/
|
|
111
|
+
export function backgroundSessionDir(id: string): string {
|
|
112
|
+
assertSafeAgentId(id);
|
|
113
|
+
return join(STORAGE_DIR, SESSIONS_SUBDIR, id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remove ONE per-run session directory. Defensive by construction: the target
|
|
118
|
+
* must look like `<...>/sessions/<id>` (basename === id, parent basename ===
|
|
119
|
+
* 'sessions') before rmSync runs, so a caller can never delete a path outside
|
|
120
|
+
* the sessions tree. Idempotent (force: true); best-effort (never throws).
|
|
121
|
+
*/
|
|
122
|
+
function pruneSessionDir(id: string, dir: string): void {
|
|
123
|
+
try {
|
|
124
|
+
assertSafeAgentId(id);
|
|
125
|
+
} catch {
|
|
126
|
+
log.error(`Refusing to prune session dir for invalid id: ${id}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const target = resolve(dir);
|
|
130
|
+
if (basename(target) !== id || basename(dirname(target)) !== SESSIONS_SUBDIR) {
|
|
131
|
+
log.error(`Refusing to prune session dir outside ${SESSIONS_SUBDIR}/: ${target}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
rmSync(target, { recursive: true, force: true });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Pruning is hygiene — it must never break execution or a settle path.
|
|
138
|
+
log.warn(`Failed to prune session dir for ${id}`, {
|
|
139
|
+
error: err instanceof Error ? err.message : String(err),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* U2 (D2): remove the persisted session for a finalized/abandoned background
|
|
146
|
+
* run. Validated (throws on an unsafe id) and idempotent — a missing directory
|
|
147
|
+
* is a success. Exposed for U3's abandon action to reuse; the internal settle
|
|
148
|
+
* path prunes the CAPTURED directory instead so a mid-run STORAGE_DIR change
|
|
149
|
+
* (tests swap it per case) can never mis-target.
|
|
150
|
+
*
|
|
151
|
+
* U3 (D5) documented boundary — abandon after a STORAGE_DIR change: this
|
|
152
|
+
* PUBLIC helper recomputes the root from the CURRENT STORAGE_DIR, so if the
|
|
153
|
+
* root moved between the spawn and the abandon it prunes the new root (a
|
|
154
|
+
* no-op when nothing lives there) and leaves the old-root session dir behind
|
|
155
|
+
* as debris. That is prod-safe today (only tests swap the root; production
|
|
156
|
+
* uses one root per process), so U3 does NOT try to track the captured dir
|
|
157
|
+
* across sessions — the run's `_sessionFile` is live-only process state and is
|
|
158
|
+
* not persisted (D4). Debris, if any, is bounded and inspectable under the old
|
|
159
|
+
* root.
|
|
160
|
+
*/
|
|
161
|
+
export function pruneBackgroundSession(id: string): void {
|
|
162
|
+
pruneSessionDir(id, backgroundSessionDir(id));
|
|
163
|
+
}
|
|
164
|
+
|
|
78
165
|
/**
|
|
79
166
|
* Persist agent record to disk
|
|
80
167
|
*/
|
|
@@ -85,15 +172,17 @@ function persistAgent(agent: BackgroundAgent): void {
|
|
|
85
172
|
try {
|
|
86
173
|
assertSafeAgentId(agent.id);
|
|
87
174
|
} catch {
|
|
88
|
-
|
|
175
|
+
log.error(`Refusing to persist agent with invalid id: ${agent.id}`);
|
|
89
176
|
return;
|
|
90
177
|
}
|
|
91
178
|
ensureStorageDir();
|
|
92
179
|
const filePath = join(STORAGE_DIR, `${agent.id}.json`);
|
|
93
180
|
// Strip live/non-serializable fields before stringify — `_sessionRef` holds the
|
|
94
181
|
// ENTIRE live pi session graph (1.4MB+ for an empty session, and uninitialized
|
|
95
|
-
// getters like Theme can make JSON.stringify throw).
|
|
96
|
-
|
|
182
|
+
// getters like Theme can make JSON.stringify throw). `_sessionFile` (U2) is the
|
|
183
|
+
// on-disk session path — live-only bookkeeping for precise pruning, never a
|
|
184
|
+
// persisted record field (D4).
|
|
185
|
+
const { _sessionRef, _sessionFile, ...persistable } = agent;
|
|
97
186
|
try {
|
|
98
187
|
// F6 (issue #29): records contain task, error, result.messages (full
|
|
99
188
|
// conversation) and finalOutput — write owner-only (0o600) so other local
|
|
@@ -102,7 +191,7 @@ function persistAgent(agent: BackgroundAgent): void {
|
|
|
102
191
|
writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
103
192
|
} catch (err) {
|
|
104
193
|
// Log but never throw — persistence must not break execution
|
|
105
|
-
|
|
194
|
+
log.error(`Failed to persist agent ${agent.id}`, { error: err instanceof Error ? err.message : String(err) });
|
|
106
195
|
}
|
|
107
196
|
}
|
|
108
197
|
|
|
@@ -136,6 +225,54 @@ export function getAgent(id: string): BackgroundAgent | null {
|
|
|
136
225
|
return agents.get(id) || loadAgent(id);
|
|
137
226
|
}
|
|
138
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Option B U1: every agent record persisted on disk (cross-session — the
|
|
230
|
+
* `.pi/subagents/` store survives a process death; the conductor's session run
|
|
231
|
+
* entries do not). Boot recovery does NOT use this: it sources candidates from
|
|
232
|
+
* the durable run registry and reads background records ONE at a time by id
|
|
233
|
+
* (`recoverInflightRuns`'s `readAgentRecord`), precisely to avoid parsing the
|
|
234
|
+
* whole store; production has no caller for this sweep. Unreadable/corrupt
|
|
235
|
+
* files and unsafe ids are skipped, never thrown.
|
|
236
|
+
*/
|
|
237
|
+
export function listPersistedAgents(): BackgroundAgent[] {
|
|
238
|
+
let files: string[];
|
|
239
|
+
try {
|
|
240
|
+
if (!existsSync(STORAGE_DIR)) return [];
|
|
241
|
+
files = readdirSync(STORAGE_DIR);
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const records: BackgroundAgent[] = [];
|
|
246
|
+
for (const file of files) {
|
|
247
|
+
if (!file.endsWith('.json')) continue;
|
|
248
|
+
const id = file.slice(0, -'.json'.length);
|
|
249
|
+
try {
|
|
250
|
+
assertSafeAgentId(id);
|
|
251
|
+
} catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const record = loadAgent(id);
|
|
255
|
+
if (record) records.push(record);
|
|
256
|
+
}
|
|
257
|
+
return records;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Option B U1 (D3): read-modify-write the interrupted mark onto a persisted
|
|
262
|
+
* agent record. Idempotent: an already-marked record is returned unchanged.
|
|
263
|
+
* Returns null when the record does not exist.
|
|
264
|
+
*/
|
|
265
|
+
export function markAgentInterrupted(id: string, interruptedAt: string): BackgroundAgent | null {
|
|
266
|
+
const agent = getAgent(id);
|
|
267
|
+
if (!agent) return null;
|
|
268
|
+
if (!agent.interruptedAt) {
|
|
269
|
+
agent.interruptedAt = interruptedAt;
|
|
270
|
+
agents.set(id, agent);
|
|
271
|
+
persistAgent(agent);
|
|
272
|
+
}
|
|
273
|
+
return agent;
|
|
274
|
+
}
|
|
275
|
+
|
|
139
276
|
/**
|
|
140
277
|
* Update agent status
|
|
141
278
|
*/
|
|
@@ -320,15 +457,13 @@ export async function steerAgent(id: string, message: string): Promise<Backgroun
|
|
|
320
457
|
}
|
|
321
458
|
|
|
322
459
|
/**
|
|
323
|
-
* Get transcript path for an agent
|
|
460
|
+
* Get transcript path for an agent.
|
|
461
|
+
*
|
|
462
|
+
* Issue #276: the format lives in `transcript-path.ts` (the single source).
|
|
463
|
+
* This re-export preserves the historical `session-manager.getTranscriptPath`
|
|
464
|
+
* surface for `index.ts`'s dynamic import and the existing tests.
|
|
324
465
|
*/
|
|
325
|
-
export
|
|
326
|
-
// F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
|
|
327
|
-
// ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
|
|
328
|
-
// already validated before this is reached with a live agent.)
|
|
329
|
-
assertSafeAgentId(id);
|
|
330
|
-
return join('.pi', 'output', `agent-${id}.jsonl`);
|
|
331
|
-
}
|
|
466
|
+
export { transcriptPath as getTranscriptPath } from './transcript-path';
|
|
332
467
|
|
|
333
468
|
/**
|
|
334
469
|
* Spawn a background session using pi's session API
|
|
@@ -359,6 +494,15 @@ export async function spawnBackgroundSession(
|
|
|
359
494
|
/** Issue #114: per-unit priority — recorded on the run entry + agent record
|
|
360
495
|
* so the drill-in header and history carry it (absent → no segment). */
|
|
361
496
|
priority?: string;
|
|
497
|
+
/** Option B U1: durability identity. A retry inherits the original's
|
|
498
|
+
* dispatchId and records resumeOf/attempt; a fresh dispatch omits them. */
|
|
499
|
+
dispatchId?: string;
|
|
500
|
+
resumeOf?: string;
|
|
501
|
+
attempt?: number;
|
|
502
|
+
/** Option B U3 (D4): recovery posture at dispatch — snapshotted onto the
|
|
503
|
+
* agent record + run entry so the boot notice reads the posture the run
|
|
504
|
+
* was actually dispatched under. Absent reads as "offer". */
|
|
505
|
+
recoveryPosture?: RecoveryMode;
|
|
362
506
|
}
|
|
363
507
|
): Promise<BackgroundAgent> {
|
|
364
508
|
// Serialize access to pi API to prevent concurrent import races
|
|
@@ -388,8 +532,16 @@ export async function spawnBackgroundSession(
|
|
|
388
532
|
}
|
|
389
533
|
}
|
|
390
534
|
|
|
391
|
-
// Create session manager for this background agent
|
|
392
|
-
|
|
535
|
+
// Create the session manager for this background agent. U2 (D1): an explicit
|
|
536
|
+
// per-run sessionDir is REQUIRED — `inMemory` persists nothing (no post-hoc
|
|
537
|
+
// inspection, no U4 resume) and the SDK default (`~/.pi/agent/sessions/<cwd-key>/`,
|
|
538
|
+
// passed as `undefined`) would scatter our runs into the global store. The
|
|
539
|
+
// directory lives beside the agent record: `.pi/subagents/sessions/<id>/`.
|
|
540
|
+
const sessionManager = SessionManager.create(effectiveCwd, backgroundSessionDir(id));
|
|
541
|
+
// Capture the exact path the SDK will write. The settle path derives the
|
|
542
|
+
// prune target from THIS (not from a freshly-computed STORAGE_DIR path) so a
|
|
543
|
+
// change of STORAGE_DIR between spawn and settle cannot mis-target the delete.
|
|
544
|
+
const sessionFile: string | undefined = sessionManager.getSessionFile();
|
|
393
545
|
const settingsManager = SettingsManager.create(effectiveCwd);
|
|
394
546
|
|
|
395
547
|
// E20: Inject the subagent prompt into the session's system prompt.
|
|
@@ -469,6 +621,13 @@ export async function spawnBackgroundSession(
|
|
|
469
621
|
// Set session name
|
|
470
622
|
session.setSessionName(`background-${id.slice(0, 8)}`);
|
|
471
623
|
|
|
624
|
+
// Option B U1: durability identity. Background runs have no child process
|
|
625
|
+
// (the session is in-process), so there is no childMarker — only the owner
|
|
626
|
+
// identity that a boot scan uses to tell a live conductor from a dead one.
|
|
627
|
+
const dispatchId = params.dispatchId ?? generateUUID();
|
|
628
|
+
const attempt = params.attempt ?? 1;
|
|
629
|
+
const owner = currentProcessOwner();
|
|
630
|
+
|
|
472
631
|
// Create agent record
|
|
473
632
|
const agent: BackgroundAgent = {
|
|
474
633
|
id,
|
|
@@ -481,6 +640,15 @@ export async function spawnBackgroundSession(
|
|
|
481
640
|
model: params.model || 'unknown',
|
|
482
641
|
thinkingLevel: params.thinkingLevel || 'medium',
|
|
483
642
|
priority: params.priority,
|
|
643
|
+
dispatchId,
|
|
644
|
+
resumeOf: params.resumeOf,
|
|
645
|
+
attempt,
|
|
646
|
+
owner,
|
|
647
|
+
// Option B U3 (D4): per-run recovery posture snapshot. Additive — absent
|
|
648
|
+
// reads as "offer" via `runRecoveryPosture`/the notice collector.
|
|
649
|
+
recoveryPosture: params.recoveryPosture,
|
|
650
|
+
// U2: live-only pointer to this run's session file (stripped before persist).
|
|
651
|
+
_sessionFile: sessionFile,
|
|
484
652
|
};
|
|
485
653
|
|
|
486
654
|
agents.set(id, agent);
|
|
@@ -506,13 +674,27 @@ export async function spawnBackgroundSession(
|
|
|
506
674
|
priority: params.priority,
|
|
507
675
|
startedAt: new Date().toISOString(),
|
|
508
676
|
originalParams: params.originalParams,
|
|
677
|
+
dispatchId,
|
|
678
|
+
resumeOf: params.resumeOf,
|
|
679
|
+
attempt,
|
|
680
|
+
owner,
|
|
681
|
+
recoveryPosture: params.recoveryPosture,
|
|
509
682
|
};
|
|
510
|
-
pi
|
|
683
|
+
persistRunRecord(pi, run, "background");
|
|
511
684
|
|
|
512
685
|
// Issue #98: keep the session run entry in lockstep with the agent record —
|
|
513
686
|
// every terminal branch that flips agent.status also finalizes the run entry
|
|
514
687
|
// ("done"/"failed"). The guard keeps the entry single-finalized even when a
|
|
515
688
|
// settle handler throws after finalizing (markTerminalBestEffort catch-all).
|
|
689
|
+
//
|
|
690
|
+
// U2 (D2) retention: this is the SINGLE terminal choke point for a background
|
|
691
|
+
// run — git-setup refusal, a synchronous prompt throw, the .then completed /
|
|
692
|
+
// provider-failed / aborted branches, the .catch stopped / failed branches and
|
|
693
|
+
// the markTerminalBestEffort catch-all ALL land here exactly once (the
|
|
694
|
+
// runFinalized guard). Pruning here therefore covers every settle path. The
|
|
695
|
+
// index.ts poller crash notices are NOT settlements (they flip only the live
|
|
696
|
+
// UI state and leave the run/agent record to this settle); an interrupted run
|
|
697
|
+
// (U1 boot-scan mark) never reaches this function and KEEPS its file (D3).
|
|
516
698
|
let runFinalized = false;
|
|
517
699
|
const finalizeRunEntry = (
|
|
518
700
|
status: "done" | "failed",
|
|
@@ -521,6 +703,15 @@ export async function spawnBackgroundSession(
|
|
|
521
703
|
): void => {
|
|
522
704
|
if (runFinalized) return;
|
|
523
705
|
runFinalized = true;
|
|
706
|
+
// U2 (D2): drop the persisted session now that the run is terminal. Done
|
|
707
|
+
// FIRST (before the run-entry write below) so a persist throw cannot leave
|
|
708
|
+
// a terminal run's session file behind. Interrupted runs keep theirs.
|
|
709
|
+
if (!agent.interruptedAt) {
|
|
710
|
+
pruneSessionDir(
|
|
711
|
+
id,
|
|
712
|
+
agent._sessionFile ? dirname(agent._sessionFile) : backgroundSessionDir(id),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
524
715
|
const usage = agent.result?.usage ?? EMPTY_USAGE;
|
|
525
716
|
const finalOutput = agent.finalOutput ?? "";
|
|
526
717
|
// Issue #179 (D1/D2): the classifier's explicit category wins (it knows the
|
|
@@ -576,7 +767,7 @@ export async function spawnBackgroundSession(
|
|
|
576
767
|
? { ...(run.originalParams ?? {}), errorCategory }
|
|
577
768
|
: run.originalParams,
|
|
578
769
|
};
|
|
579
|
-
pi
|
|
770
|
+
persistRunRecord(pi, entry);
|
|
580
771
|
// Issue #179 (D6): the explicit settle line — the log's job is to record the
|
|
581
772
|
// CLASSIFIED OUTCOME, which cwd alone can never convey.
|
|
582
773
|
log.info("Background run settled", {
|
|
@@ -661,9 +852,14 @@ export async function spawnBackgroundSession(
|
|
|
661
852
|
// hard failure — never spawn an unisolated background agent that was asked
|
|
662
853
|
// for isolation (fail-loud, same principle as foreground's fallback but
|
|
663
854
|
// background cannot warn-and-continue safely).
|
|
664
|
-
let
|
|
855
|
+
let headState: HeadState | undefined;
|
|
665
856
|
let workBranchName: string | undefined;
|
|
666
857
|
let releaseGitLock: (() => void) | undefined;
|
|
858
|
+
// Idempotency guard for cleanupWorkBranch: it must be safe to call from both
|
|
859
|
+
// the normal settle path and the catch-all that runs when a settle step
|
|
860
|
+
// throws FIRST (before its own cleanup call). Without this the second call
|
|
861
|
+
// would re-restore/re-delete and log spurious warnings.
|
|
862
|
+
let gitCleanupDone = false;
|
|
667
863
|
const gitCwd = params.cwd ?? ctx.cwd;
|
|
668
864
|
if (params.gitMode === 'branch') {
|
|
669
865
|
// C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
|
|
@@ -696,13 +892,13 @@ export async function spawnBackgroundSession(
|
|
|
696
892
|
'requesting background gitMode=branch isolation'
|
|
697
893
|
);
|
|
698
894
|
}
|
|
699
|
-
|
|
700
|
-
const branchResult = createWorkBranch(gitCwd,
|
|
895
|
+
headState = getHeadState(gitCwd);
|
|
896
|
+
const branchResult = createWorkBranch(gitCwd, headState.sha);
|
|
701
897
|
if (branchResult.ok) {
|
|
702
898
|
workBranchName = branchResult.branch;
|
|
703
899
|
log.info('Created work branch for background agent', {
|
|
704
900
|
branch: workBranchName,
|
|
705
|
-
base:
|
|
901
|
+
base: headState.branch,
|
|
706
902
|
agentId: id,
|
|
707
903
|
});
|
|
708
904
|
} else {
|
|
@@ -740,7 +936,8 @@ export async function spawnBackgroundSession(
|
|
|
740
936
|
// uncommitted edits would leak into the base working tree on switch —
|
|
741
937
|
// commitAll first makes the diff real and the switch clean.
|
|
742
938
|
const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
|
|
743
|
-
if (!workBranchName || !
|
|
939
|
+
if (gitCleanupDone || !workBranchName || !headState) return {};
|
|
940
|
+
gitCleanupDone = true;
|
|
744
941
|
const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
|
|
745
942
|
// The lock MUST be released on every exit path — early returns included.
|
|
746
943
|
try {
|
|
@@ -778,7 +975,7 @@ export async function spawnBackgroundSession(
|
|
|
778
975
|
if (workingDiff) info.gitDiff = workingDiff;
|
|
779
976
|
}
|
|
780
977
|
}
|
|
781
|
-
const diffResult = captureDiff(gitCwd,
|
|
978
|
+
const diffResult = captureDiff(gitCwd, headState.sha);
|
|
782
979
|
if (diffResult.ok && diffResult.diff.trim()) {
|
|
783
980
|
// Merge committed + working diffs — either can exist alone.
|
|
784
981
|
info.gitDiff = info.gitDiff
|
|
@@ -789,9 +986,12 @@ export async function spawnBackgroundSession(
|
|
|
789
986
|
log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
|
|
790
987
|
}
|
|
791
988
|
try {
|
|
792
|
-
|
|
989
|
+
const restoreResult = restoreHead(gitCwd, headState);
|
|
990
|
+
if (!restoreResult.ok) {
|
|
991
|
+
log.warn(`restoreHead failed for background agent ${id}`, { error: restoreResult.error });
|
|
992
|
+
}
|
|
793
993
|
} catch (err) {
|
|
794
|
-
log.warn(`
|
|
994
|
+
log.warn(`restoreHead failed for background agent ${id}`, { error: (err as Error).message });
|
|
795
995
|
}
|
|
796
996
|
try {
|
|
797
997
|
deleteBranch(gitCwd, workBranchName);
|
|
@@ -812,6 +1012,11 @@ export async function spawnBackgroundSession(
|
|
|
812
1012
|
// terminal so no zombie 'running' record survives; never rethrow.
|
|
813
1013
|
const markTerminalBestEffort = (fallback: AgentStatus): void => {
|
|
814
1014
|
try {
|
|
1015
|
+
// Issue #53 / review C: this catch-all fires when a settle handler step
|
|
1016
|
+
// threw — possibly BEFORE the branch's own cleanupWorkBranch() call. Run
|
|
1017
|
+
// it here too (idempotent) so a throw can never bypass branch teardown
|
|
1018
|
+
// and strand the work branch in the repo working tree.
|
|
1019
|
+
cleanupWorkBranch();
|
|
815
1020
|
// Issue #31 (PR #76 review): this catch-all fires when a settle handler
|
|
816
1021
|
// threw — either BEFORE the branch's own capture (ref still live: capture
|
|
817
1022
|
// now, it is the last chance to record the session's output) or AFTER it
|
package/src/state.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
ThinkingLevel,
|
|
18
18
|
ApprovalMode,
|
|
19
19
|
CompletionNotifyMode,
|
|
20
|
+
RecoveryMode,
|
|
20
21
|
CircuitBreakerState,
|
|
21
22
|
Priority,
|
|
22
23
|
} from "./types";
|
|
@@ -24,17 +25,21 @@ import type { TranscriptMessage } from "./transcript-tail";
|
|
|
24
25
|
import {
|
|
25
26
|
isSubagentStateShape,
|
|
26
27
|
isSubagentRunShape,
|
|
28
|
+
isInterruptedRun,
|
|
27
29
|
CUSTOM_ENTRY_TYPES,
|
|
28
30
|
MAX_RUN_HISTORY_ENTRIES,
|
|
29
31
|
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
30
32
|
DEFAULT_SESSION_COST_LIMIT,
|
|
31
33
|
DEFAULT_COMPLETION_NOTIFY,
|
|
32
34
|
COMPLETION_NOTIFY_MODES,
|
|
35
|
+
DEFAULT_RECOVERY,
|
|
36
|
+
RECOVERY_MODES,
|
|
33
37
|
MAX_CONSECUTIVE_FAILURES,
|
|
34
38
|
CIRCUIT_BREAKER_RESET_MS,
|
|
35
39
|
CIRCUIT_DEGRADED_THINKING,
|
|
36
40
|
} from "./types";
|
|
37
41
|
import { cleanupRuns } from "./history";
|
|
42
|
+
import { persistRunRecord, type InflightRunKind } from "./run-registry";
|
|
38
43
|
import type { Logger } from "./logging";
|
|
39
44
|
|
|
40
45
|
// ---------------------------------------------------------------------------
|
|
@@ -69,14 +74,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
|
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Terminal-preferring run-entry lookup — the ONE implementation of the
|
|
72
|
-
* preference rule.
|
|
77
|
+
* preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
|
|
78
|
+
* boot scan and every display/retry lookup agree by construction.
|
|
73
79
|
*
|
|
74
80
|
* Each run writes TWO custom entries sharing its id, in append order: a spawn
|
|
75
81
|
* entry (status "running", carrying `originalParams`) FIRST, then the final
|
|
76
82
|
* entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
|
|
77
|
-
* settle.
|
|
78
|
-
*
|
|
79
|
-
* entry
|
|
83
|
+
* settle. Preference, in order:
|
|
84
|
+
*
|
|
85
|
+
* 1. a TERMINAL entry (status !== "running") — the run is resolved;
|
|
86
|
+
* 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
|
|
87
|
+
* append-only stores keep the original spawn entry forever, so the marked
|
|
88
|
+
* clone is the authoritative RECOVERED state U3's shared lookup reads;
|
|
89
|
+
* 3. otherwise the first match (the pre-finalize spawn shape).
|
|
90
|
+
*
|
|
91
|
+
* Returns undefined when no entry matches.
|
|
80
92
|
*/
|
|
81
93
|
export function resolveTerminalRunEntry(
|
|
82
94
|
entries: SubagentRun[],
|
|
@@ -84,7 +96,57 @@ export function resolveTerminalRunEntry(
|
|
|
84
96
|
): SubagentRun | undefined {
|
|
85
97
|
const matching = entries.filter((r) => r.id === id);
|
|
86
98
|
if (matching.length === 0) return undefined;
|
|
87
|
-
return
|
|
99
|
+
return (
|
|
100
|
+
matching.find((r) => r.status !== "running") ??
|
|
101
|
+
matching.find((r) => r.interruptedAt) ??
|
|
102
|
+
matching[0]
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run-history display projection (issue #259): ONE record per SETTLED run.
|
|
108
|
+
*
|
|
109
|
+
* Each run appends two entries sharing its id — a spawn entry (status
|
|
110
|
+
* "running") first, then the terminal entry at settle — so a raw entry list
|
|
111
|
+
* renders every run twice and the spawn row shows a stale "running" forever.
|
|
112
|
+
* This collapses each id to its terminal-preferred entry via the ONE shared
|
|
113
|
+
* rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
|
|
114
|
+
* terminal entry yet) are omitted: live state belongs to the monitor. U3 (D1)
|
|
115
|
+
* carves ONE exception — an INTERRUPTED run (running + `interruptedAt`, per
|
|
116
|
+
* `isInterruptedRun`) is surfaced, because the human must see it to recover or
|
|
117
|
+
* abandon it.
|
|
118
|
+
*
|
|
119
|
+
* Ordering is preserved from `entries`; callers pass `getRunEntries` output
|
|
120
|
+
* (newest-first via `cleanupRuns`), which stays the single ordering authority —
|
|
121
|
+
* no re-sort is performed here.
|
|
122
|
+
*/
|
|
123
|
+
export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
|
|
124
|
+
const display: SubagentRun[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (seen.has(entry.id)) continue;
|
|
128
|
+
seen.add(entry.id);
|
|
129
|
+
const resolved = resolveTerminalRunEntry(entries, entry.id);
|
|
130
|
+
if (resolved && (resolved.status !== "running" || isInterruptedRun(resolved))) {
|
|
131
|
+
display.push(resolved);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return display;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* U3 (D3): the retry surface's candidate projection — settled FAILED runs plus
|
|
139
|
+
* unresolved INTERRUPTED runs, one row per id. Built on the SAME
|
|
140
|
+
* terminal-preferred collapse the history list uses, so the two surfaces can
|
|
141
|
+
* never disagree about which record represents a run. The retry offer reuses
|
|
142
|
+
* the existing `retryRunId` machinery (recorded params restored by
|
|
143
|
+
* `resolveRetryParams`), so an interrupted run is recoverable without any new
|
|
144
|
+
* delegate_task parameter.
|
|
145
|
+
*/
|
|
146
|
+
export function retryableRuns(entries: SubagentRun[]): SubagentRun[] {
|
|
147
|
+
return collapseRunsForHistory(entries).filter(
|
|
148
|
+
(r) => r.status === "failed" || isInterruptedRun(r),
|
|
149
|
+
);
|
|
88
150
|
}
|
|
89
151
|
|
|
90
152
|
// ---------------------------------------------------------------------------
|
|
@@ -158,6 +220,7 @@ export class SessionState {
|
|
|
158
220
|
gitMode: "none",
|
|
159
221
|
approvalMode: "writes",
|
|
160
222
|
completionNotify: DEFAULT_COMPLETION_NOTIFY,
|
|
223
|
+
recovery: DEFAULT_RECOVERY,
|
|
161
224
|
maxHistoryEntries: MAX_RUN_HISTORY_ENTRIES,
|
|
162
225
|
sessionCostLimit: DEFAULT_SESSION_COST_LIMIT,
|
|
163
226
|
perTaskCostEstimate: 0,
|
|
@@ -183,6 +246,7 @@ export class SessionState {
|
|
|
183
246
|
gitMode: this.config.gitMode,
|
|
184
247
|
approvalMode: this.config.approvalMode,
|
|
185
248
|
completionNotify: this.config.completionNotify,
|
|
249
|
+
recovery: this.config.recovery,
|
|
186
250
|
maxHistoryEntries: this.config.maxHistoryEntries,
|
|
187
251
|
sessionCostLimit: this.config.sessionCostLimit,
|
|
188
252
|
perTaskCostEstimate: this.config.perTaskCostEstimate,
|
|
@@ -194,8 +258,14 @@ export class SessionState {
|
|
|
194
258
|
});
|
|
195
259
|
}
|
|
196
260
|
|
|
197
|
-
|
|
198
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Persist a run record through the ONE choke point (`persistRunRecord`),
|
|
263
|
+
* which appends the session entry AND mirrors the record into the durable
|
|
264
|
+
* in-flight registry. `kind` is supplied at dispatch; terminal writes clear
|
|
265
|
+
* the registry entry.
|
|
266
|
+
*/
|
|
267
|
+
persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
|
|
268
|
+
persistRunRecord(pi, run, kind);
|
|
199
269
|
}
|
|
200
270
|
|
|
201
271
|
// -------------------------------------------------------------------
|
|
@@ -244,6 +314,9 @@ export class SessionState {
|
|
|
244
314
|
COMPLETION_NOTIFY_MODES.includes(data.completionNotify as CompletionNotifyMode)
|
|
245
315
|
? data.completionNotify
|
|
246
316
|
: DEFAULT_COMPLETION_NOTIFY;
|
|
317
|
+
this.config.recovery = RECOVERY_MODES.includes(data.recovery as RecoveryMode)
|
|
318
|
+
? data.recovery
|
|
319
|
+
: DEFAULT_RECOVERY;
|
|
247
320
|
if (data.maxHistoryEntries !== undefined) this.config.maxHistoryEntries = data.maxHistoryEntries;
|
|
248
321
|
if (data.sessionCostLimit !== undefined) this.config.sessionCostLimit = data.sessionCostLimit;
|
|
249
322
|
if (data.perTaskCostEstimate !== undefined) this.config.perTaskCostEstimate = data.perTaskCostEstimate;
|
|
@@ -533,6 +606,7 @@ export class SessionState {
|
|
|
533
606
|
this.config.gitMode = "none";
|
|
534
607
|
this.config.approvalMode = "writes";
|
|
535
608
|
this.config.completionNotify = DEFAULT_COMPLETION_NOTIFY;
|
|
609
|
+
this.config.recovery = DEFAULT_RECOVERY;
|
|
536
610
|
this.config.maxHistoryEntries = MAX_RUN_HISTORY_ENTRIES;
|
|
537
611
|
this.config.sessionCostLimit = DEFAULT_SESSION_COST_LIMIT;
|
|
538
612
|
this.config.perTaskCostEstimate = 0;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Purpose: The single declaration of the transcript path format — filesystem (`join`) and display (POSIX) forms.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertSafeAgentId } from "./sanitize";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The transcript directory, relative to the project root. This is the ONE
|
|
7
|
+
* declaration of the format; every other module derives its path from here
|
|
8
|
+
* rather than re-inlining `.pi/output` in a template string (issue #276).
|
|
9
|
+
*/
|
|
10
|
+
export const TRANSCRIPT_DIR = ".pi/output";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filesystem path to an agent's transcript: `<dir>/agent-<id>.jsonl`.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY (F24): the id is validated through `assertSafeAgentId` BEFORE it
|
|
16
|
+
* reaches `path.join` — join does NOT sanitize `../` or absolute segments, so
|
|
17
|
+
* a traversal id would otherwise escape `dir`. Keep this chokepoint for every
|
|
18
|
+
* filesystem caller (`transcript.ts`, the `get_agent_result` re-export).
|
|
19
|
+
*
|
|
20
|
+
* `dir` is a parameter so `transcript.ts` can forward its mutable test-only
|
|
21
|
+
* `OUTPUT_DIR`; production callers take the `TRANSCRIPT_DIR` default.
|
|
22
|
+
*/
|
|
23
|
+
export function transcriptPath(agentId: string, dir: string = TRANSCRIPT_DIR): string {
|
|
24
|
+
assertSafeAgentId(agentId);
|
|
25
|
+
return join(dir, `agent-${agentId}.jsonl`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Display form of the transcript path: `<TRANSCRIPT_DIR>/agent-<id>.jsonl`,
|
|
30
|
+
* always with forward slashes so the rendered panel is identical on every
|
|
31
|
+
* platform.
|
|
32
|
+
*
|
|
33
|
+
* This is deliberately format-only: it does NOT call `assertSafeAgentId` and
|
|
34
|
+
* must never throw. It is called during TUI rendering (`tui.ts` →
|
|
35
|
+
* `buildOutputHonestyLines`) from a stored run record, where a throw would
|
|
36
|
+
* break the panel. Display text is POSIX while filesystem access uses `join` —
|
|
37
|
+
* the two forms intentionally differ (settled in issue #276).
|
|
38
|
+
*/
|
|
39
|
+
export function transcriptDisplayPath(agentId: string): string {
|
|
40
|
+
return `${TRANSCRIPT_DIR}/agent-${agentId}.jsonl`;
|
|
41
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
|
|
2
|
-
import { join } from 'path';
|
|
3
2
|
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
|
|
4
3
|
import type { TranscriptEntry, TranscriptEntryType } from './types';
|
|
5
|
-
import {
|
|
4
|
+
import { transcriptPath, TRANSCRIPT_DIR } from './transcript-path';
|
|
5
|
+
import { createLogger } from './logging';
|
|
6
|
+
|
|
7
|
+
// Issue #265: route the settle-path warning through the logger (file-only by
|
|
8
|
+
// default) instead of a raw console.warn that corrupts the pi TUI.
|
|
9
|
+
const log = createLogger('brl-subagent');
|
|
6
10
|
|
|
7
11
|
// Output directory — overridable for tests (issue #52): unit tests must NOT
|
|
8
|
-
// write into the real repo .pi/ dir; the setter is test-only.
|
|
9
|
-
|
|
12
|
+
// write into the real repo .pi/ dir; the setter is test-only. Defaults to the
|
|
13
|
+
// single-sourced TRANSCRIPT_DIR (issue #276).
|
|
14
|
+
let OUTPUT_DIR = TRANSCRIPT_DIR;
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
17
|
* TEST-ONLY: point the transcript output at an alternate directory.
|
|
@@ -30,13 +35,9 @@ function ensureOutputDir(): void {
|
|
|
30
35
|
* Get transcript file path for an agent
|
|
31
36
|
*/
|
|
32
37
|
export function getTranscriptPath(agentId: string): string {
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
// getTranscript. path.join does not sanitize "../" or absolute segments, so
|
|
37
|
-
// throw on anything that is not a UUID before it reaches the filesystem.
|
|
38
|
-
assertSafeAgentId(agentId);
|
|
39
|
-
return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
|
|
38
|
+
// Issue #276: the format and the F24 `assertSafeAgentId` chokepoint live in
|
|
39
|
+
// transcript-path.ts; here we only forward the mutable test-only OUTPUT_DIR.
|
|
40
|
+
return transcriptPath(agentId, OUTPUT_DIR);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -118,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
|
|
|
118
119
|
// appending — that invariant is load-bearing for caller validation).
|
|
119
120
|
const path = getTranscriptPath(agentId);
|
|
120
121
|
if (!existsSync(path)) {
|
|
121
|
-
|
|
122
|
+
log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
124
125
|
appendEntry(agentId, 'system', `Transcript completed: ${status}`);
|