@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/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;
@@ -296,7 +372,8 @@ export async function runAgentSession(
296
372
  const initialMessages = session.messages;
297
373
  const initialMessageCount = initialMessages.length;
298
374
  const initialMessageSnapshot = initialMessages.slice();
299
- let transcriptMayHaveBeenReplaced = false;
375
+ let compactionInProgress = false;
376
+ let completedCompaction = false;
300
377
  const assistantMessagesForAttempt: AgentMessage[] = [];
301
378
  let partialAssistantMessage: AgentMessage | undefined;
302
379
  type AttemptCapture = {
@@ -426,11 +503,13 @@ export async function runAgentSession(
426
503
  // Keep an append-only fallback for providers/fakes that reject before
427
504
  // emitting message_end. It is deliberately used only when event capture
428
505
  // is empty; event capture is authoritative across compaction and retries.
429
- // Requiring the original array and unchanged historical prefix prevents a
430
- // replacement/compaction transcript from leaking old assistant output.
506
+ // An aborted compaction may leave the original append-only transcript
507
+ // untouched. Completed or indeterminate compaction remains fail-closed even
508
+ // if a host happened to preserve the array identity.
431
509
  const currentMessages = session.messages;
432
510
  if (
433
- transcriptMayHaveBeenReplaced ||
511
+ compactionInProgress ||
512
+ completedCompaction ||
434
513
  currentMessages !== initialMessages ||
435
514
  currentMessages.length < initialMessageCount
436
515
  ) {
@@ -525,17 +604,23 @@ export async function runAgentSession(
525
604
  // delay before ordinary inactivity detection resumes.
526
605
  noteActivity("waiting to retry", event.delayMs);
527
606
  break;
528
- case "auto_retry_end":
607
+ case "auto_retry_end": {
608
+ const autoRetry = event as {
609
+ success?: unknown;
610
+ };
611
+ if (autoRetry.success === false && pendingCompactionAttempt) {
612
+ pendingCompactionAttempt.omitFinalAssistant = false;
613
+ }
529
614
  noteActivity("waiting for model output");
530
615
  break;
616
+ }
531
617
  case "compaction_start":
532
- // Even if a host mutates the transcript in place rather than replacing
533
- // its array, historical messages are no longer a safe fallback source.
534
- transcriptMayHaveBeenReplaced = true;
618
+ compactionInProgress = true;
535
619
  noteActivity("compacting context");
536
620
  break;
537
621
  case "compaction_end":
538
- transcriptMayHaveBeenReplaced = true;
622
+ compactionInProgress = false;
623
+ if (!event.aborted) completedCompaction = true;
539
624
  // Context-overflow agent_end is intentionally emitted with
540
625
  // willRetry=false because Pi's retry decision belongs to compaction.
541
626
  // Only an overflow compaction that actually retries may retract that
@@ -584,6 +669,7 @@ export async function runAgentSession(
584
669
  if (signal) {
585
670
  abortHandler = () => {
586
671
  clearStallWatchdog();
672
+ clearDeadlineWatchdog();
587
673
  // Fire-and-forget: prompt()/the quiescence barrier observe cancellation
588
674
  // and then return the partial evidence that this runner reports.
589
675
  requestSessionCancellation("parent-aborted");
@@ -612,34 +698,107 @@ export async function runAgentSession(
612
698
  tokens: 0,
613
699
  usage: emptyUsage(),
614
700
  touchedFiles: [],
701
+ attributedFiles: [],
702
+ prompted: false,
615
703
  };
616
704
  }
617
705
 
618
- // Start detection only once the session is ready to receive its prompt;
619
- // queued delegate tasks never enter this runner and therefore never time out.
620
- armStallWatchdog();
621
- 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
+ }
622
750
 
623
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;
624
759
  await session.prompt(prompt);
625
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.
626
766
  clearStallWatchdog();
767
+ phase = "collecting git evidence";
768
+ lastActivityAt = Date.now();
627
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.
628
785
  const state = session.state as { errorMessage?: string };
629
786
  const output = capturedOutput();
630
787
  const usage = currentUsage();
631
-
632
- // Compute touched files: union of activity-based (edit/write) and git diff
633
- // against the pre-prompt baseline. Independent of the runner's event model.
634
788
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
635
- const gitAfter = await getGitChangedFiles(config.cwd);
636
- const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
789
+ const fromGit =
790
+ gitBaseline && gitAfter
791
+ ? [...gitAfter].filter((f) => !gitBaseline.has(f))
792
+ : [];
637
793
  const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
638
- const errorMessage = stalled
639
- ? stallError()
640
- : signal?.aborted
641
- ? "Aborted"
642
- : state.errorMessage;
794
+ const attributedFiles = fromActivities;
795
+ const errorMessage = signal?.aborted
796
+ ? "Aborted"
797
+ : deadlineExceeded
798
+ ? deadlineError()
799
+ : stalled
800
+ ? stallError()
801
+ : state.errorMessage;
643
802
 
644
803
  return {
645
804
  output: output || "(no output)",
@@ -648,26 +807,62 @@ export async function runAgentSession(
648
807
  tokens: usage.totalTokens,
649
808
  usage,
650
809
  touchedFiles,
651
- 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,
652
819
  };
653
820
  } catch (err) {
654
821
  // Preserve partial-work evidence: whatever assistant output, token spend,
655
- // 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.
656
847
  const partialOutput = capturedOutput();
657
848
  const usage = currentUsage();
658
-
659
849
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
660
- const gitAfter = await getGitChangedFiles(config.cwd);
661
- const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
850
+ const fromGit =
851
+ gitBaseline && gitAfter
852
+ ? [...gitAfter].filter((f) => !gitBaseline.has(f))
853
+ : [];
662
854
  const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
855
+ const attributedFiles = fromActivities;
663
856
 
664
- const msg = stalled
665
- ? stallError()
666
- : signal?.aborted
667
- ? "Aborted"
668
- : err instanceof Error
669
- ? err.message
670
- : 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);
671
866
  return {
672
867
  output: partialOutput || "(no output)",
673
868
  error: msg,
@@ -675,10 +870,19 @@ export async function runAgentSession(
675
870
  tokens: usage.totalTokens,
676
871
  usage,
677
872
  touchedFiles,
678
- 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,
679
882
  };
680
883
  } finally {
681
884
  clearStallWatchdog();
885
+ clearDeadlineWatchdog();
682
886
  if (signal && abortHandler)
683
887
  signal.removeEventListener("abort", abortHandler);
684
888
  unsubscribe();