@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/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
- /** Mirror a progress update from runAgent into a TaskProgress row. */
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
- p.tokens = u.tokens;
113
- p.toolUses = u.toolUses;
114
- p.durationMs = u.durationMs;
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(task: ResolvedTask, result: TaskResult): boolean {
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 timeout: ReturnType<typeof setTimeout>;
296
+ let clear: (() => void) | undefined;
222
297
  const done = () => {
223
- clearTimeout(timeout);
298
+ clear?.();
224
299
  signal?.removeEventListener("abort", done);
225
300
  resolve();
226
301
  };
227
- timeout = setTimeout(done, delayMs);
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 action='close' and the early-busy/abort paths. */
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.action === "close") {
574
+ if (task.sessionAction === "close") {
500
575
  if (!task.sessionId) {
501
576
  return finishTask(
502
577
  env,
503
578
  p,
504
- failTask(task, "action='close' requires sessionId."),
579
+ failTask(task, "sessionAction='close' requires sessionId."),
505
580
  );
506
581
  }
507
- const closed = await pool.closePooledAgent(task.sessionId);
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.action === "list") {
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() 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.
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) return failTask(task, "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 runAgentSession(
676
+ let r = await runAgentSessionForTesting(
556
677
  acquired.session,
557
678
  task.prompt,
558
679
  { cwd: task.cwd },
559
680
  env.signal,
560
- (u) => env.onProgress(p, u),
681
+ onProgress,
561
682
  gitBaseline,
562
- Date.now(),
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
- // 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
- });
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 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.
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
- // 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,
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
- await sleepForWholeTaskRetry(env.signal, delayMs);
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
- result = await runAttempt();
687
- accumulatedUsage = addUsage(accumulatedUsage, result.usage);
688
- result = {
689
- ...result,
690
- tokens: accumulatedUsage.totalTokens,
691
- usage: accumulatedUsage,
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
- return finishTask(env, p, result);
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,