brl-subagent 2.3.9 → 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/AGENT.md +5 -0
- package/README.md +20 -8
- package/package.json +11 -9
- package/src/concurrency.ts +1 -0
- package/src/diff.ts +1 -0
- package/src/event-bus.ts +8 -2
- package/src/git.ts +68 -0
- package/src/history.ts +59 -1
- package/src/index.ts +244 -406
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +58 -4
- package/src/messaging.ts +1 -0
- package/src/metrics.ts +1 -0
- package/src/model-availability.ts +1 -0
- package/src/notify-completion.ts +1 -0
- package/src/params.ts +1 -0
- package/src/paths.ts +1 -0
- package/src/preflight.ts +30 -14
- package/src/prelude.ts +1 -0
- package/src/presets.ts +1 -0
- package/src/proc.ts +140 -0
- package/src/prompt.ts +1 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/reports.ts +64 -8
- package/src/router.ts +1 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +207 -16
- package/src/sanitize.ts +54 -3
- package/src/scheduler.ts +1 -0
- package/src/schema.ts +254 -0
- package/src/session-manager.ts +142 -111
- package/src/state.ts +52 -7
- package/src/templates.ts +1 -0
- package/src/transcript-path.ts +41 -0
- package/src/transcript-tail.ts +1 -0
- package/src/transcript.ts +14 -12
- package/src/tui-format.ts +1 -0
- package/src/tui.ts +231 -296
- package/src/types.ts +130 -2
- package/src/unit-run.ts +9 -0
- package/src/validate.ts +16 -2
package/src/session-manager.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
// Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
|
|
1
2
|
import { randomUUID } from 'crypto';
|
|
2
3
|
import { join } from 'path';
|
|
3
|
-
import { mkdirSync, writeFileSync, readFileSync,
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'fs';
|
|
4
5
|
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
5
|
-
import { EMPTY_USAGE,
|
|
6
|
+
import { EMPTY_USAGE, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
6
7
|
import { accumulateUsage } from './runner';
|
|
7
8
|
import * as eventBus from './event-bus';
|
|
8
9
|
import * as transcript from './transcript';
|
|
@@ -10,7 +11,11 @@ import { createEvent } from './event-bus';
|
|
|
10
11
|
import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
|
|
11
12
|
import { wrapTask } from './prompt';
|
|
12
13
|
import { createLogger } from './logging';
|
|
13
|
-
import { getCurrentBranch, createWorkBranch, captureDiff,
|
|
14
|
+
import { getCurrentBranch, getHeadState, restoreHead, createWorkBranch, captureDiff, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
|
|
15
|
+
import type { HeadState } from './git';
|
|
16
|
+
import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
|
|
17
|
+
import { currentProcessOwner } from './recovery';
|
|
18
|
+
import { persistRunRecord } from './run-registry';
|
|
14
19
|
|
|
15
20
|
const log = createLogger('brl-subagent');
|
|
16
21
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -83,7 +88,7 @@ function persistAgent(agent: BackgroundAgent): void {
|
|
|
83
88
|
try {
|
|
84
89
|
assertSafeAgentId(agent.id);
|
|
85
90
|
} catch {
|
|
86
|
-
|
|
91
|
+
log.error(`Refusing to persist agent with invalid id: ${agent.id}`);
|
|
87
92
|
return;
|
|
88
93
|
}
|
|
89
94
|
ensureStorageDir();
|
|
@@ -100,7 +105,7 @@ function persistAgent(agent: BackgroundAgent): void {
|
|
|
100
105
|
writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
101
106
|
} catch (err) {
|
|
102
107
|
// Log but never throw — persistence must not break execution
|
|
103
|
-
|
|
108
|
+
log.error(`Failed to persist agent ${agent.id}`, { error: err instanceof Error ? err.message : String(err) });
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
111
|
|
|
@@ -117,49 +122,6 @@ function loadAgent(id: string): BackgroundAgent | null {
|
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
/**
|
|
121
|
-
* Create a new background agent session
|
|
122
|
-
*
|
|
123
|
-
* NOTE: In v2.0.3, this creates a record but does NOT actually spawn a pi session.
|
|
124
|
-
* The actual session spawning will be implemented when pi's ExtensionAPI supports it.
|
|
125
|
-
* For now, this is a placeholder that creates the agent record.
|
|
126
|
-
*/
|
|
127
|
-
export function createSession(params: {
|
|
128
|
-
task: string;
|
|
129
|
-
type?: string;
|
|
130
|
-
description?: string;
|
|
131
|
-
model?: string;
|
|
132
|
-
thinkingLevel?: ThinkingLevel;
|
|
133
|
-
systemPrompt?: string;
|
|
134
|
-
}): BackgroundAgent {
|
|
135
|
-
const id = generateUUID();
|
|
136
|
-
const agent: BackgroundAgent = {
|
|
137
|
-
id,
|
|
138
|
-
sessionId: `session-${id}`,
|
|
139
|
-
type: params.type || 'general-purpose',
|
|
140
|
-
description: params.description || params.task.slice(0, 50),
|
|
141
|
-
status: 'pending',
|
|
142
|
-
startedAt: Date.now(),
|
|
143
|
-
task: params.task,
|
|
144
|
-
model: params.model || 'unknown',
|
|
145
|
-
thinkingLevel: params.thinkingLevel || 'medium',
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
agents.set(id, agent);
|
|
149
|
-
persistAgent(agent);
|
|
150
|
-
|
|
151
|
-
// Start transcript for this agent
|
|
152
|
-
transcript.startTranscript(agent.id, params.task);
|
|
153
|
-
|
|
154
|
-
eventBus.emit(eventBus.createEvent('subagent:created', agent.id, {
|
|
155
|
-
type: agent.type,
|
|
156
|
-
description: agent.description,
|
|
157
|
-
task: agent.task,
|
|
158
|
-
}));
|
|
159
|
-
|
|
160
|
-
return agent;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
125
|
/**
|
|
164
126
|
* Get agent record by ID
|
|
165
127
|
*/
|
|
@@ -178,20 +140,51 @@ export function getAgent(id: string): BackgroundAgent | null {
|
|
|
178
140
|
}
|
|
179
141
|
|
|
180
142
|
/**
|
|
181
|
-
*
|
|
143
|
+
* Option B U1: every agent record persisted on disk (cross-session — the
|
|
144
|
+
* `.pi/subagents/` store survives a process death; the conductor's session run
|
|
145
|
+
* entries do not). Boot recovery does NOT use this: it sources candidates from
|
|
146
|
+
* the durable run registry and reads background records ONE at a time by id
|
|
147
|
+
* (`recoverInflightRuns`'s `readAgentRecord`), precisely to avoid parsing the
|
|
148
|
+
* whole store; production has no caller for this sweep. Unreadable/corrupt
|
|
149
|
+
* files and unsafe ids are skipped, never thrown.
|
|
182
150
|
*/
|
|
183
|
-
export function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
151
|
+
export function listPersistedAgents(): BackgroundAgent[] {
|
|
152
|
+
let files: string[];
|
|
153
|
+
try {
|
|
154
|
+
if (!existsSync(STORAGE_DIR)) return [];
|
|
155
|
+
files = readdirSync(STORAGE_DIR);
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const records: BackgroundAgent[] = [];
|
|
188
160
|
for (const file of files) {
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
161
|
+
if (!file.endsWith('.json')) continue;
|
|
162
|
+
const id = file.slice(0, -'.json'.length);
|
|
163
|
+
try {
|
|
164
|
+
assertSafeAgentId(id);
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const record = loadAgent(id);
|
|
169
|
+
if (record) records.push(record);
|
|
192
170
|
}
|
|
193
|
-
|
|
194
|
-
|
|
171
|
+
return records;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Option B U1 (D3): read-modify-write the interrupted mark onto a persisted
|
|
176
|
+
* agent record. Idempotent: an already-marked record is returned unchanged.
|
|
177
|
+
* Returns null when the record does not exist.
|
|
178
|
+
*/
|
|
179
|
+
export function markAgentInterrupted(id: string, interruptedAt: string): BackgroundAgent | null {
|
|
180
|
+
const agent = getAgent(id);
|
|
181
|
+
if (!agent) return null;
|
|
182
|
+
if (!agent.interruptedAt) {
|
|
183
|
+
agent.interruptedAt = interruptedAt;
|
|
184
|
+
agents.set(id, agent);
|
|
185
|
+
persistAgent(agent);
|
|
186
|
+
}
|
|
187
|
+
return agent;
|
|
195
188
|
}
|
|
196
189
|
|
|
197
190
|
/**
|
|
@@ -225,25 +218,6 @@ export function updateAgentStatus(id: string, status: AgentStatus, error?: strin
|
|
|
225
218
|
return agent;
|
|
226
219
|
}
|
|
227
220
|
|
|
228
|
-
/**
|
|
229
|
-
* Set agent result
|
|
230
|
-
*/
|
|
231
|
-
export function setAgentResult(id: string, result: SubagentResult): BackgroundAgent | null {
|
|
232
|
-
const agent = getAgent(id);
|
|
233
|
-
if (!agent) return null;
|
|
234
|
-
|
|
235
|
-
agent.result = result;
|
|
236
|
-
// Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
|
|
237
|
-
// length/incomplete), not exitCode alone — a mid-run provider death resolves
|
|
238
|
-
// with exitCode 0 and was previously recorded as 'completed'.
|
|
239
|
-
agent.status = isSubagentError(result) ? 'failed' : 'completed';
|
|
240
|
-
agent.completedAt = Date.now();
|
|
241
|
-
|
|
242
|
-
agents.set(id, agent);
|
|
243
|
-
persistAgent(agent);
|
|
244
|
-
return agent;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
221
|
/**
|
|
248
222
|
* Issue #179 (D4): when a run ends in a failure, the prose extracted by
|
|
249
223
|
* extractFinalOutput may come from an EARLIER turn — the failing turn carries
|
|
@@ -281,7 +255,7 @@ function defaultTerminalMessage(stopReason: string | undefined): string {
|
|
|
281
255
|
* Extract the final assistant text from a session's messages.
|
|
282
256
|
* Falls back to the LAST assistant message with non-empty text content — the
|
|
283
257
|
* final message is often a tool-call-only turn (agent stopped mid-turn, failed,
|
|
284
|
-
* or hit the
|
|
258
|
+
* or hit the deadline), and that turn carries no text of its own.
|
|
285
259
|
*/
|
|
286
260
|
export function extractFinalOutput(session: { messages: Array<{ role: string; content?: Array<{ type: string; text?: string }> | string | null }> }): string {
|
|
287
261
|
const assistants = [...session.messages].reverse().filter(m => m.role === 'assistant');
|
|
@@ -351,24 +325,45 @@ export async function stopAgent(id: string): Promise<BackgroundAgent | null> {
|
|
|
351
325
|
}
|
|
352
326
|
|
|
353
327
|
/**
|
|
354
|
-
* Steer a running agent by injecting a message
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
328
|
+
* Steer a running agent by injecting a message into its live session.
|
|
329
|
+
*
|
|
330
|
+
* Issue #241: steering DELIVERS via the SDK's `AgentSession.steer()` — the
|
|
331
|
+
* message is queued while the agent is running and delivered after the current
|
|
332
|
+
* assistant turn finishes its tool calls, before the next model call.
|
|
333
|
+
*
|
|
334
|
+
* Steering is NOT a state transition: the agent stays 'running' (no status
|
|
335
|
+
* flip, no persistAgent) so REPEAT steers keep working. Delivery failures
|
|
336
|
+
* propagate to the caller — a false success would tell the conductor a steer
|
|
337
|
+
* landed when the agent never saw it. The transcript line is an audit record
|
|
338
|
+
* written AFTER successful delivery, best-effort: a landed steer must never be
|
|
339
|
+
* reported as failed because the audit write failed.
|
|
358
340
|
*/
|
|
359
|
-
export function steerAgent(id: string, message: string): BackgroundAgent | null {
|
|
341
|
+
export async function steerAgent(id: string, message: string): Promise<BackgroundAgent | null> {
|
|
360
342
|
const agent = getAgent(id);
|
|
361
343
|
if (!agent) return null;
|
|
362
344
|
if (agent.status !== 'running') {
|
|
363
345
|
throw new Error(`Cannot steer agent ${id}: status is ${agent.status}, not running`);
|
|
364
346
|
}
|
|
365
347
|
|
|
366
|
-
|
|
367
|
-
|
|
348
|
+
const session = agent._sessionRef;
|
|
349
|
+
if (!session) {
|
|
350
|
+
throw new Error(`Cannot steer agent ${id}: no live session (the run may be settling)`);
|
|
351
|
+
}
|
|
352
|
+
if (typeof session.steer !== 'function') {
|
|
353
|
+
throw new Error('installed pi SDK has no AgentSession.steer — steering unsupported');
|
|
354
|
+
}
|
|
368
355
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
356
|
+
// Deliver FIRST — a rejection here must surface as a real tool error, never
|
|
357
|
+
// a recorded-but-undelivered steer.
|
|
358
|
+
await session.steer(message);
|
|
359
|
+
|
|
360
|
+
// Audit line — best-effort. The steer already landed; an append failure is
|
|
361
|
+
// logged, not propagated.
|
|
362
|
+
try {
|
|
363
|
+
transcript.appendEntry(id, 'user', `Steering: ${message}`);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
log.warn(`steerAgent: transcript append failed for ${id}`, { error: (err as Error).message });
|
|
366
|
+
}
|
|
372
367
|
|
|
373
368
|
eventBus.emit(eventBus.createEvent('subagent:steered', agent.id, { message }));
|
|
374
369
|
|
|
@@ -376,15 +371,13 @@ export function steerAgent(id: string, message: string): BackgroundAgent | null
|
|
|
376
371
|
}
|
|
377
372
|
|
|
378
373
|
/**
|
|
379
|
-
* Get transcript path for an agent
|
|
374
|
+
* Get transcript path for an agent.
|
|
375
|
+
*
|
|
376
|
+
* Issue #276: the format lives in `transcript-path.ts` (the single source).
|
|
377
|
+
* This re-export preserves the historical `session-manager.getTranscriptPath`
|
|
378
|
+
* surface for `index.ts`'s dynamic import and the existing tests.
|
|
380
379
|
*/
|
|
381
|
-
export
|
|
382
|
-
// F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
|
|
383
|
-
// ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
|
|
384
|
-
// already validated before this is reached with a live agent.)
|
|
385
|
-
assertSafeAgentId(id);
|
|
386
|
-
return join('.pi', 'output', `agent-${id}.jsonl`);
|
|
387
|
-
}
|
|
380
|
+
export { transcriptPath as getTranscriptPath } from './transcript-path';
|
|
388
381
|
|
|
389
382
|
/**
|
|
390
383
|
* Spawn a background session using pi's session API
|
|
@@ -415,6 +408,11 @@ export async function spawnBackgroundSession(
|
|
|
415
408
|
/** Issue #114: per-unit priority — recorded on the run entry + agent record
|
|
416
409
|
* so the drill-in header and history carry it (absent → no segment). */
|
|
417
410
|
priority?: string;
|
|
411
|
+
/** Option B U1: durability identity. A retry inherits the original's
|
|
412
|
+
* dispatchId and records resumeOf/attempt; a fresh dispatch omits them. */
|
|
413
|
+
dispatchId?: string;
|
|
414
|
+
resumeOf?: string;
|
|
415
|
+
attempt?: number;
|
|
418
416
|
}
|
|
419
417
|
): Promise<BackgroundAgent> {
|
|
420
418
|
// Serialize access to pi API to prevent concurrent import races
|
|
@@ -525,6 +523,13 @@ export async function spawnBackgroundSession(
|
|
|
525
523
|
// Set session name
|
|
526
524
|
session.setSessionName(`background-${id.slice(0, 8)}`);
|
|
527
525
|
|
|
526
|
+
// Option B U1: durability identity. Background runs have no child process
|
|
527
|
+
// (the session is in-process), so there is no childMarker — only the owner
|
|
528
|
+
// identity that a boot scan uses to tell a live conductor from a dead one.
|
|
529
|
+
const dispatchId = params.dispatchId ?? generateUUID();
|
|
530
|
+
const attempt = params.attempt ?? 1;
|
|
531
|
+
const owner = currentProcessOwner();
|
|
532
|
+
|
|
528
533
|
// Create agent record
|
|
529
534
|
const agent: BackgroundAgent = {
|
|
530
535
|
id,
|
|
@@ -537,6 +542,10 @@ export async function spawnBackgroundSession(
|
|
|
537
542
|
model: params.model || 'unknown',
|
|
538
543
|
thinkingLevel: params.thinkingLevel || 'medium',
|
|
539
544
|
priority: params.priority,
|
|
545
|
+
dispatchId,
|
|
546
|
+
resumeOf: params.resumeOf,
|
|
547
|
+
attempt,
|
|
548
|
+
owner,
|
|
540
549
|
};
|
|
541
550
|
|
|
542
551
|
agents.set(id, agent);
|
|
@@ -562,8 +571,12 @@ export async function spawnBackgroundSession(
|
|
|
562
571
|
priority: params.priority,
|
|
563
572
|
startedAt: new Date().toISOString(),
|
|
564
573
|
originalParams: params.originalParams,
|
|
574
|
+
dispatchId,
|
|
575
|
+
resumeOf: params.resumeOf,
|
|
576
|
+
attempt,
|
|
577
|
+
owner,
|
|
565
578
|
};
|
|
566
|
-
pi
|
|
579
|
+
persistRunRecord(pi, run, "background");
|
|
567
580
|
|
|
568
581
|
// Issue #98: keep the session run entry in lockstep with the agent record —
|
|
569
582
|
// every terminal branch that flips agent.status also finalizes the run entry
|
|
@@ -632,7 +645,7 @@ export async function spawnBackgroundSession(
|
|
|
632
645
|
? { ...(run.originalParams ?? {}), errorCategory }
|
|
633
646
|
: run.originalParams,
|
|
634
647
|
};
|
|
635
|
-
pi
|
|
648
|
+
persistRunRecord(pi, entry);
|
|
636
649
|
// Issue #179 (D6): the explicit settle line — the log's job is to record the
|
|
637
650
|
// CLASSIFIED OUTCOME, which cwd alone can never convey.
|
|
638
651
|
log.info("Background run settled", {
|
|
@@ -717,9 +730,14 @@ export async function spawnBackgroundSession(
|
|
|
717
730
|
// hard failure — never spawn an unisolated background agent that was asked
|
|
718
731
|
// for isolation (fail-loud, same principle as foreground's fallback but
|
|
719
732
|
// background cannot warn-and-continue safely).
|
|
720
|
-
let
|
|
733
|
+
let headState: HeadState | undefined;
|
|
721
734
|
let workBranchName: string | undefined;
|
|
722
735
|
let releaseGitLock: (() => void) | undefined;
|
|
736
|
+
// Idempotency guard for cleanupWorkBranch: it must be safe to call from both
|
|
737
|
+
// the normal settle path and the catch-all that runs when a settle step
|
|
738
|
+
// throws FIRST (before its own cleanup call). Without this the second call
|
|
739
|
+
// would re-restore/re-delete and log spurious warnings.
|
|
740
|
+
let gitCleanupDone = false;
|
|
723
741
|
const gitCwd = params.cwd ?? ctx.cwd;
|
|
724
742
|
if (params.gitMode === 'branch') {
|
|
725
743
|
// C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
|
|
@@ -752,13 +770,13 @@ export async function spawnBackgroundSession(
|
|
|
752
770
|
'requesting background gitMode=branch isolation'
|
|
753
771
|
);
|
|
754
772
|
}
|
|
755
|
-
|
|
756
|
-
const branchResult = createWorkBranch(gitCwd,
|
|
773
|
+
headState = getHeadState(gitCwd);
|
|
774
|
+
const branchResult = createWorkBranch(gitCwd, headState.sha);
|
|
757
775
|
if (branchResult.ok) {
|
|
758
776
|
workBranchName = branchResult.branch;
|
|
759
777
|
log.info('Created work branch for background agent', {
|
|
760
778
|
branch: workBranchName,
|
|
761
|
-
base:
|
|
779
|
+
base: headState.branch,
|
|
762
780
|
agentId: id,
|
|
763
781
|
});
|
|
764
782
|
} else {
|
|
@@ -796,7 +814,8 @@ export async function spawnBackgroundSession(
|
|
|
796
814
|
// uncommitted edits would leak into the base working tree on switch —
|
|
797
815
|
// commitAll first makes the diff real and the switch clean.
|
|
798
816
|
const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
|
|
799
|
-
if (!workBranchName || !
|
|
817
|
+
if (gitCleanupDone || !workBranchName || !headState) return {};
|
|
818
|
+
gitCleanupDone = true;
|
|
800
819
|
const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
|
|
801
820
|
// The lock MUST be released on every exit path — early returns included.
|
|
802
821
|
try {
|
|
@@ -834,7 +853,7 @@ export async function spawnBackgroundSession(
|
|
|
834
853
|
if (workingDiff) info.gitDiff = workingDiff;
|
|
835
854
|
}
|
|
836
855
|
}
|
|
837
|
-
const diffResult = captureDiff(gitCwd,
|
|
856
|
+
const diffResult = captureDiff(gitCwd, headState.sha);
|
|
838
857
|
if (diffResult.ok && diffResult.diff.trim()) {
|
|
839
858
|
// Merge committed + working diffs — either can exist alone.
|
|
840
859
|
info.gitDiff = info.gitDiff
|
|
@@ -845,9 +864,12 @@ export async function spawnBackgroundSession(
|
|
|
845
864
|
log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
|
|
846
865
|
}
|
|
847
866
|
try {
|
|
848
|
-
|
|
867
|
+
const restoreResult = restoreHead(gitCwd, headState);
|
|
868
|
+
if (!restoreResult.ok) {
|
|
869
|
+
log.warn(`restoreHead failed for background agent ${id}`, { error: restoreResult.error });
|
|
870
|
+
}
|
|
849
871
|
} catch (err) {
|
|
850
|
-
log.warn(`
|
|
872
|
+
log.warn(`restoreHead failed for background agent ${id}`, { error: (err as Error).message });
|
|
851
873
|
}
|
|
852
874
|
try {
|
|
853
875
|
deleteBranch(gitCwd, workBranchName);
|
|
@@ -868,6 +890,11 @@ export async function spawnBackgroundSession(
|
|
|
868
890
|
// terminal so no zombie 'running' record survives; never rethrow.
|
|
869
891
|
const markTerminalBestEffort = (fallback: AgentStatus): void => {
|
|
870
892
|
try {
|
|
893
|
+
// Issue #53 / review C: this catch-all fires when a settle handler step
|
|
894
|
+
// threw — possibly BEFORE the branch's own cleanupWorkBranch() call. Run
|
|
895
|
+
// it here too (idempotent) so a throw can never bypass branch teardown
|
|
896
|
+
// and strand the work branch in the repo working tree.
|
|
897
|
+
cleanupWorkBranch();
|
|
871
898
|
// Issue #31 (PR #76 review): this catch-all fires when a settle handler
|
|
872
899
|
// threw — either BEFORE the branch's own capture (ref still live: capture
|
|
873
900
|
// now, it is the last chance to record the session's output) or AFTER it
|
|
@@ -944,9 +971,13 @@ export async function spawnBackgroundSession(
|
|
|
944
971
|
// Armed immediately after the prompt call so in-prompt preflight (auth
|
|
945
972
|
// check, model resolution) counts toward the deadline; the abort lands in
|
|
946
973
|
// the .then below as an aborted run (probe contract). We pre-set the status
|
|
947
|
-
// so the .then keeps 'stopped' with the timeout reason recorded. The
|
|
948
|
-
// is normalized
|
|
949
|
-
//
|
|
974
|
+
// so the .then keeps 'stopped' with the timeout reason recorded. The raw
|
|
975
|
+
// value is normalized here (issue #240): a raw >=2^31 or Infinity would make
|
|
976
|
+
// Node fire the timer at ~1ms (instant kill), so normalizeTimeout maps it to
|
|
977
|
+
// undefined. An explicit timeout is HONORED verbatim (no ceiling); only the
|
|
978
|
+
// no-timeout fallback uses the 30m background default. The `params.timeout`
|
|
979
|
+
// guard below keeps this timer unarmed on the no-timeout path, where the
|
|
980
|
+
// index.ts deadline timer owns the no-timeout default (no redundant timer).
|
|
950
981
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
951
982
|
if (params.timeout && params.timeout > 0) {
|
|
952
983
|
timeoutHandle = setTimeout(() => {
|
|
@@ -966,7 +997,7 @@ export async function spawnBackgroundSession(
|
|
|
966
997
|
error: (err as Error).message,
|
|
967
998
|
});
|
|
968
999
|
}
|
|
969
|
-
},
|
|
1000
|
+
}, normalizeTimeout(params.timeout) ?? DEFAULT_BACKGROUND_DEADLINE_MS);
|
|
970
1001
|
}
|
|
971
1002
|
|
|
972
1003
|
runPromise.then(() => {
|
package/src/state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Session-bound state container with versioned validation and migration for persisted settings.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — State Management (F5, F7, F9)
|
|
3
4
|
*
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
CIRCUIT_DEGRADED_THINKING,
|
|
35
36
|
} from "./types";
|
|
36
37
|
import { cleanupRuns } from "./history";
|
|
38
|
+
import { persistRunRecord, type InflightRunKind } from "./run-registry";
|
|
37
39
|
import type { Logger } from "./logging";
|
|
38
40
|
|
|
39
41
|
// ---------------------------------------------------------------------------
|
|
@@ -68,14 +70,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
|
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* Terminal-preferring run-entry lookup — the ONE implementation of the
|
|
71
|
-
* preference rule.
|
|
73
|
+
* preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
|
|
74
|
+
* boot scan and every display/retry lookup agree by construction.
|
|
72
75
|
*
|
|
73
76
|
* Each run writes TWO custom entries sharing its id, in append order: a spawn
|
|
74
77
|
* entry (status "running", carrying `originalParams`) FIRST, then the final
|
|
75
78
|
* entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
|
|
76
|
-
* settle.
|
|
77
|
-
*
|
|
78
|
-
* entry
|
|
79
|
+
* settle. Preference, in order:
|
|
80
|
+
*
|
|
81
|
+
* 1. a TERMINAL entry (status !== "running") — the run is resolved;
|
|
82
|
+
* 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
|
|
83
|
+
* append-only stores keep the original spawn entry forever, so the marked
|
|
84
|
+
* clone is the authoritative RECOVERED state U3's shared lookup reads;
|
|
85
|
+
* 3. otherwise the first match (the pre-finalize spawn shape).
|
|
86
|
+
*
|
|
87
|
+
* Returns undefined when no entry matches.
|
|
79
88
|
*/
|
|
80
89
|
export function resolveTerminalRunEntry(
|
|
81
90
|
entries: SubagentRun[],
|
|
@@ -83,7 +92,37 @@ export function resolveTerminalRunEntry(
|
|
|
83
92
|
): SubagentRun | undefined {
|
|
84
93
|
const matching = entries.filter((r) => r.id === id);
|
|
85
94
|
if (matching.length === 0) return undefined;
|
|
86
|
-
return
|
|
95
|
+
return (
|
|
96
|
+
matching.find((r) => r.status !== "running") ??
|
|
97
|
+
matching.find((r) => r.interruptedAt) ??
|
|
98
|
+
matching[0]
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run-history display projection (issue #259): ONE record per SETTLED run.
|
|
104
|
+
*
|
|
105
|
+
* Each run appends two entries sharing its id — a spawn entry (status
|
|
106
|
+
* "running") first, then the terminal entry at settle — so a raw entry list
|
|
107
|
+
* renders every run twice and the spawn row shows a stale "running" forever.
|
|
108
|
+
* This collapses each id to its terminal-preferred entry via the ONE shared
|
|
109
|
+
* rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
|
|
110
|
+
* terminal entry yet) are omitted: live state belongs to the monitor.
|
|
111
|
+
*
|
|
112
|
+
* Ordering is preserved from `entries`; callers pass `getRunEntries` output
|
|
113
|
+
* (newest-first via `cleanupRuns`), which stays the single ordering authority —
|
|
114
|
+
* no re-sort is performed here.
|
|
115
|
+
*/
|
|
116
|
+
export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
|
|
117
|
+
const display: SubagentRun[] = [];
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (seen.has(entry.id)) continue;
|
|
121
|
+
seen.add(entry.id);
|
|
122
|
+
const resolved = resolveTerminalRunEntry(entries, entry.id);
|
|
123
|
+
if (resolved && resolved.status !== "running") display.push(resolved);
|
|
124
|
+
}
|
|
125
|
+
return display;
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
// ---------------------------------------------------------------------------
|
|
@@ -193,8 +232,14 @@ export class SessionState {
|
|
|
193
232
|
});
|
|
194
233
|
}
|
|
195
234
|
|
|
196
|
-
|
|
197
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Persist a run record through the ONE choke point (`persistRunRecord`),
|
|
237
|
+
* which appends the session entry AND mirrors the record into the durable
|
|
238
|
+
* in-flight registry. `kind` is supplied at dispatch; terminal writes clear
|
|
239
|
+
* the registry entry.
|
|
240
|
+
*/
|
|
241
|
+
persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
|
|
242
|
+
persistRunRecord(pi, run, kind);
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
// -------------------------------------------------------------------
|
package/src/templates.ts
CHANGED
|
@@ -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-tail.ts
CHANGED
package/src/transcript.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
|
|
2
2
|
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
|
|
3
3
|
import type { TranscriptEntry, TranscriptEntryType } from './types';
|
|
4
|
-
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');
|
|
5
10
|
|
|
6
11
|
// Output directory — overridable for tests (issue #52): unit tests must NOT
|
|
7
|
-
// write into the real repo .pi/ dir; the setter is test-only.
|
|
8
|
-
|
|
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;
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* TEST-ONLY: point the transcript output at an alternate directory.
|
|
@@ -29,13 +35,9 @@ function ensureOutputDir(): void {
|
|
|
29
35
|
* Get transcript file path for an agent
|
|
30
36
|
*/
|
|
31
37
|
export function getTranscriptPath(agentId: string): string {
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
// getTranscript. path.join does not sanitize "../" or absolute segments, so
|
|
36
|
-
// throw on anything that is not a UUID before it reaches the filesystem.
|
|
37
|
-
assertSafeAgentId(agentId);
|
|
38
|
-
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);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -117,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
|
|
|
117
119
|
// appending — that invariant is load-bearing for caller validation).
|
|
118
120
|
const path = getTranscriptPath(agentId);
|
|
119
121
|
if (!existsSync(path)) {
|
|
120
|
-
|
|
122
|
+
log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
|
|
121
123
|
return;
|
|
122
124
|
}
|
|
123
125
|
appendEntry(agentId, 'system', `Transcript completed: ${status}`);
|
package/src/tui-format.ts
CHANGED