@bermudi/pi-delegate 0.1.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/lifecycle.ts ADDED
@@ -0,0 +1,704 @@
1
+ import * as fs from "node:fs";
2
+ import {
3
+ createAgentSession,
4
+ SessionManager,
5
+ type AgentSession,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type {
8
+ AgentProgressUpdate,
9
+ AcquiredSession,
10
+ ResolvedTask,
11
+ TaskProgress,
12
+ TaskResult,
13
+ TaskRunEnv,
14
+ } from "./types.ts";
15
+ import * as pool from "./pool.ts";
16
+ import { isSessionBusy } from "./tickets.ts";
17
+ import {
18
+ createSubagentSessionManager,
19
+ setParentSession,
20
+ persistSessionHeader,
21
+ } from "./sessions.ts";
22
+ import { runAgentSession } from "./runner.ts";
23
+ import { getGitChangedFiles } from "./file-tracking.ts";
24
+ import { getHostDeps } from "./host.ts";
25
+ import { resolveCwd, validateResumeFromPath } from "./utils.ts";
26
+ import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
27
+ import { addUsage, emptyUsage } from "./usage.ts";
28
+
29
+ /**
30
+ * Test-only overrides for whole-task retry settings. When set, these bypass
31
+ * the config-driven values so retry integration tests don't sleep real seconds.
32
+ * Set via `_setWholeTaskRetryForTesting`.
33
+ */
34
+ let testWholeTaskMaxRetries: number | undefined;
35
+ let testWholeTaskBaseDelayMs: number | undefined;
36
+
37
+ /** @internal Test-only override for whole-task retry count and base delay. */
38
+ export function _setWholeTaskRetryForTesting(
39
+ opts:
40
+ | {
41
+ maxRetries?: number;
42
+ baseDelayMs?: number;
43
+ }
44
+ | undefined,
45
+ ): void {
46
+ testWholeTaskMaxRetries = opts?.maxRetries;
47
+ testWholeTaskBaseDelayMs = opts?.baseDelayMs;
48
+ }
49
+
50
+ function resolvedWholeTaskMaxRetries(): number {
51
+ return testWholeTaskMaxRetries ?? getWholeTaskMaxRetries();
52
+ }
53
+ function resolvedWholeTaskBaseDelayMs(): number {
54
+ return testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs();
55
+ }
56
+
57
+ /** Build a failed TaskResult. Used for early-failure paths (abort, busy, validation). */
58
+ function failTask(
59
+ task: ResolvedTask,
60
+ error: string,
61
+ sessionFile?: string,
62
+ ): TaskResult {
63
+ return {
64
+ agent: task.agentName,
65
+ output: "",
66
+ error,
67
+ durationMs: 0,
68
+ tokens: 0,
69
+ usage: emptyUsage(),
70
+ sessionFile,
71
+ touchedFiles: [],
72
+ };
73
+ }
74
+
75
+ /** Build a successful TaskResult for session-management actions (close/list).
76
+ * Pass elapsedMs to record wall time since delegate started (matches the live progress UI). */
77
+ function completeSessionAction(
78
+ task: ResolvedTask,
79
+ output: string,
80
+ elapsedMs?: number,
81
+ ): TaskResult {
82
+ return {
83
+ agent: task.agentName,
84
+ output,
85
+ durationMs: elapsedMs ?? 0,
86
+ tokens: 0,
87
+ usage: emptyUsage(),
88
+ sessionFile: undefined,
89
+ touchedFiles: [],
90
+ };
91
+ }
92
+
93
+ /** Dispose a materialized session that remains lifecycle-owned.
94
+ * Pool hits and successfully committed sessions remain pool-owned. */
95
+ function disposeOwnedSession(acquired: AcquiredSession): void {
96
+ if (!acquired.lifecycleOwnsSession) return;
97
+ try {
98
+ acquired.session.dispose();
99
+ } catch (error) {
100
+ // Cleanup must not replace the task's primary result, but it must emit a
101
+ // signal: extension-bearing sessions can retain callbacks/resources when a
102
+ // provider's dispose implementation misbehaves.
103
+ console.error("[delegate] uncommitted subagent disposal failed", error);
104
+ }
105
+ }
106
+
107
+ /** Mirror a progress update from runAgent into a TaskProgress row. */
108
+ export function updateProgressFromRun(
109
+ p: TaskProgress,
110
+ u: AgentProgressUpdate,
111
+ ): void {
112
+ p.tokens = u.tokens;
113
+ p.toolUses = u.toolUses;
114
+ p.durationMs = u.durationMs;
115
+ p.lastActivityAt = u.lastActivityAt;
116
+ p.activities = u.activities;
117
+ p.failureKind = u.failureKind;
118
+ }
119
+
120
+ /** Mirror a completed TaskResult into a TaskProgress row (status/duration/error). */
121
+ function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
122
+ p.status = r.error ? "failed" : "done";
123
+ p.durationMs = r.durationMs;
124
+ p.tokens = r.tokens;
125
+ p.error = r.error;
126
+ p.failureKind = r.failureKind;
127
+ }
128
+
129
+ /** Apply a TaskResult to progress and notify the env (sync fires onUpdate).
130
+ * Used at every return point in runResolvedTask — mirrors the old fire() pattern
131
+ * that the duplicated sync/async bodies used after every early-return. */
132
+ function finishTask(
133
+ env: TaskRunEnv,
134
+ p: TaskProgress,
135
+ r: TaskResult,
136
+ ): TaskResult {
137
+ updateProgressFromResult(p, r);
138
+ env.onStatusChange?.();
139
+ return r;
140
+ }
141
+
142
+ /** A failure attributable to the resolved model/provider — not transient for
143
+ * that model, so same-model retry is pointless. Distinguished from a bare
144
+ * transient 429 (per-minute rate limit) by the *account-level* wording:
145
+ * "usage limit", "quota", "upgrade for higher limits", "exceeded your
146
+ * … quota", or an auth/credential failure (401/403 with word boundaries so a
147
+ * port like 4019 doesn't false-positive). The parent should resume with a
148
+ * different `model` (see `resumeFrom` + `model`). */
149
+ export function isModelAttributableError(error: string | undefined): boolean {
150
+ if (!error) return false;
151
+ const e = error.toLowerCase();
152
+ if (e.includes("abort")) return false;
153
+ return (
154
+ e.includes("usage limit") ||
155
+ e.includes("upgrade for higher limits") ||
156
+ e.includes("quota") ||
157
+ e.includes("exceeded your") ||
158
+ e.includes("insufficient credit") ||
159
+ e.includes("insufficient quota") ||
160
+ e.includes("insufficient funds") ||
161
+ e.includes("billing") ||
162
+ e.includes("unauthorized") ||
163
+ e.includes("unauthenticated") ||
164
+ e.includes("authentication") ||
165
+ e.includes("invalid api key") ||
166
+ (e.includes("api key") && e.includes("invalid")) ||
167
+ /\b401\b/.test(e) ||
168
+ /\b403\b/.test(e)
169
+ );
170
+ }
171
+
172
+ function isClearlyTransientFinalError(error: string | undefined): boolean {
173
+ if (!error) return false;
174
+ const e = error.toLowerCase();
175
+ if (e.includes("abort")) return false;
176
+ // A model-attributable error (usage limit, auth) is NOT transient for this
177
+ // model — exclude it so whole-task retry doesn't burn attempts into the same
178
+ // wall. The parent gets one clean failure and a "switch model" hint instead.
179
+ if (isModelAttributableError(error)) return false;
180
+ return (
181
+ /\b429\b/.test(e) ||
182
+ /\b5\d\d\b/.test(e) ||
183
+ e.includes('"code":"1305"') ||
184
+ e.includes("temporarily overloaded") ||
185
+ e.includes("temporarily unavailable") ||
186
+ e.includes("overloaded") ||
187
+ e.includes("rate limit") ||
188
+ e.includes("too many requests") ||
189
+ e.includes("timeout") ||
190
+ e.includes("timed out") ||
191
+ e.includes("connection reset") ||
192
+ e.includes("econnreset") ||
193
+ e.includes("connection refused") ||
194
+ e.includes("network error")
195
+ );
196
+ }
197
+
198
+ function canRetryWholeTask(task: ResolvedTask, result: TaskResult): boolean {
199
+ // Whole-task retry can repeat tool side effects. Keep it to stateless fresh
200
+ // tasks, and only when our touched-file accounting says the failed attempt
201
+ // did not write/edit anything. A `model_error` (usage limit, auth, quota) is
202
+ // not transient for the resolved model — retrying with the same model just
203
+ // hits the same wall, so skip it and let the parent resume with a different
204
+ // model (see the hint in formatFailedTask).
205
+ return (
206
+ result.failureKind !== "stalled" &&
207
+ result.failureKind !== "model_error" &&
208
+ !task.sessionId &&
209
+ !task.resumeFrom &&
210
+ result.touchedFiles.length === 0 &&
211
+ isClearlyTransientFinalError(result.error)
212
+ );
213
+ }
214
+
215
+ async function sleepForWholeTaskRetry(
216
+ signal: AbortSignal | undefined,
217
+ delayMs: number,
218
+ ): Promise<void> {
219
+ if (signal?.aborted) return;
220
+ await new Promise<void>((resolve) => {
221
+ let timeout: ReturnType<typeof setTimeout>;
222
+ const done = () => {
223
+ clearTimeout(timeout);
224
+ signal?.removeEventListener("abort", done);
225
+ resolve();
226
+ };
227
+ timeout = setTimeout(done, delayMs);
228
+ if (!signal) return;
229
+ signal.addEventListener("abort", done, { once: true });
230
+ });
231
+ }
232
+
233
+ /** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
234
+ * Reuses the caller-supplied sessionManager (so parent-linking + per-task .jsonl
235
+ * files stay under our control). Extension-free host deps may be cached, while
236
+ * provider-configured or allowlisted-extension deps are session-local because
237
+ * Pi binds mutable extension callbacks onto each loader runtime. */
238
+ async function buildDelegateSession(
239
+ task: ResolvedTask,
240
+ sessionManager: SessionManager,
241
+ modelRegistry: TaskRunEnv["modelRegistry"],
242
+ ): Promise<AgentSession> {
243
+ // Resolve host deps for this task's cwd + system prompt. Extension-free
244
+ // resource loaders are cached after the first call; provider-configured or
245
+ // allowlisted-extension loaders are deliberately fresh per session because
246
+ // their extension runtime is mutable. The resourceLoader is cwd-scoped (it
247
+ // scans for AGENTS.md/skills) and the system prompt is per named-agent.
248
+ // The custom prompt overrides the default AgentSession system prompt.
249
+ // Pass only the provider needed by this task. This keeps a non-Kilo task
250
+ // from receiving Kilo's provider/auth adapter merely because Kilo is also
251
+ // configured in the parent runtime.
252
+ const providerConfig = modelRegistry.getRegisteredProviderConfig?.(
253
+ task.model.provider,
254
+ );
255
+ const providerConfigs = providerConfig
256
+ ? ([[task.model.provider, providerConfig]] as const)
257
+ : [];
258
+ const hostDeps = await getHostDeps({
259
+ cwd: task.cwd,
260
+ systemPrompt: task.systemPrompt,
261
+ providerConfigs,
262
+ modelProvider: task.model.provider,
263
+ });
264
+
265
+ const { session } = await createAgentSession({
266
+ cwd: task.cwd,
267
+ model: task.model,
268
+ thinkingLevel: task.thinking,
269
+ tools: task.tools,
270
+ sessionManager,
271
+ // Extension-free host deps may be shared: the canonical model/auth runtime
272
+ // (reads the same ~/.pi/agent files as the parent), settings manager, and
273
+ // resource loader. Provider-configured or allowlisted-extension tasks get
274
+ // fresh instances so Pi's mutable extension runtime cannot cross-wire
275
+ // sessions. Since pi 0.80.8 `createAgentSession` takes `modelRuntime` in
276
+ // place of the removed `authStorage`/`modelRegistry` options.
277
+ modelRuntime: hostDeps.modelRuntime,
278
+ settingsManager: hostDeps.settingsManager,
279
+ resourceLoader: hostDeps.resourceLoader,
280
+ });
281
+ return session;
282
+ }
283
+
284
+ /** Resolve the agent + session for a task. Single source of truth for pool, resume, and miss logic. */
285
+ async function acquireAgentSession(
286
+ env: TaskRunEnv,
287
+ task: ResolvedTask,
288
+ p: TaskProgress,
289
+ ): Promise<AcquiredSession | { error: TaskResult }> {
290
+ let sessionManager: SessionManager | undefined;
291
+ let sessionFile: string | undefined;
292
+
293
+ // ── Pool hit (reuse live stateful session) ───────────────────────────────
294
+ // The SessionPool owns the freeze compare — checkout returns a structured
295
+ // mismatch; lifecycle only formats the error. checkout is pure (no lastUsed
296
+ // bump), so a speculative checkout that bails leaves no trace; lastUsed is
297
+ // bumped by commit() on a successful run.
298
+ if (task.sessionId) {
299
+ const co = pool.checkout(task.sessionId, {
300
+ cwd: task.cwd,
301
+ thinking: task.thinking,
302
+ tools: task.tools,
303
+ ...task.reuseIntent,
304
+ });
305
+ if (co.status === "mismatch") {
306
+ const detail = co.mismatches
307
+ .map((m) => `${m.field}: '${m.frozen}' vs '${m.requested}'`)
308
+ .join("; ");
309
+ return {
310
+ error: failTask(
311
+ task,
312
+ `Session '${task.sessionId}' config mismatch. Close and recreate: ${detail}`,
313
+ ),
314
+ };
315
+ }
316
+ if (co.status === "hit") {
317
+ // A pooled session has its own accumulated context — resumeFrom pointing
318
+ // elsewhere is contradictory. This folds the old defensive agentPool.has
319
+ // precheck: checkout already told us the session is live.
320
+ if (task.resumeFrom) {
321
+ return {
322
+ error: failTask(
323
+ task,
324
+ `resumeFrom conflicts with active sessionId '${task.sessionId}'. The pooled session has its own accumulated context. Close the session first if you want to resume from a different point.`,
325
+ ),
326
+ };
327
+ }
328
+ p.model = co.modelId;
329
+ return {
330
+ session: co.session,
331
+ sessionManager: co.sessionManager,
332
+ sessionFile: co.sessionFile,
333
+ lifecycleOwnsSession: false,
334
+ };
335
+ }
336
+ // status === "miss" → fall through to resume / fresh materialization.
337
+ }
338
+
339
+ // ── Resume from a previous session file ──────────────────────────────────
340
+ // Resume takes precedence over a fresh sessionId miss: resumeFrom points at a
341
+ // concrete prior conversation we must continue, whereas a sessionId miss just
342
+ // means "create a new pooled session under this id".
343
+ if (task.resumeFrom) {
344
+ const resumeFromPathError = validateResumeFromPath(task.resumeFrom);
345
+ if (resumeFromPathError) {
346
+ return {
347
+ error: failTask(
348
+ task,
349
+ `resumeFrom: invalid session path: ${resumeFromPathError}; got ${JSON.stringify(task.resumeFrom)}`,
350
+ ),
351
+ };
352
+ }
353
+ const resolvedPath = resolveCwd(task.resumeFrom);
354
+ if (!fs.existsSync(resolvedPath)) {
355
+ return {
356
+ error: failTask(
357
+ task,
358
+ `resumeFrom: file not found: ${resolvedPath}`,
359
+ resolvedPath,
360
+ ),
361
+ };
362
+ }
363
+ // Open the existing session and let createAgentSession restore its messages
364
+ // internally (sdk.js reads buildSessionContext().messages + model/thinking).
365
+ let resumed: SessionManager;
366
+ try {
367
+ resumed = SessionManager.open(resolvedPath);
368
+ } catch {
369
+ return {
370
+ error: failTask(
371
+ task,
372
+ `resumeFrom: corrupt session: ${resolvedPath}`,
373
+ resolvedPath,
374
+ ),
375
+ };
376
+ }
377
+ // Non-empty sessions have at least the header + the restored branch. An
378
+ // empty/corrupt file surfaces as a session with no restorable messages.
379
+ if (!resumed.buildSessionContext().messages.length) {
380
+ return {
381
+ error: failTask(
382
+ task,
383
+ `resumeFrom: empty session: ${resolvedPath}`,
384
+ resolvedPath,
385
+ ),
386
+ };
387
+ }
388
+
389
+ // Link resumed session to parent for /resume discoverability.
390
+ const parentFile = env.parentSessionManager?.getSessionFile?.();
391
+ if (parentFile) setParentSession(resumed, parentFile);
392
+
393
+ const session = await buildDelegateSession(
394
+ task,
395
+ resumed,
396
+ env.modelRegistry,
397
+ );
398
+ return {
399
+ session,
400
+ sessionManager: resumed,
401
+ sessionFile: resolvedPath,
402
+ lifecycleOwnsSession: true,
403
+ };
404
+ }
405
+
406
+ // ── Fresh session (no resume) ────────────────────────────────────────────
407
+ const fresh = createSubagentSessionManager(
408
+ env.parentSessionManager,
409
+ task.cwd,
410
+ );
411
+ if (!fresh) {
412
+ return { error: failTask(task, "Internal: could not create session file") };
413
+ }
414
+ sessionManager = fresh.manager;
415
+ sessionFile = fresh.file;
416
+
417
+ const session = await buildDelegateSession(
418
+ task,
419
+ sessionManager,
420
+ env.modelRegistry,
421
+ );
422
+ return {
423
+ session,
424
+ sessionManager,
425
+ sessionFile,
426
+ lifecycleOwnsSession: true,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Resolve the `sessionFile` to report on a TaskResult.
432
+ *
433
+ * On failure, the upstream SessionManager may never have written the `.jsonl`
434
+ * (it gates the first write behind an assistant message — a documented contract).
435
+ * A first-call failure (e.g. Cloudflare 524) leaves the planned path uncreated,
436
+ * yet delegate would otherwise report it as if it existed. So when a run failed,
437
+ * force-flush the header first so the path becomes real and resumable.
438
+ *
439
+ * Defense-in-depth: regardless of success/failure, only report the path if the
440
+ * file actually exists on disk, so the TaskResult never points at nothing.
441
+ */
442
+ function resolveResumableSessionFile(
443
+ sessionFile: string | undefined,
444
+ sessionManager: SessionManager | undefined,
445
+ error: string | undefined,
446
+ ): string | undefined {
447
+ if (!sessionFile) return undefined;
448
+ // On failure, the header may not have been written yet — force it now.
449
+ if (error && sessionManager) persistSessionHeader(sessionManager);
450
+ return fs.existsSync(sessionFile) ? sessionFile : undefined;
451
+ }
452
+
453
+ /** Run a single resolved task. Single source of truth for the per-task lifecycle.
454
+ * Used by both sync (params.async === false) and async (params.async === true) paths.
455
+ * When task.sessionId is set, the entire acquire/run/close lifecycle runs under
456
+ * a per-session mutex so concurrent tasks with the same sessionId serialize
457
+ * cleanly. The lock also covers action='close' and the early-busy/abort paths. */
458
+ export async function runResolvedTask(
459
+ env: TaskRunEnv,
460
+ task: ResolvedTask,
461
+ p: TaskProgress,
462
+ taskIndex: number,
463
+ ): Promise<TaskResult> {
464
+ if (task.sessionId) {
465
+ return pool.withSessionLock(task.sessionId, () =>
466
+ runResolvedTaskUnlocked(env, task, p, taskIndex),
467
+ );
468
+ }
469
+ return runResolvedTaskUnlocked(env, task, p, taskIndex);
470
+ }
471
+
472
+ async function runResolvedTaskUnlocked(
473
+ env: TaskRunEnv,
474
+ task: ResolvedTask,
475
+ p: TaskProgress,
476
+ taskIndex: number,
477
+ ): Promise<TaskResult> {
478
+ try {
479
+ // ── Aborted before we started? ───────────────────────────────────
480
+ if (env.signal?.aborted) {
481
+ return finishTask(env, p, failTask(task, "Aborted"));
482
+ }
483
+
484
+ // ── Session busy guard (defense-in-depth) ────────────────────────
485
+ // Primary validation is in execute() before ticket creation.
486
+ // This catches edge cases where validation missed a conflict.
487
+ if (task.sessionId) {
488
+ const busyTicketId = isSessionBusy(task.sessionId);
489
+ if (busyTicketId && busyTicketId !== env.ticketId) {
490
+ const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
491
+ return finishTask(env, p, failTask(task, msg));
492
+ }
493
+ }
494
+
495
+ p.status = "running";
496
+ p.model = task.model?.id;
497
+
498
+ // ── Session action handling ───────────────────────────────────────
499
+ if (task.action === "close") {
500
+ if (!task.sessionId) {
501
+ return finishTask(
502
+ env,
503
+ p,
504
+ failTask(task, "action='close' requires sessionId."),
505
+ );
506
+ }
507
+ const closed = await pool.closePooledAgent(task.sessionId);
508
+ return finishTask(
509
+ env,
510
+ p,
511
+ completeSessionAction(
512
+ task,
513
+ closed
514
+ ? `Session '${task.sessionId}' closed.`
515
+ : `Session '${task.sessionId}' not found.`,
516
+ Date.now() - env.delegateStartedAt,
517
+ ),
518
+ );
519
+ }
520
+
521
+ if (task.action === "list") {
522
+ return finishTask(
523
+ env,
524
+ p,
525
+ completeSessionAction(
526
+ task,
527
+ `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
528
+ Date.now() - env.delegateStartedAt,
529
+ ),
530
+ );
531
+ }
532
+
533
+ const runAttempt = async (): Promise<TaskResult> => {
534
+ // ── Pool / resume / fresh-agent resolution ────────────────────────
535
+ const acquired = await acquireAgentSession(env, task, p);
536
+ if ("error" in acquired) return acquired.error;
537
+
538
+ // A pool hit is already owned by the pool. Fresh/resumed sessions belong
539
+ // to this attempt until commit() explicitly transfers ownership. Keeping
540
+ // this state local makes cleanup a finally invariant rather than a list
541
+ // of special cases for aborts, stalls, and provider failures.
542
+ let sessionReleased = !acquired.lifecycleOwnsSession;
543
+ try {
544
+ // Re-check abort after acquisition. The pre-acquire check at the top can
545
+ // miss a signal that fires during getHostDeps/createAgentSession/git
546
+ // baseline. runAgentSession re-checks after attaching its listener, but a
547
+ // cancelled ticket should not even start the subagent (no file writes, no
548
+ // pool insert).
549
+ if (env.signal?.aborted) return failTask(task, "Aborted");
550
+
551
+ // Snapshot git status before the run so touchedFiles can diff after.
552
+ // AgentSession owns retry/compaction internally — runAgentSession just
553
+ // drives the prompt and maps events to the progress model.
554
+ const gitBaseline = await getGitChangedFiles(task.cwd);
555
+ let r = await runAgentSession(
556
+ acquired.session,
557
+ task.prompt,
558
+ { cwd: task.cwd },
559
+ env.signal,
560
+ (u) => env.onProgress(p, u),
561
+ gitBaseline,
562
+ Date.now(),
563
+ );
564
+
565
+ // The signal can fire after the pre-run check or while the runner is
566
+ // collecting post-prompt evidence. Keep cancellation from looking like
567
+ // success; finally below releases any uncommitted session.
568
+ if (env.signal?.aborted && !r.error) {
569
+ r = { ...r, error: "Aborted" };
570
+ }
571
+
572
+ // A stalled prompt was explicitly aborted and is no longer a safe
573
+ // continuation. Close a pooled hit; a fresh/resumed session is still
574
+ // released by the finally below. closePooledAgent removes the pooled
575
+ // entry before surfacing cleanup errors, so a pool-owned session is
576
+ // never directly disposed here.
577
+ if (r.failureKind === "stalled" && task.sessionId) {
578
+ try {
579
+ if (await pool.closePooledAgent(task.sessionId)) {
580
+ sessionReleased = true;
581
+ }
582
+ } catch (error) {
583
+ // Preserve the primary stalled result while logging the cleanup
584
+ // failure explicitly. A pooled session is already removed by the
585
+ // pool; a pool miss remains lifecycle-owned and is handled below.
586
+ console.error(
587
+ `[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
588
+ error,
589
+ );
590
+ }
591
+ }
592
+
593
+ const sessionFile = resolveResumableSessionFile(
594
+ acquired.sessionFile,
595
+ acquired.sessionManager,
596
+ r.error,
597
+ );
598
+
599
+ // Pool bookkeeping. commit() is the sole mutator: it decides
600
+ // insert-vs-recordUse by map presence (sound because the session lock
601
+ // serializes same-sessionId tasks). A successful insert transfers
602
+ // ownership; a failed insert leaves finally responsible for disposal.
603
+ if (task.sessionId && !r.error) {
604
+ sessionReleased = pool.commit(task.sessionId, {
605
+ session: acquired.session,
606
+ sessionManager: acquired.sessionManager,
607
+ sessionFile: acquired.sessionFile,
608
+ frozen: {
609
+ systemPrompt: task.systemPrompt,
610
+ model: task.model,
611
+ thinking: task.thinking,
612
+ tools: task.tools,
613
+ cwd: task.cwd,
614
+ },
615
+ tokens: r.tokens,
616
+ });
617
+ }
618
+
619
+ return {
620
+ agent: task.agentName,
621
+ output: r.output,
622
+ error: r.error,
623
+ // Classify the failure: the runner sets `stalled` for the inactivity
624
+ // watchdog; here we add `model_error` for failures attributable to
625
+ // the resolved model (usage limit, auth, quota) so the parent gets a
626
+ // "switch model" hint instead of a same-model retry hint, and so
627
+ // canRetryWholeTask skips the pointless same-model retry.
628
+ failureKind:
629
+ r.failureKind ??
630
+ (r.error && isModelAttributableError(r.error)
631
+ ? "model_error"
632
+ : undefined),
633
+ durationMs: r.durationMs,
634
+ tokens: r.tokens,
635
+ usage: r.usage,
636
+ sessionFile,
637
+ touchedFiles: r.touchedFiles,
638
+ };
639
+ } finally {
640
+ // This runs for ordinary success, normal provider failure, whole-task
641
+ // retry attempts, abort races, stalls, and unexpected throws. Pool hits
642
+ // remain pool-owned; successful inserts were explicitly released above.
643
+ if (!sessionReleased) disposeOwnedSession(acquired);
644
+ }
645
+ };
646
+
647
+ // The session lock is now taken at the top of runResolvedTask (covers the
648
+ // full acquire/run/close lifecycle), so attempts execute serially per
649
+ // sessionId without needing an inner lock here.
650
+ let result = await runAttempt();
651
+ // Accumulate usage across whole-task retries: the parent pays for every
652
+ // attempt, including the transient failures that retry. Keep tokens tied
653
+ // to the accumulated usage as well; otherwise the final result and the
654
+ // final progress row describe different amounts of work.
655
+ let accumulatedUsage = result.usage;
656
+ result = {
657
+ ...result,
658
+ tokens: accumulatedUsage.totalTokens,
659
+ usage: accumulatedUsage,
660
+ };
661
+ const maxRetries = resolvedWholeTaskMaxRetries();
662
+ const baseDelayMs = resolvedWholeTaskBaseDelayMs();
663
+ for (
664
+ let retry = 0;
665
+ retry < maxRetries && canRetryWholeTask(task, result);
666
+ retry++
667
+ ) {
668
+ const delayMs = baseDelayMs * 2 ** retry;
669
+ await sleepForWholeTaskRetry(env.signal, delayMs);
670
+ if (env.signal?.aborted) {
671
+ // Preserve any partial output/session path from the last failed attempt
672
+ // while recording that the retry loop was aborted. The task already
673
+ // paid for every completed attempt, including the one before sleep.
674
+ result = {
675
+ ...result,
676
+ error: "Aborted",
677
+ tokens: accumulatedUsage.totalTokens,
678
+ usage: accumulatedUsage,
679
+ };
680
+ break;
681
+ }
682
+ p.status = "running";
683
+ p.error = undefined;
684
+ p.failureKind = undefined;
685
+ env.onStatusChange?.();
686
+ result = await runAttempt();
687
+ accumulatedUsage = addUsage(accumulatedUsage, result.usage);
688
+ result = {
689
+ ...result,
690
+ tokens: accumulatedUsage.totalTokens,
691
+ usage: accumulatedUsage,
692
+ };
693
+ }
694
+ return finishTask(env, p, result);
695
+ } catch (err) {
696
+ // Any acquired session is released by runAttempt's finally before an
697
+ // exception reaches this boundary.
698
+ return finishTask(
699
+ env,
700
+ p,
701
+ failTask(task, err instanceof Error ? err.message : String(err)),
702
+ );
703
+ }
704
+ }