brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
4
|
+
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats } from './types';
|
|
5
|
+
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
6
|
+
import { accumulateUsage } from './runner';
|
|
7
|
+
import * as eventBus from './event-bus';
|
|
8
|
+
import * as transcript from './transcript';
|
|
9
|
+
import { createEvent } from './event-bus';
|
|
10
|
+
import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
|
|
11
|
+
import { wrapTask } from './prompt';
|
|
12
|
+
import { createLogger } from './logging';
|
|
13
|
+
import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, commitAll, captureWorkingDiff } from './git';
|
|
14
|
+
|
|
15
|
+
const log = createLogger('brl-subagent');
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
17
|
+
|
|
18
|
+
// Serialize concurrent spawn attempts — pi's API modules aren't safe for
|
|
19
|
+
// concurrent access from extensions.
|
|
20
|
+
var spawnQueue: Promise<void> = Promise.resolve();
|
|
21
|
+
|
|
22
|
+
// C2: per-repo locks serializing the FULL gitMode=branch lifecycle (setup →
|
|
23
|
+
// settle → teardown). Keyed by cwd because different repos don't conflict.
|
|
24
|
+
const gitBranchLocks = new Map<string, Promise<void>>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* TEST-ONLY: release every held git branch lock. The lock is normally held
|
|
28
|
+
* for the agent's lifetime (released at settle), but tests that never settle
|
|
29
|
+
* (hanging prompt mocks) would leak it into the next test.
|
|
30
|
+
*/
|
|
31
|
+
export function __testResetGitBranchLocks(): void {
|
|
32
|
+
gitBranchLocks.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Robust UUID generation with fallback
|
|
36
|
+
function generateUUID(): string {
|
|
37
|
+
try {
|
|
38
|
+
return randomUUID();
|
|
39
|
+
} catch {
|
|
40
|
+
// Fallback for contexts where crypto is not available
|
|
41
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
42
|
+
const r = (Math.random() * 16) | 0;
|
|
43
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
44
|
+
return v.toString(16);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// In-memory store of background agents
|
|
50
|
+
const agents = new Map<string, BackgroundAgent>();
|
|
51
|
+
|
|
52
|
+
// Persistence directory — overridable for tests (issue #52): unit tests must
|
|
53
|
+
// NOT write into the real repo .pi/ dir; the setter is test-only.
|
|
54
|
+
let STORAGE_DIR = '.pi/subagents';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* TEST-ONLY: point the agent-record storage at an alternate directory.
|
|
58
|
+
* The tests call this in beforeEach to isolate from the real repo .pi/.
|
|
59
|
+
*/
|
|
60
|
+
export function __setStorageDir(dir: string): void {
|
|
61
|
+
STORAGE_DIR = dir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ensure storage directory exists
|
|
66
|
+
*/
|
|
67
|
+
function ensureStorageDir(): void {
|
|
68
|
+
// F6 (issue #29): records hold the full task/result conversation — the dir
|
|
69
|
+
// must be owner-only (0o700) so other local users cannot list the files.
|
|
70
|
+
mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Persist agent record to disk
|
|
75
|
+
*/
|
|
76
|
+
function persistAgent(agent: BackgroundAgent): void {
|
|
77
|
+
// F24: agent.id may come from a disk-loaded record (loadAgent JSON.parse's
|
|
78
|
+
// the id field unvalidated). A planted record with a traversal id would
|
|
79
|
+
// make join() escape STORAGE_DIR — validate BEFORE building the write path.
|
|
80
|
+
try {
|
|
81
|
+
assertSafeAgentId(agent.id);
|
|
82
|
+
} catch {
|
|
83
|
+
console.error(`[brl-subagent] Refusing to persist agent with invalid id: ${agent.id}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
ensureStorageDir();
|
|
87
|
+
const filePath = join(STORAGE_DIR, `${agent.id}.json`);
|
|
88
|
+
// Strip live/non-serializable fields before stringify — `_sessionRef` holds the
|
|
89
|
+
// ENTIRE live pi session graph (1.4MB+ for an empty session, and uninitialized
|
|
90
|
+
// getters like Theme can make JSON.stringify throw).
|
|
91
|
+
const { _sessionRef, ...persistable } = agent;
|
|
92
|
+
try {
|
|
93
|
+
// F6 (issue #29): records contain task, error, result.messages (full
|
|
94
|
+
// conversation) and finalOutput — write owner-only (0o600) so other local
|
|
95
|
+
// users cannot read subagent tasks/output. mode applies on file CREATE;
|
|
96
|
+
// pre-existing files keep their old mode (not retroactive).
|
|
97
|
+
writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Log but never throw — persistence must not break execution
|
|
100
|
+
console.error(`[brl-subagent] Failed to persist agent ${agent.id}:`, err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load agent record from disk
|
|
106
|
+
*/
|
|
107
|
+
function loadAgent(id: string): BackgroundAgent | null {
|
|
108
|
+
const filePath = join(STORAGE_DIR, `${id}.json`);
|
|
109
|
+
if (!existsSync(filePath)) return null;
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a new background agent session
|
|
119
|
+
*
|
|
120
|
+
* NOTE: In v2.0.3, this creates a record but does NOT actually spawn a pi session.
|
|
121
|
+
* The actual session spawning will be implemented when pi's ExtensionAPI supports it.
|
|
122
|
+
* For now, this is a placeholder that creates the agent record.
|
|
123
|
+
*/
|
|
124
|
+
export function createSession(params: {
|
|
125
|
+
task: string;
|
|
126
|
+
type?: string;
|
|
127
|
+
description?: string;
|
|
128
|
+
model?: string;
|
|
129
|
+
thinkingLevel?: ThinkingLevel;
|
|
130
|
+
systemPrompt?: string;
|
|
131
|
+
}): BackgroundAgent {
|
|
132
|
+
const id = generateUUID();
|
|
133
|
+
const agent: BackgroundAgent = {
|
|
134
|
+
id,
|
|
135
|
+
sessionId: `session-${id}`,
|
|
136
|
+
type: params.type || 'general-purpose',
|
|
137
|
+
description: params.description || params.task.slice(0, 50),
|
|
138
|
+
status: 'pending',
|
|
139
|
+
startedAt: Date.now(),
|
|
140
|
+
task: params.task,
|
|
141
|
+
model: params.model || 'unknown',
|
|
142
|
+
thinkingLevel: params.thinkingLevel || 'medium',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
agents.set(id, agent);
|
|
146
|
+
persistAgent(agent);
|
|
147
|
+
|
|
148
|
+
// Start transcript for this agent
|
|
149
|
+
transcript.startTranscript(agent.id, params.task);
|
|
150
|
+
|
|
151
|
+
eventBus.emit(eventBus.createEvent('subagent:created', agent.id, {
|
|
152
|
+
type: agent.type,
|
|
153
|
+
description: agent.description,
|
|
154
|
+
task: agent.task,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
return agent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get agent record by ID
|
|
162
|
+
*/
|
|
163
|
+
export function getAgent(id: string): BackgroundAgent | null {
|
|
164
|
+
// F24: id may arrive from LLM tool params (get_agent_status, steer_subagent,
|
|
165
|
+
// get_agent_result). Validate BEFORE any map/fs access — path.join does not
|
|
166
|
+
// sanitize "../" or absolute segments, so a traversal id like "../../etc/foo"
|
|
167
|
+
// would otherwise read arbitrary .json files via loadAgent(). Invalid ids
|
|
168
|
+
// surface as "agent not found" to tool callers.
|
|
169
|
+
try {
|
|
170
|
+
assertSafeAgentId(id);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return agents.get(id) || loadAgent(id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* List all background agents
|
|
179
|
+
*/
|
|
180
|
+
export function listAgents(): BackgroundAgent[] {
|
|
181
|
+
ensureStorageDir();
|
|
182
|
+
const files = readdirSync(STORAGE_DIR).filter(f => f.endsWith('.json'));
|
|
183
|
+
const result: BackgroundAgent[] = [];
|
|
184
|
+
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const id = file.replace('.json', '');
|
|
187
|
+
const agent = agents.get(id) || loadAgent(id);
|
|
188
|
+
if (agent) result.push(agent);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result.sort((a, b) => b.startedAt - a.startedAt);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update agent status
|
|
196
|
+
*/
|
|
197
|
+
export function updateAgentStatus(id: string, status: AgentStatus, error?: string): BackgroundAgent | null {
|
|
198
|
+
const agent = getAgent(id);
|
|
199
|
+
if (!agent) return null;
|
|
200
|
+
|
|
201
|
+
agent.status = status;
|
|
202
|
+
if (status === 'completed' || status === 'failed' || status === 'stopped') {
|
|
203
|
+
agent.completedAt = Date.now();
|
|
204
|
+
}
|
|
205
|
+
if (error) agent.error = error;
|
|
206
|
+
|
|
207
|
+
agents.set(id, agent);
|
|
208
|
+
persistAgent(agent);
|
|
209
|
+
|
|
210
|
+
if (status === 'running') {
|
|
211
|
+
eventBus.emit(eventBus.createEvent('subagent:started', id, {}));
|
|
212
|
+
} else if (status === 'completed') {
|
|
213
|
+
eventBus.emit(eventBus.createEvent('subagent:completed', id, { error }));
|
|
214
|
+
} else if (status === 'failed') {
|
|
215
|
+
eventBus.emit(eventBus.createEvent('subagent:failed', id, { error }));
|
|
216
|
+
} else if (status === 'stopped') {
|
|
217
|
+
eventBus.emit(eventBus.createEvent('subagent:stopped', id, {}));
|
|
218
|
+
} else if (status === 'steered') {
|
|
219
|
+
eventBus.emit(eventBus.createEvent('subagent:steered', id, {}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return agent;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Set agent result
|
|
227
|
+
*/
|
|
228
|
+
export function setAgentResult(id: string, result: SubagentResult): BackgroundAgent | null {
|
|
229
|
+
const agent = getAgent(id);
|
|
230
|
+
if (!agent) return null;
|
|
231
|
+
|
|
232
|
+
agent.result = result;
|
|
233
|
+
agent.status = result.exitCode === 0 ? 'completed' : 'failed';
|
|
234
|
+
agent.completedAt = Date.now();
|
|
235
|
+
|
|
236
|
+
agents.set(id, agent);
|
|
237
|
+
persistAgent(agent);
|
|
238
|
+
return agent;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extract the final assistant text from a session's messages.
|
|
243
|
+
* Falls back to the LAST assistant message with non-empty text content — the
|
|
244
|
+
* final message is often a tool-call-only turn (agent stopped mid-turn, failed,
|
|
245
|
+
* or hit the hard cap), and that turn carries no text of its own.
|
|
246
|
+
*/
|
|
247
|
+
export function extractFinalOutput(session: { messages: Array<{ role: string; content?: Array<{ type: string; text?: string }> | string | null }> }): string {
|
|
248
|
+
const assistants = [...session.messages].reverse().filter(m => m.role === 'assistant');
|
|
249
|
+
for (const msg of assistants) {
|
|
250
|
+
const content = msg.content;
|
|
251
|
+
let text = '';
|
|
252
|
+
if (Array.isArray(content)) {
|
|
253
|
+
text = content.filter(c => c.type === 'text').map(c => c.text ?? '').join('');
|
|
254
|
+
} else if (typeof content === 'string') {
|
|
255
|
+
text = content;
|
|
256
|
+
}
|
|
257
|
+
if (text.trim()) return text;
|
|
258
|
+
}
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Set the final assistant output captured from the agent's session
|
|
264
|
+
*/
|
|
265
|
+
export function setAgentFinalOutput(id: string, output: string): BackgroundAgent | null {
|
|
266
|
+
const agent = getAgent(id);
|
|
267
|
+
if (!agent) return null;
|
|
268
|
+
|
|
269
|
+
agent.finalOutput = output;
|
|
270
|
+
|
|
271
|
+
agents.set(id, agent);
|
|
272
|
+
persistAgent(agent);
|
|
273
|
+
return agent;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Stop a running agent — REAL abort (PR #28 W1).
|
|
278
|
+
*
|
|
279
|
+
* Probe contract (sdk-abort-contract.test.ts): session.prompt() RESOLVES on
|
|
280
|
+
* abort — the SDK's runWithLifecycle catches the AbortError and converts it
|
|
281
|
+
* into a failure message with stopReason "aborted". So:
|
|
282
|
+
* 1. set status 'stopped' FIRST (the .then in spawnBackgroundSession will
|
|
283
|
+
* fire after abort settles and must not overwrite it), then
|
|
284
|
+
* 2. await session.abort() (waits for the agent to become idle), then
|
|
285
|
+
* 3. complete the transcript.
|
|
286
|
+
*/
|
|
287
|
+
export async function stopAgent(id: string): Promise<BackgroundAgent | null> {
|
|
288
|
+
const agent = getAgent(id);
|
|
289
|
+
if (!agent) return null;
|
|
290
|
+
// Nothing to stop — already terminal.
|
|
291
|
+
if (agent.status === 'completed' || agent.status === 'failed' || agent.status === 'stopped') {
|
|
292
|
+
return agent;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Mark stopped BEFORE aborting so the spawnBackgroundSession .then handler
|
|
296
|
+
// (which fires after the run settles) keeps the stopped state.
|
|
297
|
+
updateAgentStatus(id, 'stopped');
|
|
298
|
+
|
|
299
|
+
const session = agent._sessionRef;
|
|
300
|
+
if (session) {
|
|
301
|
+
try {
|
|
302
|
+
await session.abort();
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// Abort can throw if the session is mid-dispose; the status flip above
|
|
305
|
+
// is already recorded, so this is non-fatal.
|
|
306
|
+
log.warn(`stopAgent: abort failed for ${id}`, { error: (err as Error).message });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
transcript.completeTranscript(id, 'stopped');
|
|
311
|
+
return agent;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Steer a running agent by injecting a message
|
|
316
|
+
*
|
|
317
|
+
* NOTE: In v2.0.3, this is a placeholder.
|
|
318
|
+
* Actual message injection will be implemented when pi's ExtensionAPI supports it.
|
|
319
|
+
*/
|
|
320
|
+
export function steerAgent(id: string, message: string): BackgroundAgent | null {
|
|
321
|
+
const agent = getAgent(id);
|
|
322
|
+
if (!agent) return null;
|
|
323
|
+
if (agent.status !== 'running') {
|
|
324
|
+
throw new Error(`Cannot steer agent ${id}: status is ${agent.status}, not running`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Record steering in transcript
|
|
328
|
+
transcript.appendEntry(id, 'user', `Steering: ${message}`);
|
|
329
|
+
|
|
330
|
+
agent.status = 'steered';
|
|
331
|
+
agents.set(id, agent);
|
|
332
|
+
persistAgent(agent);
|
|
333
|
+
|
|
334
|
+
eventBus.emit(eventBus.createEvent('subagent:steered', agent.id, { message }));
|
|
335
|
+
|
|
336
|
+
return agent;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get transcript path for an agent
|
|
341
|
+
*/
|
|
342
|
+
export function getTranscriptPath(id: string): string {
|
|
343
|
+
// F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
|
|
344
|
+
// ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
|
|
345
|
+
// already validated before this is reached with a live agent.)
|
|
346
|
+
assertSafeAgentId(id);
|
|
347
|
+
return join('.pi', 'output', `agent-${id}.jsonl`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Spawn a background session using pi's session API
|
|
352
|
+
*
|
|
353
|
+
* This creates a real pi session that runs independently.
|
|
354
|
+
* The session can be polled later with getAgent() or steered with steerAgent().
|
|
355
|
+
*/
|
|
356
|
+
export async function spawnBackgroundSession(
|
|
357
|
+
pi: ExtensionAPI,
|
|
358
|
+
ctx: ExtensionContext,
|
|
359
|
+
params: {
|
|
360
|
+
task: string;
|
|
361
|
+
type?: string;
|
|
362
|
+
description?: string;
|
|
363
|
+
model?: string;
|
|
364
|
+
thinkingLevel?: ThinkingLevel;
|
|
365
|
+
systemPrompt?: string;
|
|
366
|
+
cwd?: string;
|
|
367
|
+
toolOptions?: SubagentToolOptions;
|
|
368
|
+
/** Per-agent deadline in ms — the session is aborted when exceeded (issue #28 W3). */
|
|
369
|
+
timeout?: number;
|
|
370
|
+
/** Git isolation for the background run — "branch" creates a work branch (issue #28 W4). */
|
|
371
|
+
gitMode?: GitMode;
|
|
372
|
+
/** Issue #98: snapshot of the caller's raw params — lets retryRunId restore
|
|
373
|
+
* the original model/thinkingLevel/preset for background runs (mirrors the
|
|
374
|
+
* foreground run-record creation in index.ts). */
|
|
375
|
+
originalParams?: SubagentRun["originalParams"];
|
|
376
|
+
/** Issue #114: per-unit priority — recorded on the run entry + agent record
|
|
377
|
+
* so the drill-in header and history carry it (absent → no segment). */
|
|
378
|
+
priority?: string;
|
|
379
|
+
}
|
|
380
|
+
): Promise<BackgroundAgent> {
|
|
381
|
+
// Serialize access to pi API to prevent concurrent import races
|
|
382
|
+
const prev = spawnQueue;
|
|
383
|
+
let resolveNext!: () => void;
|
|
384
|
+
spawnQueue = new Promise<void>(r => { resolveNext = r; });
|
|
385
|
+
await prev;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const id = generateUUID();
|
|
389
|
+
const effectiveCwd = params.cwd ?? ctx.cwd;
|
|
390
|
+
// Dynamic import — static import fails under concurrent jiti loads
|
|
391
|
+
const { getAgentDir, createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader } = await import('@earendil-works/pi-coding-agent');
|
|
392
|
+
const agentDir = getAgentDir();
|
|
393
|
+
|
|
394
|
+
// Resolve the model STRING to a real Model object — createAgentSession expects
|
|
395
|
+
// Model<any>, not "provider/id". Passing a string makes provider resolution
|
|
396
|
+
// yield undefined ("No API key found for undefined") and clamps thinking to "off".
|
|
397
|
+
// Falls back to undefined → SDK's findInitialModel picks the settings default.
|
|
398
|
+
let resolvedModel;
|
|
399
|
+
if (params.model) {
|
|
400
|
+
const slashIdx = params.model.indexOf('/');
|
|
401
|
+
if (slashIdx > 0) {
|
|
402
|
+
const provider = params.model.slice(0, slashIdx);
|
|
403
|
+
const modelId = params.model.slice(slashIdx + 1);
|
|
404
|
+
resolvedModel = ctx.modelRegistry.find(provider, modelId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Create session manager for this background agent
|
|
409
|
+
const sessionManager = SessionManager.inMemory(effectiveCwd);
|
|
410
|
+
const settingsManager = SettingsManager.create(effectiveCwd);
|
|
411
|
+
|
|
412
|
+
// E20: Inject the subagent prompt into the session's system prompt.
|
|
413
|
+
// The SDK has no systemPrompt option on createAgentSession — the sanctioned
|
|
414
|
+
// injection point is the resource loader's appendSystemPrompt (the same
|
|
415
|
+
// mechanism pi uses for --append-system-prompt).
|
|
416
|
+
//
|
|
417
|
+
// F26: resolvePromptInput (resource-loader.js:16) checks existsSync(input)
|
|
418
|
+
// and readFileSync's the string if it matches a real path — a raw prompt
|
|
419
|
+
// value like ".env" or "package.json" would be silently REPLACED by that
|
|
420
|
+
// file's contents (arbitrary-file-read-into-prompt).
|
|
421
|
+
//
|
|
422
|
+
// The defense is NOT "newlines are invalid in filenames" (false on POSIX —
|
|
423
|
+
// filenames can contain \n). The actual mechanism:
|
|
424
|
+
// 1. The "/" in "</system-prompt>" forces a multi-component RELATIVE path
|
|
425
|
+
// (never absolute — leading \n guarantees no "/" prefix), so the
|
|
426
|
+
// wrapped value can never equal a single-component name like ".env".
|
|
427
|
+
// 2. buildSubagentPrompt ALWAYS appends SUBAGENT_INSTRUCTIONS (measured
|
|
428
|
+
// 1644 bytes, no "/"), so one contiguous path component exceeds
|
|
429
|
+
// NAME_MAX=255 on mainstream filesystems → the path can never be
|
|
430
|
+
// created → existsSync is always false.
|
|
431
|
+
// 3. Windows: \n, <, > are illegal in filenames — impossible outright.
|
|
432
|
+
//
|
|
433
|
+
// Residual (theoretical): a filesystem with component limit > ~1663 bytes,
|
|
434
|
+
// cwd write access, AND byte-exact prediction of the full prompt could
|
|
435
|
+
// plant a newline-named dir + symlink leaf. Not exploitable realistically;
|
|
436
|
+
// do not "simplify" the frame (e.g. dropping the "/" marker) without
|
|
437
|
+
// re-verifying this property.
|
|
438
|
+
const literalPrompt = params.systemPrompt?.trim()
|
|
439
|
+
? `\n<system-prompt>\n${params.systemPrompt}\n</system-prompt>\n`
|
|
440
|
+
: undefined;
|
|
441
|
+
//
|
|
442
|
+
// F25: ALWAYS build our own loader (even without a systemPrompt). If
|
|
443
|
+
// resourceLoader is undefined, createAgentSession constructs its own
|
|
444
|
+
// DefaultResourceLoader({ cwd, agentDir, settingsManager }) and reload()s it
|
|
445
|
+
// — importing extension/skill code from the LLM-controlled target cwd into
|
|
446
|
+
// THIS process (RCE; background mode has no trust prompt).
|
|
447
|
+
//
|
|
448
|
+
// noExtensions + noSkills are BLANKET (they also disable user-global
|
|
449
|
+
// extensions/skills from agentDir, e.g. ~/.pi/agent/skills). This is a
|
|
450
|
+
// deliberate security choice: background sessions have no trust prompt, so
|
|
451
|
+
// nothing is imported from anywhere — the prompt is fully specified by the
|
|
452
|
+
// caller. Users needing skills/extensions should use foreground delegation.
|
|
453
|
+
const loader = new DefaultResourceLoader({
|
|
454
|
+
cwd: effectiveCwd,
|
|
455
|
+
agentDir,
|
|
456
|
+
settingsManager,
|
|
457
|
+
...(literalPrompt ? { appendSystemPrompt: [literalPrompt] } : {}),
|
|
458
|
+
noExtensions: true,
|
|
459
|
+
noSkills: true,
|
|
460
|
+
});
|
|
461
|
+
await loader.reload();
|
|
462
|
+
const resourceLoader: InstanceType<typeof DefaultResourceLoader> = loader;
|
|
463
|
+
|
|
464
|
+
// Create the session — honor the resolved tool restrictions so the
|
|
465
|
+
// background agent's ACTUAL toolset matches what the prompt tells it
|
|
466
|
+
// (and what H1 validation checked). Pre-PR this was a hardcoded full
|
|
467
|
+
// toolset, so an agent told "read-only" by the prompt still had write.
|
|
468
|
+
const bgTools = params.toolOptions?.tools;
|
|
469
|
+
const bgExcludeTools = params.toolOptions?.excludeTools;
|
|
470
|
+
const { session } = await createAgentSession({
|
|
471
|
+
cwd: effectiveCwd,
|
|
472
|
+
agentDir,
|
|
473
|
+
sessionManager,
|
|
474
|
+
settingsManager,
|
|
475
|
+
// NOTE: modelRegistry is NOT part of CreateAgentSessionOptions (pi 0.84
|
|
476
|
+
// SDK surface) and the runtime never reads it — the model is passed
|
|
477
|
+
// explicitly via `model: resolvedModel` above, so this option was dead.
|
|
478
|
+
resourceLoader,
|
|
479
|
+
model: resolvedModel,
|
|
480
|
+
thinkingLevel: params.thinkingLevel,
|
|
481
|
+
tools: bgTools ?? ['read', 'bash', 'grep', 'find', 'ls', 'write', 'edit'],
|
|
482
|
+
...(bgExcludeTools ? { excludeTools: bgExcludeTools } : {}),
|
|
483
|
+
...(params.toolOptions?.noBuiltinTools ? { noTools: 'builtin' as const } : {}),
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Set session name
|
|
487
|
+
session.setSessionName(`background-${id.slice(0, 8)}`);
|
|
488
|
+
|
|
489
|
+
// Create agent record
|
|
490
|
+
const agent: BackgroundAgent = {
|
|
491
|
+
id,
|
|
492
|
+
sessionId: session.sessionId ?? id,
|
|
493
|
+
type: params.type || 'general-purpose',
|
|
494
|
+
description: params.description || params.task.slice(0, 50),
|
|
495
|
+
status: 'running',
|
|
496
|
+
startedAt: Date.now(),
|
|
497
|
+
task: params.task,
|
|
498
|
+
model: params.model || 'unknown',
|
|
499
|
+
thinkingLevel: params.thinkingLevel || 'medium',
|
|
500
|
+
priority: params.priority,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
agents.set(id, agent);
|
|
504
|
+
persistAgent(agent);
|
|
505
|
+
|
|
506
|
+
// Issue #98: background runs must be retry-able — persist a session run
|
|
507
|
+
// entry at spawn (id == agent id, so retryRunId: <agent-id> resolves via
|
|
508
|
+
// state.findRunById → getRunEntries). The entry lives in the same session
|
|
509
|
+
// custom-entry store the foreground path writes to (state.persistRun); the
|
|
510
|
+
// background path previously never wrote there, so retries silently no-oped.
|
|
511
|
+
const startedAtMs = agent.startedAt; // reuse the agent record's start clock
|
|
512
|
+
const run: SubagentRun = {
|
|
513
|
+
id, // SAME as agent.id — the caller holds this id
|
|
514
|
+
task: params.task,
|
|
515
|
+
description: params.description,
|
|
516
|
+
// Issue #98 (review F3): history/reports consumers read run.label —
|
|
517
|
+
// the background label (params.description) must land there too, not
|
|
518
|
+
// only in description.
|
|
519
|
+
label: params.description,
|
|
520
|
+
status: "running",
|
|
521
|
+
model: params.model || "unknown",
|
|
522
|
+
thinkingLevel: params.thinkingLevel || "medium",
|
|
523
|
+
priority: params.priority,
|
|
524
|
+
startedAt: new Date().toISOString(),
|
|
525
|
+
originalParams: params.originalParams,
|
|
526
|
+
};
|
|
527
|
+
pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
|
|
528
|
+
|
|
529
|
+
// Issue #98: keep the session run entry in lockstep with the agent record —
|
|
530
|
+
// every terminal branch that flips agent.status also finalizes the run entry
|
|
531
|
+
// ("done"/"failed"). The guard keeps the entry single-finalized even when a
|
|
532
|
+
// settle handler throws after finalizing (markTerminalBestEffort catch-all).
|
|
533
|
+
let runFinalized = false;
|
|
534
|
+
const finalizeRunEntry = (status: "done" | "failed", error?: string): void => {
|
|
535
|
+
if (runFinalized) return;
|
|
536
|
+
runFinalized = true;
|
|
537
|
+
const usage = agent.result?.usage ?? EMPTY_USAGE;
|
|
538
|
+
const finalOutput = agent.finalOutput ?? "";
|
|
539
|
+
// Issue #120 (Track 1): stamp the honest errorCategory on the background run
|
|
540
|
+
// record the same way the foreground path does (index.ts sets
|
|
541
|
+
// run.originalParams.errorCategory = classifyError(result)). Previously the
|
|
542
|
+
// background entry never carried a category, so background failures were
|
|
543
|
+
// invisible to the SLA errorCategoryBreakdown and to failure diagnostics.
|
|
544
|
+
const errorCategory =
|
|
545
|
+
status === "failed" && error
|
|
546
|
+
? classifyError({
|
|
547
|
+
messages: [],
|
|
548
|
+
usage: { ...EMPTY_USAGE },
|
|
549
|
+
exitCode: 1,
|
|
550
|
+
stderr: "",
|
|
551
|
+
errorMessage: error,
|
|
552
|
+
})
|
|
553
|
+
: undefined;
|
|
554
|
+
const entry: SubagentRun = {
|
|
555
|
+
...run,
|
|
556
|
+
status,
|
|
557
|
+
errorMessage: error,
|
|
558
|
+
finishedAt: new Date().toISOString(),
|
|
559
|
+
durationMs: Date.now() - startedAtMs,
|
|
560
|
+
// Issue #122: audit fields — usage and final output are already
|
|
561
|
+
// captured on the agent record before the terminal finalize runs.
|
|
562
|
+
cost: usage.cost,
|
|
563
|
+
tokensIn: usage.input,
|
|
564
|
+
tokensOut: usage.output,
|
|
565
|
+
outputSummary: finalOutput.slice(0, 200),
|
|
566
|
+
fullOutput: finalOutput || undefined,
|
|
567
|
+
originalParams:
|
|
568
|
+
errorCategory
|
|
569
|
+
? { ...(run.originalParams ?? {}), errorCategory }
|
|
570
|
+
: run.originalParams,
|
|
571
|
+
};
|
|
572
|
+
pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Start transcript for this agent
|
|
576
|
+
transcript.startTranscript(agent.id, params.task);
|
|
577
|
+
|
|
578
|
+
// Store session ref for live monitor polling
|
|
579
|
+
agent._sessionRef = session;
|
|
580
|
+
|
|
581
|
+
// Issue #31 (PR #76 review, DRY): every terminal branch must capture the
|
|
582
|
+
// final output while the session is still live, THEN release the ref — the
|
|
583
|
+
// capture must always precede the release or the output dies with the
|
|
584
|
+
// session graph. Single helper for all four settlement paths.
|
|
585
|
+
const captureAndReleaseSession = (): void => {
|
|
586
|
+
setAgentFinalOutput(id, extractFinalOutput(session));
|
|
587
|
+
agent._sessionRef = undefined;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// Issue #122: the session's REAL usage lives on the assistant messages in
|
|
591
|
+
// session.messages — each turn carries the same usage shape accumulateUsage
|
|
592
|
+
// folds (the runner's single source of truth for usage semantics: turns++,
|
|
593
|
+
// sum input/output/cache, cost.total, contextTokens from totalTokens). Fold
|
|
594
|
+
// the session's assistant turns into a fresh stats object and MERGE it onto
|
|
595
|
+
// the agent's result record — never replace: the git-diff branches may have
|
|
596
|
+
// already set exitCode/messages/stderr/gitBranch/gitDiff, and the aborted/
|
|
597
|
+
// failed branches previously OVERWROTE usage with the empty fallback. Runs
|
|
598
|
+
// on every terminal path right before the finalize; must never throw
|
|
599
|
+
// (best-effort, same discipline as markTerminalBestEffort).
|
|
600
|
+
const recordSessionUsage = (): void => {
|
|
601
|
+
try {
|
|
602
|
+
const usage: UsageStats = { ...EMPTY_USAGE };
|
|
603
|
+
for (const msg of session.messages ?? []) {
|
|
604
|
+
if (msg.role === 'assistant') {
|
|
605
|
+
accumulateUsage(usage, msg.usage as Parameters<typeof accumulateUsage>[1]);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
agent.result = {
|
|
609
|
+
// Issue #122: seed the FULL required SubagentResult shape (messages /
|
|
610
|
+
// exitCode / stderr) BEFORE the existing record is spread — the seed
|
|
611
|
+
// only fills fields the record doesn't already have, so real values
|
|
612
|
+
// from the git-diff branches (exitCode 1 on aborted/failed, 0 on
|
|
613
|
+
// completed, plus messages/stderr) always win. A seed AFTER the spread
|
|
614
|
+
// would clobber a real exitCode: 1 back to 0. usage is folded below
|
|
615
|
+
// and merged last so the REAL session usage replaces the branches'
|
|
616
|
+
// EMPTY_USAGE placeholder.
|
|
617
|
+
messages: [],
|
|
618
|
+
exitCode: 0,
|
|
619
|
+
stderr: '',
|
|
620
|
+
...(agent.result ?? {}),
|
|
621
|
+
usage,
|
|
622
|
+
} as SubagentResult;
|
|
623
|
+
} catch {
|
|
624
|
+
// Usage extraction is best-effort — it must never block the terminal
|
|
625
|
+
// flip, the finalize, or the ref release.
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// W4 (issue #28): git isolation — create a work branch BEFORE the prompt so
|
|
630
|
+
// the background agent's file writes land on the branch, never on the
|
|
631
|
+
// working tree's base branch. There is no interactive approval in background
|
|
632
|
+
// mode, so on completion the diff is captured and the branch is DISCARDED
|
|
633
|
+
// (the diff is recorded on the agent for review); on failure/abort the
|
|
634
|
+
// branch is discarded the same way. A branch that cannot be created is a
|
|
635
|
+
// hard failure — never spawn an unisolated background agent that was asked
|
|
636
|
+
// for isolation (fail-loud, same principle as foreground's fallback but
|
|
637
|
+
// background cannot warn-and-continue safely).
|
|
638
|
+
let originalBranch: string | undefined;
|
|
639
|
+
let workBranchName: string | undefined;
|
|
640
|
+
let releaseGitLock: (() => void) | undefined;
|
|
641
|
+
const gitCwd = params.cwd ?? ctx.cwd;
|
|
642
|
+
if (params.gitMode === 'branch') {
|
|
643
|
+
// C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
|
|
644
|
+
// covers setup; without this lock, a second concurrent branch-mode spawn
|
|
645
|
+
// would read the first's work branch as its base and both teardowns would
|
|
646
|
+
// fight over the shared working tree (stranding the repo on an orphan
|
|
647
|
+
// branch). The lock is released in cleanupWorkBranch (or on setup throw).
|
|
648
|
+
const prev = gitBranchLocks.get(gitCwd) ?? Promise.resolve();
|
|
649
|
+
let release!: () => void;
|
|
650
|
+
const gate = new Promise<void>(r => { release = r; });
|
|
651
|
+
const entry = prev.then(() => gate);
|
|
652
|
+
gitBranchLocks.set(gitCwd, entry);
|
|
653
|
+
await prev;
|
|
654
|
+
releaseGitLock = () => {
|
|
655
|
+
// Delete the map entry ONLY if we are still the head of the chain — a
|
|
656
|
+
// queued spawn behind us replaced the entry with its own.
|
|
657
|
+
if (gitBranchLocks.get(gitCwd) === entry) gitBranchLocks.delete(gitCwd);
|
|
658
|
+
release();
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
// C1: isolation is only real when the base tree is clean. A dirty tree
|
|
663
|
+
// would ride onto the work branch, get clobbered by the agent's writes,
|
|
664
|
+
// and leak back into the base working tree on switch — refuse loudly.
|
|
665
|
+
if (hasUncommittedChanges(gitCwd)) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
'working tree has uncommitted changes; commit or stash before ' +
|
|
668
|
+
'requesting background gitMode=branch isolation'
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
originalBranch = getCurrentBranch(gitCwd);
|
|
672
|
+
const branchResult = createWorkBranch(gitCwd, originalBranch);
|
|
673
|
+
if (branchResult.ok) {
|
|
674
|
+
workBranchName = branchResult.branch;
|
|
675
|
+
log.info('Created work branch for background agent', {
|
|
676
|
+
branch: workBranchName,
|
|
677
|
+
base: originalBranch,
|
|
678
|
+
agentId: id,
|
|
679
|
+
});
|
|
680
|
+
} else {
|
|
681
|
+
throw new Error(`createWorkBranch failed: ${branchResult.error}`);
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
// The agent record was already persisted as 'running' above — flip it
|
|
685
|
+
// to 'failed' so no zombie 'running' record survives the refused spawn
|
|
686
|
+
// (get_subagent_result would otherwise report 'running' forever).
|
|
687
|
+
releaseGitLock?.();
|
|
688
|
+
releaseGitLock = undefined;
|
|
689
|
+
// Sanitize ONCE and reuse for the status update and the thrown error.
|
|
690
|
+
const sanitizedError = sanitizeErrorMessage((err as Error).message, effectiveCwd);
|
|
691
|
+
try {
|
|
692
|
+
updateAgentStatus(id, 'failed',
|
|
693
|
+
`gitMode 'branch' requested but work branch setup failed: ${sanitizedError}`);
|
|
694
|
+
// Issue #98: the refused spawn is a failure — finalize the run entry so
|
|
695
|
+
// no zombie 'running' entry survives (lockstep with the record flip).
|
|
696
|
+
finalizeRunEntry('failed',
|
|
697
|
+
`gitMode 'branch' requested but work branch setup failed: ${sanitizedError}`);
|
|
698
|
+
transcript.completeTranscript(id, 'failed');
|
|
699
|
+
} catch { /* ignore */ }
|
|
700
|
+
throw new Error(
|
|
701
|
+
`gitMode 'branch' requested but work branch setup failed: ${sanitizedError}. ` +
|
|
702
|
+
`Refusing to spawn the background agent unisolated.`,
|
|
703
|
+
{ cause: err }
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Branch teardown — commit the agent's work (so the diff is real), capture
|
|
709
|
+
// the diff, switch back to the original branch, delete the work branch, and
|
|
710
|
+
// release the per-repo git lock. Runs on EVERY settle path. C1: agents
|
|
711
|
+
// rarely commit, so captureDiff(base...HEAD) alone would be empty and the
|
|
712
|
+
// uncommitted edits would leak into the base working tree on switch —
|
|
713
|
+
// commitAll first makes the diff real and the switch clean.
|
|
714
|
+
const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
|
|
715
|
+
if (!workBranchName || !originalBranch) return {};
|
|
716
|
+
const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
|
|
717
|
+
// The lock MUST be released on every exit path — early returns included.
|
|
718
|
+
try {
|
|
719
|
+
try {
|
|
720
|
+
const stillOnOurBranch = getCurrentBranch(gitCwd) === workBranchName;
|
|
721
|
+
if (!stillOnOurBranch) {
|
|
722
|
+
log.warn(`cleanupWorkBranch: tree moved off ${workBranchName} — skipping switch/delete`, {
|
|
723
|
+
agentId: id,
|
|
724
|
+
});
|
|
725
|
+
return info;
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
log.warn(`getCurrentBranch failed during cleanup for background agent ${id}`, {
|
|
729
|
+
error: (err as Error).message,
|
|
730
|
+
});
|
|
731
|
+
return info;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
// C1: commit the agent's work so captureDiff sees it. If no identity is
|
|
735
|
+
// configured, fall back to capturing the working-tree diff directly.
|
|
736
|
+
const dirty = hasUncommittedChanges(gitCwd);
|
|
737
|
+
if (dirty) {
|
|
738
|
+
const commitResult = commitAll(gitCwd, `brl-subagent ${id}: background work`);
|
|
739
|
+
if (commitResult.ok) {
|
|
740
|
+
log.info('Committed background agent work on work branch', {
|
|
741
|
+
sha: commitResult.sha,
|
|
742
|
+
branch: workBranchName,
|
|
743
|
+
agentId: id,
|
|
744
|
+
});
|
|
745
|
+
} else {
|
|
746
|
+
log.warn(`commitAll failed for background agent ${id} — capturing working diff`, {
|
|
747
|
+
error: commitResult.error,
|
|
748
|
+
});
|
|
749
|
+
const workingDiff = captureWorkingDiff(gitCwd);
|
|
750
|
+
if (workingDiff) info.gitDiff = workingDiff;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const diffResult = captureDiff(gitCwd, originalBranch);
|
|
754
|
+
if (diffResult.ok && diffResult.diff.trim()) {
|
|
755
|
+
// Merge committed + working diffs — either can exist alone.
|
|
756
|
+
info.gitDiff = info.gitDiff
|
|
757
|
+
? `${info.gitDiff}\n${diffResult.diff}`
|
|
758
|
+
: diffResult.diff;
|
|
759
|
+
}
|
|
760
|
+
} catch (err) {
|
|
761
|
+
log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
switchToBranch(gitCwd, originalBranch);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
log.warn(`switchToBranch failed for background agent ${id}`, { error: (err as Error).message });
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
deleteBranch(gitCwd, workBranchName);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
log.warn(`deleteBranch failed for background agent ${id}`, { error: (err as Error).message });
|
|
772
|
+
}
|
|
773
|
+
} finally {
|
|
774
|
+
releaseGitLock?.();
|
|
775
|
+
releaseGitLock = undefined;
|
|
776
|
+
}
|
|
777
|
+
return info;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// Issue #53: the settle handlers (.then/.catch below) run in promise
|
|
781
|
+
// callbacks — a throw from any step (fs failure, deleted transcript, emit
|
|
782
|
+
// error) would escape as an uncaughtException and kill the whole pi
|
|
783
|
+
// process. If a handler step throws, log it and best-effort flip the record
|
|
784
|
+
// terminal so no zombie 'running' record survives; never rethrow.
|
|
785
|
+
const markTerminalBestEffort = (fallback: AgentStatus): void => {
|
|
786
|
+
try {
|
|
787
|
+
// Issue #31 (PR #76 review): this catch-all fires when a settle handler
|
|
788
|
+
// threw — either BEFORE the branch's own capture (ref still live: capture
|
|
789
|
+
// now, it is the last chance to record the session's output) or AFTER it
|
|
790
|
+
// (finalOutput already recorded: never clobber it with a re-capture of a
|
|
791
|
+
// released ref, which would yield ''). Guard on finalOutput to cover both.
|
|
792
|
+
if (agent.finalOutput === undefined) {
|
|
793
|
+
try {
|
|
794
|
+
setAgentFinalOutput(id, extractFinalOutput(agent._sessionRef ?? { messages: [] }));
|
|
795
|
+
} catch {
|
|
796
|
+
// Output capture is best-effort — it must never block the terminal
|
|
797
|
+
// flip or the ref release below.
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (agent.status !== 'completed' && agent.status !== 'failed' && agent.status !== 'stopped') {
|
|
801
|
+
agent.status = fallback;
|
|
802
|
+
}
|
|
803
|
+
if (!agent.completedAt) agent.completedAt = Date.now();
|
|
804
|
+
// Issue #122: merge the session's real usage even on the catch-all — it
|
|
805
|
+
// fires when a settle handler threw BEFORE the branch's own extraction,
|
|
806
|
+
// so this is the last chance to record the tokens the run burned.
|
|
807
|
+
recordSessionUsage();
|
|
808
|
+
agents.set(id, agent);
|
|
809
|
+
persistAgent(agent);
|
|
810
|
+
// Issue #98 (review F2): derive the run-entry status from the agent
|
|
811
|
+
// record's terminal state — the catch-all fires on ANY settle-handler
|
|
812
|
+
// throw, so hardcoding 'failed' would mislabel a run whose completion
|
|
813
|
+
// branch already flipped the agent to 'completed'.
|
|
814
|
+
// Issue #120 (Review round): the catch-all fires when a settle handler
|
|
815
|
+
// threw — possibly BEFORE the branch's own stamp. A user-cancel whose
|
|
816
|
+
// abort branch never reached its stamped finalize would otherwise record
|
|
817
|
+
// an EMPTY errorMessage and NO errorCategory. Mirror the stamped paths
|
|
818
|
+
// with the honest user-abort fallback so no path is left without a
|
|
819
|
+
// category.
|
|
820
|
+
finalizeRunEntry(
|
|
821
|
+
agent.status === 'completed' ? 'done' : 'failed',
|
|
822
|
+
agent.error ?? SUBAGENT_ABORTED_MESSAGE,
|
|
823
|
+
);
|
|
824
|
+
// Issue #31: the live session ref must not survive terminal paths —
|
|
825
|
+
// including this catch-all, which fires when a settle handler throws.
|
|
826
|
+
agent._sessionRef = undefined;
|
|
827
|
+
} catch {
|
|
828
|
+
// The record may be beyond saving — the process must survive.
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// Emit created event
|
|
833
|
+
eventBus.emit(eventBus.createEvent('subagent:created', id, {
|
|
834
|
+
type: agent.type,
|
|
835
|
+
description: agent.description,
|
|
836
|
+
task: agent.task,
|
|
837
|
+
}));
|
|
838
|
+
|
|
839
|
+
// Start the session in the background (don't await)
|
|
840
|
+
// The session will run independently
|
|
841
|
+
// F27: wrap in the task-as-data fence — the user message is DATA, not
|
|
842
|
+
// instructions (see SUBAGENT_INSTRUCTIONS Task Boundary).
|
|
843
|
+
// NOTE: if prompt() throws SYNCHRONOUSLY, the .then/.catch below never
|
|
844
|
+
// attach and cleanupWorkBranch would never run — catch here so the work
|
|
845
|
+
// branch is not orphaned (it is a real filesystem branch).
|
|
846
|
+
let runPromise: Promise<unknown>;
|
|
847
|
+
try {
|
|
848
|
+
runPromise = session.prompt(wrapTask(params.task));
|
|
849
|
+
} catch (err) {
|
|
850
|
+
cleanupWorkBranch();
|
|
851
|
+
// Issue #98 (review F1): a synchronous prompt() throw never reaches the
|
|
852
|
+
// settle handlers — finalize the run entry so no zombie 'running' entry
|
|
853
|
+
// survives (findRunById would otherwise resolve and retry a run that
|
|
854
|
+
// never started).
|
|
855
|
+
finalizeRunEntry('failed', sanitizeErrorMessage((err as Error).message, effectiveCwd));
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// W3 (issue #28): per-agent deadline — abort the session when exceeded.
|
|
860
|
+
// Armed immediately after the prompt call so in-prompt preflight (auth
|
|
861
|
+
// check, model resolution) counts toward the deadline; the abort lands in
|
|
862
|
+
// the .then below as an aborted run (probe contract). We pre-set the status
|
|
863
|
+
// so the .then keeps 'stopped' with the timeout reason recorded. The value
|
|
864
|
+
// is normalized upstream (normalizeTimeout) but clamp again here — a raw
|
|
865
|
+
// >=2^31 or Infinity would make Node fire the timer at ~1ms (instant kill).
|
|
866
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
867
|
+
if (params.timeout && params.timeout > 0) {
|
|
868
|
+
timeoutHandle = setTimeout(() => {
|
|
869
|
+
try {
|
|
870
|
+
if (agent.completedAt) return; // already settled — nothing to abort
|
|
871
|
+
updateAgentStatus(id, 'stopped', `Timed out after ${params.timeout}ms`);
|
|
872
|
+
transcript.completeTranscript(id, 'stopped');
|
|
873
|
+
session.abort().catch(() => {
|
|
874
|
+
// Abort can reject if the session is mid-dispose; the status flip
|
|
875
|
+
// above is already recorded, so this is non-fatal.
|
|
876
|
+
});
|
|
877
|
+
} catch (err) {
|
|
878
|
+
// Timer callbacks must never throw uncaught — that crashes the host
|
|
879
|
+
// process. updateAgentStatus/completeTranscript can throw (persist
|
|
880
|
+
// fs failure, transcript deleted); the abort is best-effort.
|
|
881
|
+
log.warn(`spawnBackgroundSession: timeout handler error for ${id}`, {
|
|
882
|
+
error: (err as Error).message,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}, Math.min(params.timeout, 30 * 60 * 1000));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
runPromise.then(() => {
|
|
889
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
890
|
+
try {
|
|
891
|
+
// Probe contract (sdk-abort-contract.test.ts): prompt() RESOLVES on abort
|
|
892
|
+
// — the SDK's runWithLifecycle catches the AbortError and converts it into
|
|
893
|
+
// a failure message with stopReason "aborted". So an aborted run lands
|
|
894
|
+
// HERE, not in .catch. Two signals distinguish stop from completion:
|
|
895
|
+
// 1. stopAgent pre-set status to 'stopped' before calling abort();
|
|
896
|
+
// 2. the last assistant message carries stopReason "aborted".
|
|
897
|
+
const lastAssistant = [...(session.messages ?? [])].reverse().find(m => m.role === 'assistant');
|
|
898
|
+
const aborted = agent.status === 'stopped' || lastAssistant?.stopReason === 'aborted';
|
|
899
|
+
if (aborted) {
|
|
900
|
+
// Session was aborted — mark stopped. If stopAgent already flipped the
|
|
901
|
+
// status (pre-set 'stopped' + completedAt + event + transcript), only
|
|
902
|
+
// the transcript note is missing from this path.
|
|
903
|
+
if (agent.status !== 'stopped') {
|
|
904
|
+
updateAgentStatus(id, 'stopped');
|
|
905
|
+
transcript.completeTranscript(id, 'stopped');
|
|
906
|
+
}
|
|
907
|
+
// W4/M1: capture whatever the branch produced before discarding it — an
|
|
908
|
+
// aborted background agent's partial work is still worth reviewing, and
|
|
909
|
+
// must be RECORDED, not just captured-and-deleted.
|
|
910
|
+
const gitInfo = cleanupWorkBranch();
|
|
911
|
+
if (gitInfo.gitBranch) {
|
|
912
|
+
agent.result = {
|
|
913
|
+
...(agent.result ?? {}),
|
|
914
|
+
exitCode: 1,
|
|
915
|
+
messages: [],
|
|
916
|
+
stderr: '',
|
|
917
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
918
|
+
stopReason: 'aborted',
|
|
919
|
+
gitBranch: gitInfo.gitBranch,
|
|
920
|
+
gitDiff: gitInfo.gitDiff,
|
|
921
|
+
} as SubagentResult;
|
|
922
|
+
agents.set(id, agent);
|
|
923
|
+
persistAgent(agent);
|
|
924
|
+
}
|
|
925
|
+
// Issue #98: aborts/timeouts are retryable failures (retry schema:
|
|
926
|
+
// timeout/abort counts as failure) — finalize the run entry.
|
|
927
|
+
// Issue #122: a timed-out run burned tokens — record the real usage
|
|
928
|
+
// (the git-diff block above must not leave the empty fallback).
|
|
929
|
+
// Issue #120 (Track 1): agent.error carries the reason when the stop was
|
|
930
|
+
// a deadline timeout ('Timed out after Xms' → 'timeout'); a user cancel
|
|
931
|
+
// (stop_subagent) sets no error, so fall back to the honest user-abort
|
|
932
|
+
// stamp (→ 'aborted') instead of the old ambiguous 'Aborted or timed out'.
|
|
933
|
+
recordSessionUsage();
|
|
934
|
+
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
|
|
935
|
+
// Issue #31: capture the final output while the session is still
|
|
936
|
+
// live, then release the ref on the terminal path (memory retention;
|
|
937
|
+
// the poller treats a nulled ref on a terminal agent as expected).
|
|
938
|
+
captureAndReleaseSession();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
// Session completed
|
|
942
|
+
agent.status = 'completed';
|
|
943
|
+
agent.completedAt = Date.now();
|
|
944
|
+
// W4: capture diff + discard the work branch; record branch info on the
|
|
945
|
+
// agent so the caller can review what the background agent changed.
|
|
946
|
+
const gitInfo = cleanupWorkBranch();
|
|
947
|
+
if (gitInfo.gitBranch) {
|
|
948
|
+
agent.result = {
|
|
949
|
+
...(agent.result ?? {}),
|
|
950
|
+
exitCode: 0,
|
|
951
|
+
messages: [],
|
|
952
|
+
stderr: '',
|
|
953
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
954
|
+
gitBranch: gitInfo.gitBranch,
|
|
955
|
+
gitDiff: gitInfo.gitDiff,
|
|
956
|
+
} as SubagentResult;
|
|
957
|
+
}
|
|
958
|
+
// Issue #31: capture the final output while the session is still live,
|
|
959
|
+
// then release the ref before the branch's persist — the persisted
|
|
960
|
+
// record is consistent and the live session graph is freed.
|
|
961
|
+
captureAndReleaseSession();
|
|
962
|
+
// Issue #122: merge the session's summed usage onto the result record
|
|
963
|
+
// before the persist + finalize — the entry's cost/tokens are real.
|
|
964
|
+
recordSessionUsage();
|
|
965
|
+
agents.set(id, agent);
|
|
966
|
+
persistAgent(agent);
|
|
967
|
+
// Issue #98: mirror the agent-record flip in the session run entry.
|
|
968
|
+
finalizeRunEntry('done');
|
|
969
|
+
transcript.completeTranscript(id, 'completed');
|
|
970
|
+
eventBus.emit(eventBus.createEvent('subagent:completed', id, {}));
|
|
971
|
+
} catch (err) {
|
|
972
|
+
// Issue #53: any throw in this handler would escape as an
|
|
973
|
+
// uncaughtException and kill pi. Log and mark the record terminal —
|
|
974
|
+
// never rethrow.
|
|
975
|
+
log.error(`spawnBackgroundSession: completion handler failed for ${id}`, {
|
|
976
|
+
error: (err as Error).message,
|
|
977
|
+
});
|
|
978
|
+
markTerminalBestEffort('completed');
|
|
979
|
+
}
|
|
980
|
+
}).catch((err: Error) => {
|
|
981
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
982
|
+
try {
|
|
983
|
+
// W4: capture partial work + discard the work branch on failure — a failed
|
|
984
|
+
// background agent may still have produced reviewable changes.
|
|
985
|
+
const gitInfo = cleanupWorkBranch();
|
|
986
|
+
// Session failed — genuine preflight/rejection errors only (auth failure,
|
|
987
|
+
// model resolution, invalid task). Abort never rejects (probe contract).
|
|
988
|
+
if (agent.status === 'stopped') {
|
|
989
|
+
// M1: record the partial work even on the stopped path.
|
|
990
|
+
if (gitInfo.gitBranch) {
|
|
991
|
+
agent.result = {
|
|
992
|
+
...(agent.result ?? {}),
|
|
993
|
+
exitCode: 1,
|
|
994
|
+
messages: [],
|
|
995
|
+
stderr: '',
|
|
996
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
997
|
+
stopReason: 'aborted',
|
|
998
|
+
gitBranch: gitInfo.gitBranch,
|
|
999
|
+
gitDiff: gitInfo.gitDiff,
|
|
1000
|
+
} as SubagentResult;
|
|
1001
|
+
agents.set(id, agent);
|
|
1002
|
+
persistAgent(agent);
|
|
1003
|
+
}
|
|
1004
|
+
// Issue #98: the stopped path is a retryable failure too — finalize
|
|
1005
|
+
// the run entry alongside the agent record flip.
|
|
1006
|
+
// Issue #122: a stopped/timed-out run burned tokens too — record the
|
|
1007
|
+
// real usage (same aborted path semantics as the .then branch).
|
|
1008
|
+
// Issue #120 (Track 1): same honest fallback — a deadline timeout keeps
|
|
1009
|
+
// agent.error ('Timed out...' → 'timeout'); a user cancel has no error
|
|
1010
|
+
// and stamps 'Subagent aborted by user' (→ 'aborted').
|
|
1011
|
+
recordSessionUsage();
|
|
1012
|
+
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
|
|
1013
|
+
// Issue #31: the stopped path is terminal too — capture + release.
|
|
1014
|
+
captureAndReleaseSession();
|
|
1015
|
+
transcript.completeTranscript(id, 'stopped');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
agent.status = 'failed';
|
|
1019
|
+
agent.completedAt = Date.now();
|
|
1020
|
+
// F7 (issue #30): err.message may contain absolute paths (CWD prefixes,
|
|
1021
|
+
// spawn commands) — sanitize ONCE here and reuse it for the record, the
|
|
1022
|
+
// result errorMessage, and the subagent:failed event (DRY, PR #63 review).
|
|
1023
|
+
const sanitizedError = sanitizeErrorMessage(err.message, effectiveCwd);
|
|
1024
|
+
agent.error = sanitizedError;
|
|
1025
|
+
if (gitInfo.gitBranch) {
|
|
1026
|
+
agent.result = {
|
|
1027
|
+
...(agent.result ?? {}),
|
|
1028
|
+
exitCode: 1,
|
|
1029
|
+
messages: [],
|
|
1030
|
+
stderr: '',
|
|
1031
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
1032
|
+
errorMessage: sanitizedError,
|
|
1033
|
+
gitBranch: gitInfo.gitBranch,
|
|
1034
|
+
gitDiff: gitInfo.gitDiff,
|
|
1035
|
+
} as SubagentResult;
|
|
1036
|
+
}
|
|
1037
|
+
// Issue #31: capture the final output while the session is still live,
|
|
1038
|
+
// then release the ref before the branch's persist — the persisted
|
|
1039
|
+
// record is consistent and the live session graph is freed.
|
|
1040
|
+
captureAndReleaseSession();
|
|
1041
|
+
// Issue #122: fold any assistant turns the preflight/run did produce —
|
|
1042
|
+
// usually none (auth/model rejection) → honest zeros, never a crash.
|
|
1043
|
+
recordSessionUsage();
|
|
1044
|
+
agents.set(id, agent);
|
|
1045
|
+
persistAgent(agent);
|
|
1046
|
+
// Issue #98: mirror the agent-record flip in the session run entry.
|
|
1047
|
+
finalizeRunEntry('failed', sanitizedError);
|
|
1048
|
+
transcript.completeTranscript(id, 'failed');
|
|
1049
|
+
eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
// Issue #53: any throw in this handler would escape as an
|
|
1052
|
+
// uncaughtException and kill pi. Log and mark the record terminal —
|
|
1053
|
+
// never rethrow.
|
|
1054
|
+
log.error(`spawnBackgroundSession: completion handler failed for ${id}`, {
|
|
1055
|
+
error: (err as Error).message,
|
|
1056
|
+
});
|
|
1057
|
+
markTerminalBestEffort('failed');
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
return agent;
|
|
1062
|
+
} finally {
|
|
1063
|
+
resolveNext();
|
|
1064
|
+
}
|
|
1065
|
+
}
|