@bermudi/pi-delegate 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/lifecycle.ts
CHANGED
|
@@ -11,20 +11,32 @@ import type {
|
|
|
11
11
|
TaskProgress,
|
|
12
12
|
TaskResult,
|
|
13
13
|
TaskRunEnv,
|
|
14
|
+
ToolActivity,
|
|
14
15
|
} from "./types.ts";
|
|
15
16
|
import * as pool from "./pool.ts";
|
|
16
17
|
import { isSessionBusy } from "./tickets.ts";
|
|
17
18
|
import {
|
|
18
19
|
createSubagentSessionManager,
|
|
19
|
-
setParentSession,
|
|
20
20
|
persistSessionHeader,
|
|
21
|
+
setParentSession,
|
|
21
22
|
} from "./sessions.ts";
|
|
22
|
-
import { runAgentSession } from "./runner.ts";
|
|
23
|
+
import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
|
|
23
24
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
24
25
|
import { getHostDeps } from "./host.ts";
|
|
25
26
|
import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
26
27
|
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
27
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
29
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
30
|
+
|
|
31
|
+
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
32
|
+
type RunAgentSession = typeof runAgentSession;
|
|
33
|
+
let runAgentSessionForTesting: RunAgentSession = runAgentSession;
|
|
34
|
+
|
|
35
|
+
export function _setRunAgentSessionForTesting(
|
|
36
|
+
override: RunAgentSession | undefined,
|
|
37
|
+
): void {
|
|
38
|
+
runAgentSessionForTesting = override ?? runAgentSession;
|
|
39
|
+
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
30
42
|
* Test-only overrides for whole-task retry settings. When set, these bypass
|
|
@@ -61,6 +73,7 @@ function failTask(
|
|
|
61
73
|
sessionFile?: string,
|
|
62
74
|
): TaskResult {
|
|
63
75
|
return {
|
|
76
|
+
id: task.id,
|
|
64
77
|
agent: task.agentName,
|
|
65
78
|
output: "",
|
|
66
79
|
error,
|
|
@@ -69,6 +82,7 @@ function failTask(
|
|
|
69
82
|
usage: emptyUsage(),
|
|
70
83
|
sessionFile,
|
|
71
84
|
touchedFiles: [],
|
|
85
|
+
attributedFiles: [],
|
|
72
86
|
};
|
|
73
87
|
}
|
|
74
88
|
|
|
@@ -80,6 +94,7 @@ function completeSessionAction(
|
|
|
80
94
|
elapsedMs?: number,
|
|
81
95
|
): TaskResult {
|
|
82
96
|
return {
|
|
97
|
+
id: task.id,
|
|
83
98
|
agent: task.agentName,
|
|
84
99
|
output,
|
|
85
100
|
durationMs: elapsedMs ?? 0,
|
|
@@ -87,6 +102,7 @@ function completeSessionAction(
|
|
|
87
102
|
usage: emptyUsage(),
|
|
88
103
|
sessionFile: undefined,
|
|
89
104
|
touchedFiles: [],
|
|
105
|
+
attributedFiles: [],
|
|
90
106
|
};
|
|
91
107
|
}
|
|
92
108
|
|
|
@@ -104,19 +120,57 @@ function disposeOwnedSession(acquired: AcquiredSession): void {
|
|
|
104
120
|
}
|
|
105
121
|
}
|
|
106
122
|
|
|
107
|
-
/**
|
|
123
|
+
/** Merge per-attempt activities into a live history list while preserving
|
|
124
|
+
* prior-attempt evidence. Activity IDs are used as a stable handle so in-flight
|
|
125
|
+
* updates can replace earlier skeletons for the same call. */
|
|
126
|
+
function mergeToolActivities(
|
|
127
|
+
existing: TaskProgress["activities"],
|
|
128
|
+
incoming: ToolActivity[],
|
|
129
|
+
): ToolActivity[] {
|
|
130
|
+
const byId = new Map<string, number>();
|
|
131
|
+
for (let i = 0; i < existing.length; i++) {
|
|
132
|
+
byId.set(existing[i]!.id, i);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const merged = [...existing];
|
|
136
|
+
for (const incomingActivity of incoming) {
|
|
137
|
+
const index = byId.get(incomingActivity.id);
|
|
138
|
+
if (index === undefined) {
|
|
139
|
+
byId.set(incomingActivity.id, merged.length);
|
|
140
|
+
merged.push(incomingActivity);
|
|
141
|
+
} else {
|
|
142
|
+
merged[index] = { ...merged[index], ...incomingActivity };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Optional counters are used by retry-aware accounting to keep live progress
|
|
149
|
+
* monotonic across attempts. */
|
|
150
|
+
interface RunUpdateOffset {
|
|
151
|
+
tokensOffset?: number;
|
|
152
|
+
toolUsesOffset?: number;
|
|
153
|
+
}
|
|
154
|
+
/** Mirror a progress update from runAgent into a TaskProgress row.
|
|
155
|
+
*
|
|
156
|
+
* Runner callbacks report attempt-local counters. Offset/merge them so a single
|
|
157
|
+
* TaskProgress row is monotonic across whole-task retries.
|
|
158
|
+
*/
|
|
108
159
|
export function updateProgressFromRun(
|
|
109
160
|
p: TaskProgress,
|
|
110
161
|
u: AgentProgressUpdate,
|
|
162
|
+
offsets: RunUpdateOffset = {},
|
|
111
163
|
): void {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
p.
|
|
164
|
+
const cumulativeTokens = (offsets.tokensOffset ?? 0) + u.tokens;
|
|
165
|
+
const cumulativeTools = (offsets.toolUsesOffset ?? 0) + u.toolUses;
|
|
166
|
+
p.tokens = Math.max(p.tokens, cumulativeTokens);
|
|
167
|
+
p.toolUses = Math.max(p.toolUses, cumulativeTools);
|
|
168
|
+
|
|
169
|
+
p.durationMs = Math.max(p.durationMs, u.durationMs);
|
|
115
170
|
p.lastActivityAt = u.lastActivityAt;
|
|
116
|
-
p.activities = u.activities;
|
|
171
|
+
p.activities = mergeToolActivities(p.activities, u.activities);
|
|
117
172
|
p.failureKind = u.failureKind;
|
|
118
173
|
}
|
|
119
|
-
|
|
120
174
|
/** Mirror a completed TaskResult into a TaskProgress row (status/duration/error). */
|
|
121
175
|
function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
|
|
122
176
|
p.status = r.error ? "failed" : "done";
|
|
@@ -195,19 +249,33 @@ function isClearlyTransientFinalError(error: string | undefined): boolean {
|
|
|
195
249
|
);
|
|
196
250
|
}
|
|
197
251
|
|
|
198
|
-
function canRetryWholeTask(
|
|
252
|
+
function canRetryWholeTask(
|
|
253
|
+
task: ResolvedTask,
|
|
254
|
+
result: TaskResult,
|
|
255
|
+
hasBashExecution = false,
|
|
256
|
+
): boolean {
|
|
199
257
|
// Whole-task retry can repeat tool side effects. Keep it to stateless fresh
|
|
200
258
|
// tasks, and only when our touched-file accounting says the failed attempt
|
|
201
259
|
// did not write/edit anything. A `model_error` (usage limit, auth, quota) is
|
|
202
260
|
// not transient for the resolved model — retrying with the same model just
|
|
203
261
|
// hits the same wall, so skip it and let the parent resume with a different
|
|
204
|
-
// model (see the hint in formatFailedTask).
|
|
262
|
+
// model (see the hint in formatFailedTask). Similarly, once bash executes,
|
|
263
|
+
// any retry would replay non-idempotent side effects, so suppress it.
|
|
264
|
+
// We gate on *observed* bash activity plus touchedFiles, not on the tool set
|
|
265
|
+
// itself: the default tool set includes `bash` for most tasks, and suppressing
|
|
266
|
+
// retry for every bash-capable task would disable the useful transient-error
|
|
267
|
+
// retry path even when no side effects occurred. The stricter “any bash tool
|
|
268
|
+
// → no retry” rule matches the “no filesystem isolation” stance, but is too
|
|
269
|
+
// restrictive for retry safety — touched-file accounting and observed activity
|
|
270
|
+
// are the direct side-effect signals.
|
|
205
271
|
return (
|
|
206
272
|
result.failureKind !== "stalled" &&
|
|
207
273
|
result.failureKind !== "model_error" &&
|
|
274
|
+
result.failureKind !== "deadline_exceeded" &&
|
|
208
275
|
!task.sessionId &&
|
|
209
276
|
!task.resumeFrom &&
|
|
210
277
|
result.touchedFiles.length === 0 &&
|
|
278
|
+
!hasBashExecution &&
|
|
211
279
|
isClearlyTransientFinalError(result.error)
|
|
212
280
|
);
|
|
213
281
|
}
|
|
@@ -215,16 +283,23 @@ function canRetryWholeTask(task: ResolvedTask, result: TaskResult): boolean {
|
|
|
215
283
|
async function sleepForWholeTaskRetry(
|
|
216
284
|
signal: AbortSignal | undefined,
|
|
217
285
|
delayMs: number,
|
|
286
|
+
deadlineAt?: number,
|
|
218
287
|
): Promise<void> {
|
|
219
288
|
if (signal?.aborted) return;
|
|
289
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) return;
|
|
290
|
+
|
|
291
|
+
const retryAt = Date.now() + delayMs;
|
|
292
|
+
const wakeAt =
|
|
293
|
+
deadlineAt !== undefined ? Math.min(retryAt, deadlineAt) : retryAt;
|
|
294
|
+
|
|
220
295
|
await new Promise<void>((resolve) => {
|
|
221
|
-
let
|
|
296
|
+
let clear: (() => void) | undefined;
|
|
222
297
|
const done = () => {
|
|
223
|
-
|
|
298
|
+
clear?.();
|
|
224
299
|
signal?.removeEventListener("abort", done);
|
|
225
300
|
resolve();
|
|
226
301
|
};
|
|
227
|
-
|
|
302
|
+
clear = scheduleDeadline(wakeAt, done);
|
|
228
303
|
if (!signal) return;
|
|
229
304
|
signal.addEventListener("abort", done, { once: true });
|
|
230
305
|
});
|
|
@@ -244,7 +319,7 @@ async function buildDelegateSession(
|
|
|
244
319
|
// resource loaders are cached after the first call; provider-configured or
|
|
245
320
|
// allowlisted-extension loaders are deliberately fresh per session because
|
|
246
321
|
// 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.
|
|
322
|
+
// scans for project AGENTS.md/skills) and the system prompt is per named-agent.
|
|
248
323
|
// The custom prompt overrides the default AgentSession system prompt.
|
|
249
324
|
// Pass only the provider needed by this task. This keeps a non-Kilo task
|
|
250
325
|
// from receiving Kilo's provider/auth adapter merely because Kilo is also
|
|
@@ -454,7 +529,7 @@ function resolveResumableSessionFile(
|
|
|
454
529
|
* Used by both sync (params.async === false) and async (params.async === true) paths.
|
|
455
530
|
* When task.sessionId is set, the entire acquire/run/close lifecycle runs under
|
|
456
531
|
* a per-session mutex so concurrent tasks with the same sessionId serialize
|
|
457
|
-
* cleanly. The lock also covers
|
|
532
|
+
* cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
|
|
458
533
|
export async function runResolvedTask(
|
|
459
534
|
env: TaskRunEnv,
|
|
460
535
|
task: ResolvedTask,
|
|
@@ -496,15 +571,18 @@ async function runResolvedTaskUnlocked(
|
|
|
496
571
|
p.model = task.model?.id;
|
|
497
572
|
|
|
498
573
|
// ── Session action handling ───────────────────────────────────────
|
|
499
|
-
if (task.
|
|
574
|
+
if (task.sessionAction === "close") {
|
|
500
575
|
if (!task.sessionId) {
|
|
501
576
|
return finishTask(
|
|
502
577
|
env,
|
|
503
578
|
p,
|
|
504
|
-
failTask(task, "
|
|
579
|
+
failTask(task, "sessionAction='close' requires sessionId."),
|
|
505
580
|
);
|
|
506
581
|
}
|
|
507
|
-
|
|
582
|
+
// The per-session lock for action-based operations is already held by the
|
|
583
|
+
// outer runResolvedTask() wrapper. Use the internal close helper to avoid a
|
|
584
|
+
// reentrant deadlock on the same key.
|
|
585
|
+
const closed = await pool._closePooledAgentWithoutLock(task.sessionId);
|
|
508
586
|
return finishTask(
|
|
509
587
|
env,
|
|
510
588
|
p,
|
|
@@ -518,7 +596,7 @@ async function runResolvedTaskUnlocked(
|
|
|
518
596
|
);
|
|
519
597
|
}
|
|
520
598
|
|
|
521
|
-
if (task.
|
|
599
|
+
if (task.sessionAction === "list") {
|
|
522
600
|
return finishTask(
|
|
523
601
|
env,
|
|
524
602
|
p,
|
|
@@ -530,15 +608,53 @@ async function runResolvedTaskUnlocked(
|
|
|
530
608
|
);
|
|
531
609
|
}
|
|
532
610
|
|
|
611
|
+
let hasBashExecution = false;
|
|
612
|
+
let cumulativeTokens = 0;
|
|
613
|
+
let cumulativeToolUses = 0;
|
|
614
|
+
const taskStartedAt = Date.now();
|
|
615
|
+
const deadlineAt =
|
|
616
|
+
task.deadlineMs && task.deadlineMs > 0
|
|
617
|
+
? taskStartedAt + task.deadlineMs
|
|
618
|
+
: undefined;
|
|
619
|
+
let accumulatedUsage = emptyUsage();
|
|
620
|
+
|
|
621
|
+
const onAttemptProgress = (u: AgentProgressUpdate): void => {
|
|
622
|
+
if (
|
|
623
|
+
!hasBashExecution &&
|
|
624
|
+
u.activities.some((activity) => activity.name === "bash")
|
|
625
|
+
) {
|
|
626
|
+
hasBashExecution = true;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const mapped: AgentProgressUpdate = {
|
|
630
|
+
...u,
|
|
631
|
+
tokens: cumulativeTokens + u.tokens,
|
|
632
|
+
toolUses: cumulativeToolUses + u.toolUses,
|
|
633
|
+
durationMs: Date.now() - taskStartedAt,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// Keep live totals monotonic across attempts.
|
|
637
|
+
p.tokens = Math.max(p.tokens, mapped.tokens);
|
|
638
|
+
p.toolUses = Math.max(p.toolUses, mapped.toolUses);
|
|
639
|
+
if (mapped.durationMs > p.durationMs) {
|
|
640
|
+
p.durationMs = mapped.durationMs;
|
|
641
|
+
}
|
|
642
|
+
env.onProgress(p, mapped);
|
|
643
|
+
};
|
|
644
|
+
|
|
533
645
|
const runAttempt = async (): Promise<TaskResult> => {
|
|
646
|
+
let attemptToolUsesObserved = 0;
|
|
647
|
+
const onProgress = (u: AgentProgressUpdate): void => {
|
|
648
|
+
attemptToolUsesObserved = Math.max(attemptToolUsesObserved, u.toolUses);
|
|
649
|
+
onAttemptProgress(u);
|
|
650
|
+
};
|
|
651
|
+
|
|
534
652
|
// ── Pool / resume / fresh-agent resolution ────────────────────────
|
|
535
653
|
const acquired = await acquireAgentSession(env, task, p);
|
|
536
654
|
if ("error" in acquired) return acquired.error;
|
|
537
655
|
|
|
538
656
|
// A pool hit is already owned by the pool. Fresh/resumed sessions belong
|
|
539
|
-
// to this attempt until commit
|
|
540
|
-
// this state local makes cleanup a finally invariant rather than a list
|
|
541
|
-
// of special cases for aborts, stalls, and provider failures.
|
|
657
|
+
// to this attempt until commit/recordUse/close logic runs.
|
|
542
658
|
let sessionReleased = !acquired.lifecycleOwnsSession;
|
|
543
659
|
try {
|
|
544
660
|
// Re-check abort after acquisition. The pre-acquire check at the top can
|
|
@@ -546,22 +662,29 @@ async function runResolvedTaskUnlocked(
|
|
|
546
662
|
// baseline. runAgentSession re-checks after attaching its listener, but a
|
|
547
663
|
// cancelled ticket should not even start the subagent (no file writes, no
|
|
548
664
|
// pool insert).
|
|
549
|
-
if (env.signal?.aborted)
|
|
665
|
+
if (env.signal?.aborted) {
|
|
666
|
+
return failTask(task, "Aborted");
|
|
667
|
+
}
|
|
550
668
|
|
|
551
669
|
// Snapshot git status before the run so touchedFiles can diff after.
|
|
552
670
|
// AgentSession owns retry/compaction internally — runAgentSession just
|
|
553
|
-
// drives the prompt and maps events to the progress model.
|
|
671
|
+
// drives the prompt and maps events to the progress model. Git failures
|
|
672
|
+
// degrade to an undefined baseline, which tells the runner to skip
|
|
673
|
+
// git-based attribution entirely; see getGitChangedFiles for the
|
|
674
|
+
// contract.
|
|
554
675
|
const gitBaseline = await getGitChangedFiles(task.cwd);
|
|
555
|
-
let r = await
|
|
676
|
+
let r = await runAgentSessionForTesting(
|
|
556
677
|
acquired.session,
|
|
557
678
|
task.prompt,
|
|
558
679
|
{ cwd: task.cwd },
|
|
559
680
|
env.signal,
|
|
560
|
-
|
|
681
|
+
onProgress,
|
|
561
682
|
gitBaseline,
|
|
562
|
-
|
|
683
|
+
taskStartedAt,
|
|
684
|
+
deadlineAt,
|
|
563
685
|
);
|
|
564
686
|
|
|
687
|
+
cumulativeTokens += r.tokens;
|
|
565
688
|
// The signal can fire after the pre-run check or while the runner is
|
|
566
689
|
// collecting post-prompt evidence. Keep cancellation from looking like
|
|
567
690
|
// success; finally below releases any uncommitted session.
|
|
@@ -569,62 +692,86 @@ async function runResolvedTaskUnlocked(
|
|
|
569
692
|
r = { ...r, error: "Aborted" };
|
|
570
693
|
}
|
|
571
694
|
|
|
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
695
|
const sessionFile = resolveResumableSessionFile(
|
|
594
696
|
acquired.sessionFile,
|
|
595
697
|
acquired.sessionManager,
|
|
596
698
|
r.error,
|
|
597
699
|
);
|
|
598
700
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
701
|
+
if (task.sessionId) {
|
|
702
|
+
if (acquired.lifecycleOwnsSession) {
|
|
703
|
+
// Pool misses (including resumeFrom) transfer ownership only on
|
|
704
|
+
// successful completion; failures are owned by lifecycle and must
|
|
705
|
+
// be disposed in this finally path.
|
|
706
|
+
if (
|
|
707
|
+
!r.error &&
|
|
708
|
+
r.failureKind !== "stalled" &&
|
|
709
|
+
r.failureKind !== "deadline_exceeded"
|
|
710
|
+
) {
|
|
711
|
+
const committed = pool.commit(task.sessionId, {
|
|
712
|
+
session: acquired.session,
|
|
713
|
+
sessionManager: acquired.sessionManager,
|
|
714
|
+
sessionFile: acquired.sessionFile,
|
|
715
|
+
frozen: {
|
|
716
|
+
systemPrompt: task.systemPrompt,
|
|
717
|
+
model: task.model,
|
|
718
|
+
thinking: task.thinking,
|
|
719
|
+
tools: task.tools,
|
|
720
|
+
cwd: task.cwd,
|
|
721
|
+
},
|
|
722
|
+
tokens: r.tokens,
|
|
723
|
+
});
|
|
724
|
+
sessionReleased = sessionReleased || committed;
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
// A stalled, parent-aborted, or mid-prompt deadline-exceeded pooled
|
|
728
|
+
// attempt is not safe to keep; the session may have been mutated.
|
|
729
|
+
// A pre-prompt deadline (runner never called session.prompt()) left
|
|
730
|
+
// the session in its pre-task state, so return it to the pool intact.
|
|
731
|
+
if (
|
|
732
|
+
r.failureKind === "stalled" ||
|
|
733
|
+
r.error === "Aborted" ||
|
|
734
|
+
(r.failureKind === "deadline_exceeded" && r.prompted !== false)
|
|
735
|
+
) {
|
|
736
|
+
try {
|
|
737
|
+
sessionReleased =
|
|
738
|
+
(await pool._closePooledAgentWithoutLock(task.sessionId)) ||
|
|
739
|
+
sessionReleased;
|
|
740
|
+
} catch (error) {
|
|
741
|
+
// Preserve the primary failure result while logging the cleanup
|
|
742
|
+
// failure explicitly. A pooled session may still be removed by
|
|
743
|
+
// the pool; a pool-miss remains lifecycle-owned and is handled
|
|
744
|
+
// by the finally path above.
|
|
745
|
+
console.error(
|
|
746
|
+
`[delegate] failed to dispose aborted, stalled, or deadline-exceeded pooled session '${task.sessionId}'`,
|
|
747
|
+
error,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
} else if (r.failureKind !== "deadline_exceeded") {
|
|
751
|
+
// Pool hits stay owned by the pool, and non-stalled, non-aborted
|
|
752
|
+
// completions (including failed attempts) must still count usage.
|
|
753
|
+
pool.recordUse(task.sessionId, r.tokens);
|
|
754
|
+
}
|
|
755
|
+
// Pre-prompt deadline (prompted === false): the pooled session was
|
|
756
|
+
// checked out but never used. Leave it in the pool with no usage
|
|
757
|
+
// recorded.
|
|
758
|
+
}
|
|
617
759
|
}
|
|
618
760
|
|
|
761
|
+
accumulatedUsage = addUsage(accumulatedUsage, r.usage);
|
|
762
|
+
cumulativeToolUses += attemptToolUsesObserved;
|
|
763
|
+
|
|
619
764
|
return {
|
|
765
|
+
id: task.id,
|
|
620
766
|
agent: task.agentName,
|
|
621
767
|
output: r.output,
|
|
622
768
|
error: r.error,
|
|
623
|
-
// Classify the failure: the runner sets `stalled` for the
|
|
624
|
-
// watchdog; here we add `model_error` for failures
|
|
625
|
-
// the resolved model (usage limit, auth, quota) so the
|
|
626
|
-
// "switch model" hint instead of a same-model retry
|
|
627
|
-
// canRetryWholeTask skips the pointless same-model
|
|
769
|
+
// Classify the failure: the runner sets `stalled` for the
|
|
770
|
+
// inactivity watchdog; here we add `model_error` for failures
|
|
771
|
+
// attributable to the resolved model (usage limit, auth, quota) so the
|
|
772
|
+
// parent gets a "switch model" hint instead of a same-model retry
|
|
773
|
+
// hint, and so canRetryWholeTask skips the pointless same-model
|
|
774
|
+
// retry.
|
|
628
775
|
failureKind:
|
|
629
776
|
r.failureKind ??
|
|
630
777
|
(r.error && isModelAttributableError(r.error)
|
|
@@ -635,6 +782,7 @@ async function runResolvedTaskUnlocked(
|
|
|
635
782
|
usage: r.usage,
|
|
636
783
|
sessionFile,
|
|
637
784
|
touchedFiles: r.touchedFiles,
|
|
785
|
+
attributedFiles: r.attributedFiles ?? [],
|
|
638
786
|
};
|
|
639
787
|
} finally {
|
|
640
788
|
// This runs for ordinary success, normal provider failure, whole-task
|
|
@@ -644,29 +792,53 @@ async function runResolvedTaskUnlocked(
|
|
|
644
792
|
}
|
|
645
793
|
};
|
|
646
794
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
795
|
+
const buildDeadlineExceededResult = (prior?: TaskResult): TaskResult => {
|
|
796
|
+
const budgetMs = Math.max(0, (deadlineAt ?? 0) - taskStartedAt);
|
|
797
|
+
return {
|
|
798
|
+
id: task.id,
|
|
799
|
+
agent: task.agentName,
|
|
800
|
+
output: prior?.output ?? "",
|
|
801
|
+
error: formatDeadlineExceededError(budgetMs),
|
|
802
|
+
failureKind: "deadline_exceeded",
|
|
803
|
+
durationMs: prior?.durationMs ?? 0,
|
|
804
|
+
tokens: prior?.tokens ?? 0,
|
|
805
|
+
usage: prior?.usage ?? emptyUsage(),
|
|
806
|
+
sessionFile: prior?.sessionFile,
|
|
807
|
+
touchedFiles: prior?.touchedFiles ?? [],
|
|
808
|
+
attributedFiles: prior?.attributedFiles ?? [],
|
|
809
|
+
};
|
|
660
810
|
};
|
|
811
|
+
|
|
812
|
+
let result: TaskResult;
|
|
813
|
+
try {
|
|
814
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
815
|
+
result = buildDeadlineExceededResult(undefined);
|
|
816
|
+
} else {
|
|
817
|
+
result = await runAttempt();
|
|
818
|
+
}
|
|
819
|
+
} catch (err) {
|
|
820
|
+
const failure = failTask(
|
|
821
|
+
task,
|
|
822
|
+
err instanceof Error ? err.message : String(err),
|
|
823
|
+
);
|
|
824
|
+
return finishTask(env, p, {
|
|
825
|
+
...failure,
|
|
826
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
827
|
+
tokens: accumulatedUsage.totalTokens,
|
|
828
|
+
usage: accumulatedUsage,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
661
832
|
const maxRetries = resolvedWholeTaskMaxRetries();
|
|
662
833
|
const baseDelayMs = resolvedWholeTaskBaseDelayMs();
|
|
663
834
|
for (
|
|
664
835
|
let retry = 0;
|
|
665
|
-
retry < maxRetries && canRetryWholeTask(task, result);
|
|
836
|
+
retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
|
|
666
837
|
retry++
|
|
667
838
|
) {
|
|
668
839
|
const delayMs = baseDelayMs * 2 ** retry;
|
|
669
|
-
|
|
840
|
+
p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
|
|
841
|
+
await sleepForWholeTaskRetry(env.signal, delayMs, deadlineAt);
|
|
670
842
|
if (env.signal?.aborted) {
|
|
671
843
|
// Preserve any partial output/session path from the last failed attempt
|
|
672
844
|
// while recording that the retry loop was aborted. The task already
|
|
@@ -674,27 +846,52 @@ async function runResolvedTaskUnlocked(
|
|
|
674
846
|
result = {
|
|
675
847
|
...result,
|
|
676
848
|
error: "Aborted",
|
|
849
|
+
durationMs: Date.now() - taskStartedAt,
|
|
677
850
|
tokens: accumulatedUsage.totalTokens,
|
|
678
851
|
usage: accumulatedUsage,
|
|
852
|
+
touchedFiles: result.touchedFiles,
|
|
853
|
+
sessionFile: result.sessionFile,
|
|
679
854
|
};
|
|
680
855
|
break;
|
|
681
856
|
}
|
|
857
|
+
|
|
858
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
859
|
+
result = buildDeadlineExceededResult(result);
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
|
|
682
863
|
p.status = "running";
|
|
683
864
|
p.error = undefined;
|
|
684
865
|
p.failureKind = undefined;
|
|
685
866
|
env.onStatusChange?.();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
867
|
+
try {
|
|
868
|
+
result = await runAttempt();
|
|
869
|
+
} catch (err) {
|
|
870
|
+
const failure = failTask(
|
|
871
|
+
task,
|
|
872
|
+
err instanceof Error ? err.message : String(err),
|
|
873
|
+
);
|
|
874
|
+
result = {
|
|
875
|
+
...failure,
|
|
876
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
877
|
+
tokens: accumulatedUsage.totalTokens,
|
|
878
|
+
usage: accumulatedUsage,
|
|
879
|
+
};
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
693
882
|
}
|
|
694
|
-
|
|
883
|
+
|
|
884
|
+
return finishTask(env, p, {
|
|
885
|
+
...result,
|
|
886
|
+
durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
|
|
887
|
+
tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
|
|
888
|
+
usage: accumulatedUsage,
|
|
889
|
+
});
|
|
695
890
|
} catch (err) {
|
|
696
891
|
// Any acquired session is released by runAttempt's finally before an
|
|
697
|
-
// exception reaches this boundary.
|
|
892
|
+
// exception reaches this boundary. This outer catch handles unexpected
|
|
893
|
+
// throws before retry accounting is initialized, so report a minimal
|
|
894
|
+
// failure without accumulated usage.
|
|
698
895
|
return finishTask(
|
|
699
896
|
env,
|
|
700
897
|
p,
|