@bermudi/pi-delegate 0.1.1 → 0.1.3

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/runner.ts CHANGED
@@ -19,6 +19,16 @@ import type {
19
19
  ToolActivity,
20
20
  } from "./types.ts";
21
21
 
22
+ /** Human-facing error for an expired `deadlineMs` budget.
23
+ *
24
+ * This is exported so the lifecycle pre-check can return the same text as the
25
+ * runner, keeping the sync and (future) async paths consistent. */
26
+ export function formatDeadlineExceededError(budgetMs: number): string {
27
+ return `Deadline exceeded: task exceeded its ${fmtDuration(
28
+ Math.max(0, budgetMs),
29
+ )} wall-clock budget and was cooperatively aborted (not a hard kill). Completed writes and commands remain.`;
30
+ }
31
+
22
32
  /**
23
33
  * Run a single prompt against a live `AgentSession` and report progress.
24
34
  *
@@ -34,6 +44,15 @@ import type {
34
44
  * 4. snapshots usage before/after the prompt for token delta accounting, and
35
45
  * 5. computes touched files from activity + git diff.
36
46
  *
47
+ * Touched-file tracking is best-effort. Explicit edit/write tool calls are
48
+ * captured from the activity log. bash mutations are only captured via git
49
+ * status when the cwd is a git repo and git is available; in a non-git
50
+ * directory, bash-mutated files are not reported. Git failures on either the
51
+ * baseline or post-run snapshot degrade to an empty diff, and a failed baseline
52
+ * suppresses git-based attribution entirely so pre-existing dirty files are not
53
+ * blamed on this task. The resulting list is a lower bound, not a complete
54
+ * record.
55
+ *
37
56
  * Output is captured from AgentSession events so compaction cannot erase it
38
57
  * before collection; `session.messages` is only a guarded fallback for
39
58
  * providers that fail before emitting message_end. AgentSession's internal
@@ -46,16 +65,24 @@ export async function runAgentSession(
46
65
  config: { cwd: string },
47
66
  signal: AbortSignal | undefined,
48
67
  onProgress: ((update: AgentProgressUpdate) => void) | undefined,
49
- gitBaseline: Set<string>,
68
+ gitBaseline: Set<string> | undefined,
50
69
  start: number,
70
+ deadlineAt?: number,
51
71
  ): Promise<{
52
72
  output: string;
53
73
  error?: string;
54
74
  durationMs: number;
55
75
  tokens: number;
56
76
  usage: Usage;
77
+ /** Best-effort union of activity- and git-derived touched files for display. */
57
78
  touchedFiles: string[];
79
+ /** Files directly attributable to this run's edit/write tool calls. */
80
+ attributedFiles: string[];
58
81
  failureKind?: TaskFailureKind;
82
+ /** Whether `session.prompt()` was actually invoked. Distinguishes a pre-prompt
83
+ * deadline (session never used) from a mid-prompt deadline (session was
84
+ * prompted and may have partial state mutations). */
85
+ prompted: boolean;
59
86
  }> {
60
87
  const startTime = start ?? Date.now();
61
88
  const stallTimeoutMs = getStallTimeoutMs();
@@ -65,6 +92,9 @@ export async function runAgentSession(
65
92
  let stalled = false;
66
93
  let stalledPhase: string | undefined;
67
94
  let clearStallDeadline: (() => void) | undefined;
95
+ let deadlineExceeded = false;
96
+ let clearDeadline: (() => void) | undefined;
97
+ let prompted = false;
68
98
  const activities: ToolActivity[] = [];
69
99
  const pendingById = new Map<string, ToolActivity>();
70
100
  let sessionEventGeneration = 0;
@@ -110,7 +140,7 @@ export async function runAgentSession(
110
140
  new Promise<void>((resolve) => setImmediate(resolve));
111
141
 
112
142
  const requestSessionCancellation = (
113
- source: "parent-aborted" | "stalled",
143
+ source: "parent-aborted" | "stalled" | "deadline",
114
144
  ): void => {
115
145
  const logFailure = (operation: string, error: unknown) => {
116
146
  console.error(`[delegate] ${source} subagent ${operation} failed`, error);
@@ -171,9 +201,14 @@ export async function runAgentSession(
171
201
  */
172
202
  const waitForSessionQuiescence = async (): Promise<void> => {
173
203
  const cancelledGraceMs = 50;
174
- const cancellationRequested = () => signal?.aborted || stalled;
175
- const cancellationSource = (): "parent-aborted" | "stalled" =>
176
- signal?.aborted ? "parent-aborted" : "stalled";
204
+ const cancellationRequested = () =>
205
+ signal?.aborted || stalled || deadlineExceeded;
206
+ const cancellationSource = (): "parent-aborted" | "stalled" | "deadline" =>
207
+ signal?.aborted
208
+ ? "parent-aborted"
209
+ : deadlineExceeded
210
+ ? "deadline"
211
+ : "stalled";
177
212
  let quietTurns = 0;
178
213
  let graceWaited = false;
179
214
  while (quietTurns < 2) {
@@ -235,14 +270,24 @@ export async function runAgentSession(
235
270
  const fireProgress = () => {
236
271
  if (!onProgress) return;
237
272
  const delta = currentUsage().totalTokens;
238
- onProgress({
239
- tokens: delta,
240
- toolUses,
241
- durationMs: Date.now() - startTime,
242
- lastActivityAt,
243
- activities: [...activities],
244
- failureKind: stalled ? "stalled" : undefined,
245
- });
273
+ try {
274
+ onProgress({
275
+ tokens: delta,
276
+ toolUses,
277
+ durationMs: Date.now() - startTime,
278
+ lastActivityAt,
279
+ activities: [...activities],
280
+ failureKind: signal?.aborted
281
+ ? undefined
282
+ : deadlineExceeded
283
+ ? "deadline_exceeded"
284
+ : stalled
285
+ ? "stalled"
286
+ : undefined,
287
+ });
288
+ } catch (error) {
289
+ console.error("[delegate] progress callback threw; continuing", error);
290
+ }
246
291
  };
247
292
 
248
293
  const stallError = () =>
@@ -252,10 +297,12 @@ export async function runAgentSession(
252
297
  clearStallDeadline = undefined;
253
298
  };
254
299
  const abortForStall = () => {
255
- if (stalled || signal?.aborted) return;
300
+ if (stalled || signal?.aborted || deadlineExceeded) return;
256
301
  stalled = true;
257
302
  stalledPhase = phase;
258
303
  clearStallWatchdog();
304
+ clearDeadline?.();
305
+ clearDeadline = undefined;
259
306
  console.warn(
260
307
  `[delegate] stalled subagent detected after ${fmtDuration(stallTimeoutMs)} while ${phase}; requesting cooperative cancellation`,
261
308
  );
@@ -269,12 +316,41 @@ export async function runAgentSession(
269
316
  };
270
317
  const armStallWatchdog = (graceMs = 0) => {
271
318
  clearStallWatchdog();
272
- if (!stallTimeoutMs || stalled || signal?.aborted) return;
319
+ if (!stallTimeoutMs || stalled || signal?.aborted || deadlineExceeded)
320
+ return;
273
321
 
274
322
  const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
275
323
  const deadline = Date.now() + stallTimeoutMs + grace;
276
324
  clearStallDeadline = scheduleDeadline(deadline, abortForStall);
277
325
  };
326
+
327
+ const deadlineError = () =>
328
+ formatDeadlineExceededError(deadlineAt ? deadlineAt - startTime : 0);
329
+ const clearDeadlineWatchdog = () => {
330
+ clearDeadline?.();
331
+ clearDeadline = undefined;
332
+ };
333
+ const abortForDeadline = () => {
334
+ if (deadlineExceeded || signal?.aborted) return;
335
+ deadlineExceeded = true;
336
+ clearDeadlineWatchdog();
337
+ clearStallWatchdog();
338
+ console.warn(
339
+ `[delegate] subagent exceeded its wall-clock deadline; requesting cooperative cancellation`,
340
+ );
341
+ requestSessionCancellation("deadline");
342
+ fireProgress();
343
+ };
344
+ const armDeadlineWatchdog = () => {
345
+ clearDeadlineWatchdog();
346
+ if (!deadlineAt || deadlineExceeded || stalled || signal?.aborted) return;
347
+ if (Date.now() >= deadlineAt) {
348
+ abortForDeadline();
349
+ return;
350
+ }
351
+ clearDeadline = scheduleDeadline(deadlineAt, abortForDeadline);
352
+ };
353
+
278
354
  const noteActivity = (nextPhase: string, graceMs = 0) => {
279
355
  lastActivityAt = Date.now();
280
356
  phase = nextPhase;
@@ -593,6 +669,7 @@ export async function runAgentSession(
593
669
  if (signal) {
594
670
  abortHandler = () => {
595
671
  clearStallWatchdog();
672
+ clearDeadlineWatchdog();
596
673
  // Fire-and-forget: prompt()/the quiescence barrier observe cancellation
597
674
  // and then return the partial evidence that this runner reports.
598
675
  requestSessionCancellation("parent-aborted");
@@ -621,34 +698,107 @@ export async function runAgentSession(
621
698
  tokens: 0,
622
699
  usage: emptyUsage(),
623
700
  touchedFiles: [],
701
+ attributedFiles: [],
702
+ prompted: false,
624
703
  };
625
704
  }
626
705
 
627
- // Start detection only once the session is ready to receive its prompt;
628
- // queued delegate tasks never enter this runner and therefore never time out.
629
- armStallWatchdog();
630
- fireProgress();
706
+ // If the deadline is already in the past, request cooperative cancellation
707
+ // and wait for the session to settle before returning ownership. Starting a
708
+ // prompt after the deadline would let the session do work without an active
709
+ // deadline watchdog.
710
+ if (deadlineAt && Date.now() >= deadlineAt) {
711
+ abortForDeadline();
712
+ // A pre-prompt deadline still calls session.abort(), so any in-flight
713
+ // compaction or extension work must settle before lifecycle disposes or
714
+ // reuses the session. The same quiescence barrier used after prompt()
715
+ // handles a never-prompted session safely — it just observes idle state.
716
+ //
717
+ // Keep the parent abort listener armed during the quiescence wait. If the
718
+ // parent signal aborts while we are waiting, the runner should report the
719
+ // parent cancellation ("Aborted") rather than the pre-expired deadline.
720
+ await waitForSessionQuiescence();
721
+ clearStallWatchdog();
722
+ clearDeadlineWatchdog();
723
+ if (signal && abortHandler)
724
+ signal.removeEventListener("abort", abortHandler);
725
+ unsubscribe();
726
+ if (signal?.aborted) {
727
+ return {
728
+ output: "",
729
+ error: "Aborted",
730
+ durationMs: Date.now() - startTime,
731
+ tokens: 0,
732
+ usage: emptyUsage(),
733
+ touchedFiles: [],
734
+ attributedFiles: [],
735
+ prompted: false,
736
+ };
737
+ }
738
+ return {
739
+ output: "(no output)",
740
+ error: deadlineError(),
741
+ durationMs: Date.now() - startTime,
742
+ tokens: 0,
743
+ usage: emptyUsage(),
744
+ touchedFiles: [],
745
+ attributedFiles: [],
746
+ failureKind: "deadline_exceeded",
747
+ prompted: false,
748
+ };
749
+ }
631
750
 
632
751
  try {
752
+ // Start detection only once the session is ready to receive its prompt;
753
+ // queued delegate tasks never enter this runner and therefore never time out.
754
+ armStallWatchdog();
755
+ armDeadlineWatchdog();
756
+ fireProgress();
757
+
758
+ prompted = true;
633
759
  await session.prompt(prompt);
634
760
  await waitForSessionQuiescence();
761
+
762
+ // The model is done; inactivity is no longer the right watchdog. Git
763
+ // evidence collection can take several seconds (up to 5s per git call),
764
+ // so keep the wall-clock deadline armed through it while preventing the
765
+ // stall watchdog from firing on the silent git commands.
635
766
  clearStallWatchdog();
767
+ phase = "collecting git evidence";
768
+ lastActivityAt = Date.now();
636
769
 
770
+ const gitAfter = await getGitChangedFiles(config.cwd);
771
+ if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
772
+ abortForDeadline();
773
+ }
774
+ clearDeadlineWatchdog();
775
+ // If the deadline or parent abort fired after the first quiescence barrier,
776
+ // including while Git was running, the fire-and-forget
777
+ // cancellation is still unwinding. Wait for the same quiescence barrier so
778
+ // lifecycle does not dispose/reuse the session while compaction or an
779
+ // extension callback is still active.
780
+ if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
781
+
782
+ // Recompute evidence after the final quiescence wait. Output, usage,
783
+ // activity-derived touched files, and the session error state can all be
784
+ // mutated by the unwinding work that the barrier just waited for.
637
785
  const state = session.state as { errorMessage?: string };
638
786
  const output = capturedOutput();
639
787
  const usage = currentUsage();
640
-
641
- // Compute touched files: union of activity-based (edit/write) and git diff
642
- // against the pre-prompt baseline. Independent of the runner's event model.
643
788
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
644
- const gitAfter = await getGitChangedFiles(config.cwd);
645
- const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
789
+ const fromGit =
790
+ gitBaseline && gitAfter
791
+ ? [...gitAfter].filter((f) => !gitBaseline.has(f))
792
+ : [];
646
793
  const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
647
- const errorMessage = stalled
648
- ? stallError()
649
- : signal?.aborted
650
- ? "Aborted"
651
- : state.errorMessage;
794
+ const attributedFiles = fromActivities;
795
+ const errorMessage = signal?.aborted
796
+ ? "Aborted"
797
+ : deadlineExceeded
798
+ ? deadlineError()
799
+ : stalled
800
+ ? stallError()
801
+ : state.errorMessage;
652
802
 
653
803
  return {
654
804
  output: output || "(no output)",
@@ -657,26 +807,62 @@ export async function runAgentSession(
657
807
  tokens: usage.totalTokens,
658
808
  usage,
659
809
  touchedFiles,
660
- failureKind: stalled ? "stalled" : undefined,
810
+ attributedFiles,
811
+ failureKind: signal?.aborted
812
+ ? undefined
813
+ : deadlineExceeded
814
+ ? "deadline_exceeded"
815
+ : stalled
816
+ ? "stalled"
817
+ : undefined,
818
+ prompted,
661
819
  };
662
820
  } catch (err) {
663
821
  // Preserve partial-work evidence: whatever assistant output, token spend,
664
- // and touched files accumulated before the failure/abort.
822
+ // and touched files accumulated before the failure/abort. The touched-file
823
+ // union is still best-effort; any edit/write activity and git-visible
824
+ // changes observed so far are retained. Keep the stall watchdog armed
825
+ // through the quiescence barrier so a busy session that stops emitting
826
+ // events is still rescued; clear it only once the barrier completes.
827
+ await waitForSessionQuiescence();
828
+ clearStallWatchdog();
829
+ phase = "collecting git evidence";
830
+ lastActivityAt = Date.now();
831
+
832
+ const gitAfter = await getGitChangedFiles(config.cwd);
833
+ if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
834
+ abortForDeadline();
835
+ }
836
+ clearDeadlineWatchdog();
837
+ // If the deadline or parent abort fired after the first quiescence barrier,
838
+ // including while Git was running, the fire-and-forget
839
+ // cancellation is still unwinding. Wait for the same quiescence barrier so
840
+ // lifecycle does not dispose/reuse the session while compaction or an
841
+ // extension callback is still active.
842
+ if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
843
+
844
+ // Recompute evidence after the final quiescence wait. Output, usage, and
845
+ // activity-derived touched files can all be mutated by the unwinding work
846
+ // that the barrier just waited for.
665
847
  const partialOutput = capturedOutput();
666
848
  const usage = currentUsage();
667
-
668
849
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
669
- const gitAfter = await getGitChangedFiles(config.cwd);
670
- const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
850
+ const fromGit =
851
+ gitBaseline && gitAfter
852
+ ? [...gitAfter].filter((f) => !gitBaseline.has(f))
853
+ : [];
671
854
  const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
855
+ const attributedFiles = fromActivities;
672
856
 
673
- const msg = stalled
674
- ? stallError()
675
- : signal?.aborted
676
- ? "Aborted"
677
- : err instanceof Error
678
- ? err.message
679
- : String(err);
857
+ const msg = signal?.aborted
858
+ ? "Aborted"
859
+ : deadlineExceeded
860
+ ? deadlineError()
861
+ : stalled
862
+ ? stallError()
863
+ : err instanceof Error
864
+ ? err.message
865
+ : String(err);
680
866
  return {
681
867
  output: partialOutput || "(no output)",
682
868
  error: msg,
@@ -684,10 +870,19 @@ export async function runAgentSession(
684
870
  tokens: usage.totalTokens,
685
871
  usage,
686
872
  touchedFiles,
687
- failureKind: stalled ? "stalled" : undefined,
873
+ attributedFiles,
874
+ failureKind: signal?.aborted
875
+ ? undefined
876
+ : deadlineExceeded
877
+ ? "deadline_exceeded"
878
+ : stalled
879
+ ? "stalled"
880
+ : undefined,
881
+ prompted,
688
882
  };
689
883
  } finally {
690
884
  clearStallWatchdog();
885
+ clearDeadlineWatchdog();
691
886
  if (signal && abortHandler)
692
887
  signal.removeEventListener("abort", abortHandler);
693
888
  unsubscribe();