@bermudi/pi-delegate 0.1.13 → 0.1.15

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
@@ -5,7 +5,10 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  getGitChangedFiles,
8
+ extractAttributedFromActivities,
8
9
  extractTouchedFromActivities,
10
+ projectedAttributionPath,
11
+ snapshotPhysicalToolTarget,
9
12
  } from "./file-tracking.ts";
10
13
  import { extractTextFromPartialResult, extractOutput } from "./utils.ts";
11
14
  import { snapshotSessionUsage, usageDelta, emptyUsage } from "./usage.ts";
@@ -15,14 +18,27 @@ import { scheduleDeadline } from "./timer.ts";
15
18
  import {
16
19
  createQuiescenceBarrier,
17
20
  type CancellationSource,
21
+ type QuiescenceBarrier,
18
22
  } from "./quiescence.ts";
23
+ import { markSessionQuarantined } from "./session-quarantine.ts";
19
24
  import type { Usage } from "@earendil-works/pi-ai";
20
25
  import type {
21
26
  AgentProgressUpdate,
27
+ FileAttribution,
22
28
  TaskFailureKind,
23
29
  ToolActivity,
24
30
  } from "./types.ts";
25
31
 
32
+ let quiescenceTimingsForTesting:
33
+ Partial<import("./quiescence.ts").QuiescenceTimings> | undefined;
34
+
35
+ /** @internal Test-only timings for forcing the bounded abandonment path. */
36
+ export function _setRunnerQuiescenceTimingsForTesting(
37
+ timings: Partial<import("./quiescence.ts").QuiescenceTimings> | undefined,
38
+ ): void {
39
+ quiescenceTimingsForTesting = timings;
40
+ }
41
+
26
42
  /** Human-facing error for an expired `deadlineMs` budget.
27
43
  *
28
44
  * This is exported so the lifecycle pre-check can return the same text as the
@@ -33,6 +49,43 @@ export function formatDeadlineExceededError(budgetMs: number): string {
33
49
  )} wall-clock budget and was cooperatively aborted (not a hard kill). Completed writes and commands remain.`;
34
50
  }
35
51
 
52
+ function unionGitEvidence(
53
+ first: Set<string> | undefined,
54
+ second: Set<string> | undefined,
55
+ ): Set<string> | undefined {
56
+ if (!first) return second;
57
+ if (!second) return first;
58
+ return new Set([...first, ...second]);
59
+ }
60
+
61
+ function cancellationFailureKind(
62
+ source: CancellationSource | undefined,
63
+ ): TaskFailureKind | undefined {
64
+ if (source === "parent-aborted") return "cancelled";
65
+ if (source === "deadline") return "deadline_exceeded";
66
+ if (source === "stalled") return "stalled";
67
+ return undefined;
68
+ }
69
+
70
+ /** Apply cancellation precedence without obscuring it in nested conditionals. */
71
+ function resolveRunFailure(
72
+ source: CancellationSource | undefined,
73
+ fallbackError: string | undefined,
74
+ deadlineError: () => string,
75
+ stallError: () => string,
76
+ ): { error: string | undefined; failureKind: TaskFailureKind | undefined } {
77
+ if (source === "parent-aborted") {
78
+ return { error: "Aborted", failureKind: "cancelled" };
79
+ }
80
+ if (source === "deadline") {
81
+ return { error: deadlineError(), failureKind: "deadline_exceeded" };
82
+ }
83
+ if (source === "stalled") {
84
+ return { error: stallError(), failureKind: "stalled" };
85
+ }
86
+ return { error: fallbackError, failureKind: undefined };
87
+ }
88
+
36
89
  /**
37
90
  * Run a single prompt against a live `AgentSession` and report progress.
38
91
  *
@@ -84,11 +137,16 @@ export async function runAgentSession(
84
137
  touchedFiles: string[];
85
138
  /** Files directly attributable to this run's edit/write tool calls. */
86
139
  attributedFiles: string[];
140
+ /** Provenance-bearing evidence behind attributedFiles. */
141
+ fileAttributions: FileAttribution[];
87
142
  failureKind?: TaskFailureKind;
88
143
  /** Whether `session.prompt()` was actually invoked. Distinguishes a pre-prompt
89
144
  * deadline (session never used) from a mid-prompt deadline (session was
90
145
  * prompted and may have partial state mutations). */
91
146
  prompted: boolean;
147
+ /** Quiescence was abandoned. All returned accounting and evidence are lower
148
+ * bounds captured before quarantined background work became safe. */
149
+ incomplete?: "quiescence_abandoned";
92
150
  }> {
93
151
  const startTime = start ?? Date.now();
94
152
  const stallTimeoutMs = getStallTimeoutMs(delegateConfig);
@@ -101,8 +159,58 @@ export async function runAgentSession(
101
159
  let deadlineExceeded = false;
102
160
  let clearDeadline: (() => void) | undefined;
103
161
  let prompted = false;
162
+ let cancellationDispatched = false;
163
+ let promptSettled = false;
164
+ let promptSettlement:
165
+ | Promise<
166
+ | { status: "not_started" | "fulfilled" }
167
+ | { status: "rejected"; error: unknown }
168
+ >
169
+ | undefined;
170
+ const currentCancellationSource = (): CancellationSource | undefined => {
171
+ if (signal?.aborted) return "parent-aborted";
172
+ if (deadlineExceeded) return "deadline";
173
+ if (stalled) return "stalled";
174
+ return undefined;
175
+ };
176
+ let recoveryBarrier: QuiescenceBarrier | undefined;
177
+ let abandonmentSafety: Promise<void> | undefined;
178
+ let unsubscribeFull: (() => void) | undefined;
179
+ let unsubscribeRecovery: (() => void) | undefined;
180
+ const safeLog = (message: string, error?: unknown): void => {
181
+ try {
182
+ console.error(message, error);
183
+ } catch {
184
+ // Recovery and listener cleanup must never create an unhandled failure.
185
+ }
186
+ };
187
+ const removeFullListener = (): void => {
188
+ const remove = unsubscribeFull;
189
+ unsubscribeFull = undefined;
190
+ try {
191
+ remove?.();
192
+ } catch (error) {
193
+ safeLog("[delegate] full AgentSession listener cleanup failed", error);
194
+ }
195
+ };
196
+ const removeRecoveryListener = (): void => {
197
+ const remove = unsubscribeRecovery;
198
+ unsubscribeRecovery = undefined;
199
+ try {
200
+ remove?.();
201
+ } catch (error) {
202
+ safeLog(
203
+ "[delegate] recovery AgentSession listener cleanup failed",
204
+ error,
205
+ );
206
+ }
207
+ };
104
208
  const activities: ToolActivity[] = [];
105
209
  const pendingById = new Map<string, ToolActivity>();
210
+ let notifyCancellationRequested!: () => void;
211
+ const cancellationRequested = new Promise<void>((resolve) => {
212
+ notifyCancellationRequested = resolve;
213
+ });
106
214
 
107
215
  // AgentSession.prompt() can return while an agent_settled extension callback
108
216
  // is still running fire-and-forget work through ctx.compact(). The barrier
@@ -110,21 +218,34 @@ export async function runAgentSession(
110
218
  // why it cannot be answered deterministically against today's host.
111
219
  const barrier = createQuiescenceBarrier({
112
220
  session,
113
- cancellation: () =>
114
- signal?.aborted
115
- ? "parent-aborted"
116
- : deadlineExceeded
117
- ? "deadline"
118
- : stalled
119
- ? "stalled"
120
- : undefined,
221
+ timings: quiescenceTimingsForTesting,
222
+ cancellation: currentCancellationSource,
121
223
  cancel: (source) => requestSessionCancellation(source),
122
224
  });
123
- const waitForSessionQuiescence = () => barrier.wait();
225
+ // Once the bounded cancelled unwind gives up, do not start another full
226
+ // unwind budget later in the same runner. The session may still be active,
227
+ // but cancellation has an explicit terminal path instead of repeatedly
228
+ // waiting on work that the host cannot prove has stopped.
229
+ let sessionAbandoned = false;
230
+ let startAbandonedRecovery!: (source: CancellationSource) => Promise<void>;
231
+ const quarantineSession = (source: CancellationSource): void => {
232
+ if (sessionAbandoned) return;
233
+ sessionAbandoned = true;
234
+ abandonmentSafety ??= startAbandonedRecovery(source);
235
+ };
236
+ const waitForSessionQuiescence = async () => {
237
+ if (sessionAbandoned) return "abandoned" as const;
238
+ const outcome = await barrier.wait();
239
+ if (outcome === "abandoned") {
240
+ quarantineSession(currentCancellationSource() ?? "stalled");
241
+ }
242
+ return outcome;
243
+ };
124
244
 
125
245
  const requestSessionCancellation = (source: CancellationSource): void => {
246
+ cancellationDispatched = true;
126
247
  const logFailure = (operation: string, error: unknown) => {
127
- console.error(`[delegate] ${source} subagent ${operation} failed`, error);
248
+ safeLog(`[delegate] ${source} subagent ${operation} failed`, error);
128
249
  };
129
250
  try {
130
251
  session.abortCompaction();
@@ -141,14 +262,103 @@ export async function runAgentSession(
141
262
  // may synchronously emit events (e.g. a final message_update as the stream
142
263
  // unwinds); recording after the call attributes those to the abort, so the
143
264
  // barrier's re-abort check doesn't loop on the abort's own events.
144
- void session.abort().catch((error: unknown) => {
265
+ try {
266
+ void session.abort().catch((error: unknown) => {
267
+ logFailure("agent cancellation", error);
268
+ });
269
+ } catch (error) {
270
+ // Some host fakes/versions can throw before returning the abort promise.
271
+ // Continue recording cancellation so the quarantine path still engages.
145
272
  logFailure("agent cancellation", error);
146
- });
273
+ }
147
274
  // Any session event after this point means new work started despite the
148
275
  // abort (e.g. a continuation prompt from an extension's onComplete
149
276
  // callback delayed by async auth). The barrier re-aborts it rather than
150
277
  // letting it mutate files after the task is considered cancelled.
151
278
  barrier.noteCancellationRequested();
279
+ // Wake the prompt race only after every cooperative cancellation request
280
+ // has been dispatched and the barrier knows its cancellation generation.
281
+ notifyCancellationRequested();
282
+ };
283
+
284
+ startAbandonedRecovery = (source) => {
285
+ safeLog(
286
+ `[delegate] QUARANTINING ${source} AgentSession after quiescence abandonment; it will not be reused, disposed, or have its workspace cleaned until background termination confirms safety`,
287
+ );
288
+
289
+ try {
290
+ recoveryBarrier = createQuiescenceBarrier({
291
+ session,
292
+ cancellation: () => source,
293
+ // Recovery is deliberately unbounded. If the host never proves safety,
294
+ // the quarantine and its workspace live forever rather than racing work.
295
+ timings: { cancelledUnwindBudgetMs: Number.POSITIVE_INFINITY },
296
+ // A permanent quarantine is an intentional leak, not a reason to keep a
297
+ // headless Node process alive forever on its liveness probe.
298
+ unrefTimers: true,
299
+ cancel: (nextSource) => {
300
+ requestSessionCancellation(nextSource);
301
+ recoveryBarrier?.noteCancellationRequested();
302
+ },
303
+ onAbandon: () => {
304
+ // Infinity above makes this unreachable; keep fail-closed semantics if
305
+ // timing arithmetic ever changes.
306
+ },
307
+ });
308
+
309
+ // Freeze user-visible output/progress at the terminal result boundary.
310
+ // Recovery needs only event generation; retaining the full listener
311
+ // would mutate returned evidence and emit stale terminal progress.
312
+ unsubscribeRecovery = session.subscribe(() =>
313
+ recoveryBarrier?.noteEvent(),
314
+ );
315
+ removeFullListener();
316
+
317
+ requestSessionCancellation(source);
318
+ recoveryBarrier.noteCancellationRequested();
319
+ } catch (error) {
320
+ removeFullListener();
321
+ removeRecoveryListener();
322
+ safeLog(
323
+ `[delegate] quarantined ${source} AgentSession recovery setup failed; retaining session and workspace indefinitely`,
324
+ error,
325
+ );
326
+ return new Promise<void>(() => {});
327
+ }
328
+
329
+ const recovery = (async () => {
330
+ // Keep actively re-aborting while an ignored prompt/provider call winds
331
+ // down. A first quiet observation is not enough if prompt() itself is
332
+ // unresolved; after it settles, require a fresh quiet window.
333
+ await Promise.all([
334
+ promptSettlement ?? Promise.resolve({ status: "not_started" as const }),
335
+ recoveryBarrier!.wait(),
336
+ ]);
337
+ await recoveryBarrier!.wait();
338
+ safeLog(
339
+ `[delegate] quarantined ${source} AgentSession is now quiescent; deferred disposal and workspace cleanup may proceed`,
340
+ );
341
+ removeRecoveryListener();
342
+ })().catch((error) => {
343
+ safeLog(
344
+ `[delegate] quarantined ${source} AgentSession background termination failed; retaining session and workspace indefinitely`,
345
+ error,
346
+ );
347
+ // Never resolve safety after an observer failure: leaking is safer than
348
+ // allowing disposal or filesystem teardown to race unknown live work.
349
+ return new Promise<void>(() => {});
350
+ });
351
+ return recovery;
352
+ };
353
+
354
+ const finishResult = <T extends object>(
355
+ result: T,
356
+ ): T & { incomplete?: "quiescence_abandoned" } => {
357
+ if (!abandonmentSafety) return result;
358
+ return markSessionQuarantined(
359
+ { ...result, incomplete: "quiescence_abandoned" as const },
360
+ { safe: abandonmentSafety },
361
+ );
152
362
  };
153
363
 
154
364
  // Snapshot cumulative usage before the prompt so we can report only the
@@ -165,19 +375,14 @@ export async function runAgentSession(
165
375
  if (!onProgress) return;
166
376
  const delta = currentUsage().totalTokens;
167
377
  try {
378
+ const cancellationSource = currentCancellationSource();
168
379
  onProgress({
169
380
  tokens: delta,
170
381
  toolUses,
171
382
  durationMs: Date.now() - startTime,
172
383
  lastActivityAt,
173
384
  activities: [...activities],
174
- failureKind: signal?.aborted
175
- ? undefined
176
- : deadlineExceeded
177
- ? "deadline_exceeded"
178
- : stalled
179
- ? "stalled"
180
- : undefined,
385
+ failureKind: cancellationFailureKind(cancellationSource),
181
386
  });
182
387
  } catch (error) {
183
388
  console.error("[delegate] progress callback threw; continuing", error);
@@ -420,8 +625,9 @@ export async function runAgentSession(
420
625
  // result, isError) — AgentSession forwards the underlying agent events
421
626
  // verbatim. Retry and compaction events are handled below; queue/bookkeeping
422
627
  // events and thinking changes are intentionally ignored.
423
- const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
628
+ unsubscribeFull = session.subscribe((event: AgentSessionEvent) => {
424
629
  barrier.noteEvent();
630
+ recoveryBarrier?.noteEvent();
425
631
  switch (event.type) {
426
632
  case "tool_execution_start": {
427
633
  const now = Date.now();
@@ -431,6 +637,15 @@ export async function runAgentSession(
431
637
  args: event.args,
432
638
  startTime: now,
433
639
  };
640
+ if (event.toolName === "edit" || event.toolName === "write") {
641
+ // AgentSession emits this boundary synchronously before invoking the
642
+ // tool. Capture the physical target now: after execution a tool can
643
+ // delete or retarget the symlink it wrote through.
644
+ activity.fileAttribution = snapshotPhysicalToolTarget(
645
+ activity,
646
+ config.cwd,
647
+ );
648
+ }
434
649
  pendingById.set(event.toolCallId, activity);
435
650
  activities.push(activity);
436
651
  noteActivity(`executing tool '${event.toolName}'`);
@@ -578,14 +793,16 @@ export async function runAgentSession(
578
793
  // can still start a subagent that writes files and gets pooled.
579
794
  if (signal?.aborted) {
580
795
  abortHandler?.();
581
- // The early return skips the try/finally below, so clean up the
582
- // subscription and abort listener here otherwise they leak on every
583
- // already-aborted call, which is especially harmful for pooled sessions
584
- // whose subscription would outlive the task.
796
+ // A pooled session can still have extension-started work even though this
797
+ // runner never prompts it. Use the same bounded cancelled unwind as every
798
+ // other cancellation path before returning ownership.
799
+ await waitForSessionQuiescence();
800
+ // The early return skips the try/finally below. The abandonment path has
801
+ // already replaced the full listener with its minimal recovery listener.
585
802
  if (signal && abortHandler)
586
803
  signal.removeEventListener("abort", abortHandler);
587
- unsubscribe();
588
- return {
804
+ removeFullListener();
805
+ return finishResult({
589
806
  output: "",
590
807
  error: "Aborted",
591
808
  durationMs: Date.now() - startTime,
@@ -593,8 +810,10 @@ export async function runAgentSession(
593
810
  usage: emptyUsage(),
594
811
  touchedFiles: [],
595
812
  attributedFiles: [],
813
+ fileAttributions: [],
814
+ failureKind: "cancelled" as const,
596
815
  prompted: false,
597
- };
816
+ });
598
817
  }
599
818
 
600
819
  // If the deadline is already in the past, request cooperative cancellation
@@ -616,9 +835,9 @@ export async function runAgentSession(
616
835
  clearDeadlineWatchdog();
617
836
  if (signal && abortHandler)
618
837
  signal.removeEventListener("abort", abortHandler);
619
- unsubscribe();
838
+ removeFullListener();
620
839
  if (signal?.aborted) {
621
- return {
840
+ return finishResult({
622
841
  output: "",
623
842
  error: "Aborted",
624
843
  durationMs: Date.now() - startTime,
@@ -626,10 +845,12 @@ export async function runAgentSession(
626
845
  usage: emptyUsage(),
627
846
  touchedFiles: [],
628
847
  attributedFiles: [],
848
+ fileAttributions: [],
849
+ failureKind: "cancelled" as const,
629
850
  prompted: false,
630
- };
851
+ });
631
852
  }
632
- return {
853
+ return finishResult({
633
854
  output: "(no output)",
634
855
  error: deadlineError(),
635
856
  durationMs: Date.now() - startTime,
@@ -637,9 +858,10 @@ export async function runAgentSession(
637
858
  usage: emptyUsage(),
638
859
  touchedFiles: [],
639
860
  attributedFiles: [],
640
- failureKind: "deadline_exceeded",
861
+ fileAttributions: [],
862
+ failureKind: "deadline_exceeded" as const,
641
863
  prompted: false,
642
- };
864
+ });
643
865
  }
644
866
 
645
867
  try {
@@ -649,9 +871,48 @@ export async function runAgentSession(
649
871
  armDeadlineWatchdog();
650
872
  fireProgress();
651
873
 
652
- prompted = true;
653
- await session.prompt(prompt);
874
+ // AgentSession.abort() normally settles prompt(), but providers/tools can
875
+ // ignore cancellation and leave that promise pending forever. Race prompt
876
+ // settlement against cancellation, then use the barrier's bounded unwind.
877
+ // Prompt start is queued, so a cancellation dispatched in that window must be checked
878
+ // again inside the queued callback; otherwise the runner can return while
879
+ // that callback starts a newly cancelled prompt. Rejections are converted
880
+ // to data so a late rejection after abandonment cannot become an unhandled
881
+ // promise rejection.
882
+ promptSettlement = Promise.resolve()
883
+ .then(async () => {
884
+ if (cancellationDispatched) return { status: "not_started" as const };
885
+ prompted = true;
886
+ try {
887
+ await session.prompt(prompt);
888
+ return { status: "fulfilled" as const };
889
+ } catch (error) {
890
+ return { status: "rejected" as const, error };
891
+ }
892
+ })
893
+ .then((outcome) => {
894
+ promptSettled = true;
895
+ return outcome;
896
+ });
897
+ const promptOutcome = await Promise.race([
898
+ promptSettlement,
899
+ cancellationRequested.then(() => ({ status: "cancelled" as const })),
900
+ ]);
901
+ if (promptOutcome.status === "rejected") {
902
+ // A rejected prompt can still have fired agent_settled extension work.
903
+ // Keep ownership until that work is quiescent before routing the prompt
904
+ // error through the evidence-preserving catch path below. If cancellation
905
+ // arrived too, this remains the same bounded unwind/quarantine boundary.
906
+ await waitForSessionQuiescence();
907
+ throw promptOutcome.error;
908
+ }
654
909
  await waitForSessionQuiescence();
910
+ if (promptOutcome.status === "cancelled" && !promptSettled) {
911
+ // isIdle/quiescence can lie while a provider/tool keeps prompt() pending.
912
+ // Returning that object to lifecycle would permit reuse or disposal while
913
+ // ignored work still owns it, so force the existing quarantine recovery.
914
+ quarantineSession(currentCancellationSource() ?? "stalled");
915
+ }
655
916
 
656
917
  // The model is done; inactivity is no longer the right watchdog. Git
657
918
  // evidence collection can take several seconds (up to 5s per git call),
@@ -661,7 +922,7 @@ export async function runAgentSession(
661
922
  phase = "collecting git evidence";
662
923
  lastActivityAt = Date.now();
663
924
 
664
- const gitAfter = await getGitChangedFiles(config.cwd);
925
+ let gitAfter = await getGitChangedFiles(config.cwd);
665
926
  if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
666
927
  abortForDeadline();
667
928
  }
@@ -671,7 +932,17 @@ export async function runAgentSession(
671
932
  // cancellation is still unwinding. Wait for the same quiescence barrier so
672
933
  // lifecycle does not dispose/reuse the session while compaction or an
673
934
  // extension callback is still active.
674
- if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
935
+ if (currentCancellationSource()) {
936
+ await waitForSessionQuiescence();
937
+ // Cancellation unwind can mutate files after the first Git snapshot.
938
+ // Recollect only after the final bounded quiescence decision and union
939
+ // both observations: a later failure or reverted file must not erase
940
+ // evidence already observed before the unwind settled.
941
+ gitAfter = unionGitEvidence(
942
+ gitAfter,
943
+ await getGitChangedFiles(config.cwd),
944
+ );
945
+ }
675
946
 
676
947
  // Recompute evidence after the final quiescence wait. Output, usage,
677
948
  // activity-derived touched files, and the session error state can all be
@@ -680,37 +951,37 @@ export async function runAgentSession(
680
951
  const output = capturedOutput();
681
952
  const usage = currentUsage();
682
953
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
954
+ const fileAttributions = extractAttributedFromActivities(
955
+ activities,
956
+ config.cwd,
957
+ );
958
+ const attributedFiles = fileAttributions.map(projectedAttributionPath);
683
959
  const fromGit =
684
960
  gitBaseline && gitAfter
685
961
  ? [...gitAfter].filter((f) => !gitBaseline.has(f))
686
962
  : [];
687
- const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
688
- const attributedFiles = fromActivities;
689
- const errorMessage = signal?.aborted
690
- ? "Aborted"
691
- : deadlineExceeded
692
- ? deadlineError()
693
- : stalled
694
- ? stallError()
695
- : state.errorMessage;
696
-
697
- return {
963
+ const touchedFiles = [
964
+ ...new Set([...fromActivities, ...attributedFiles, ...fromGit]),
965
+ ];
966
+ const failure = resolveRunFailure(
967
+ currentCancellationSource(),
968
+ state.errorMessage,
969
+ deadlineError,
970
+ stallError,
971
+ );
972
+
973
+ return finishResult({
698
974
  output: output || "(no output)",
699
- error: errorMessage,
975
+ error: failure.error,
700
976
  durationMs: Date.now() - startTime,
701
977
  tokens: usage.totalTokens,
702
978
  usage,
703
979
  touchedFiles,
704
980
  attributedFiles,
705
- failureKind: signal?.aborted
706
- ? undefined
707
- : deadlineExceeded
708
- ? "deadline_exceeded"
709
- : stalled
710
- ? "stalled"
711
- : undefined,
981
+ fileAttributions,
982
+ failureKind: failure.failureKind,
712
983
  prompted,
713
- };
984
+ });
714
985
  } catch (err) {
715
986
  // Preserve partial-work evidence: whatever assistant output, token spend,
716
987
  // and touched files accumulated before the failure/abort. The touched-file
@@ -723,7 +994,7 @@ export async function runAgentSession(
723
994
  phase = "collecting git evidence";
724
995
  lastActivityAt = Date.now();
725
996
 
726
- const gitAfter = await getGitChangedFiles(config.cwd);
997
+ let gitAfter = await getGitChangedFiles(config.cwd);
727
998
  if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
728
999
  abortForDeadline();
729
1000
  }
@@ -733,7 +1004,16 @@ export async function runAgentSession(
733
1004
  // cancellation is still unwinding. Wait for the same quiescence barrier so
734
1005
  // lifecycle does not dispose/reuse the session while compaction or an
735
1006
  // extension callback is still active.
736
- if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
1007
+ if (currentCancellationSource()) {
1008
+ await waitForSessionQuiescence();
1009
+ // As in the success path, the unwind itself can change Git-visible
1010
+ // files. Preserve the first observation if recollection fails and union
1011
+ // both successful observations so transient changes remain evidence.
1012
+ gitAfter = unionGitEvidence(
1013
+ gitAfter,
1014
+ await getGitChangedFiles(config.cwd),
1015
+ );
1016
+ }
737
1017
 
738
1018
  // Recompute evidence after the final quiescence wait. Output, usage, and
739
1019
  // activity-derived touched files can all be mutated by the unwinding work
@@ -741,44 +1021,43 @@ export async function runAgentSession(
741
1021
  const partialOutput = capturedOutput();
742
1022
  const usage = currentUsage();
743
1023
  const fromActivities = extractTouchedFromActivities(activities, config.cwd);
1024
+ const fileAttributions = extractAttributedFromActivities(
1025
+ activities,
1026
+ config.cwd,
1027
+ );
1028
+ const attributedFiles = fileAttributions.map(projectedAttributionPath);
744
1029
  const fromGit =
745
1030
  gitBaseline && gitAfter
746
1031
  ? [...gitAfter].filter((f) => !gitBaseline.has(f))
747
1032
  : [];
748
- const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
749
- const attributedFiles = fromActivities;
750
-
751
- const msg = signal?.aborted
752
- ? "Aborted"
753
- : deadlineExceeded
754
- ? deadlineError()
755
- : stalled
756
- ? stallError()
757
- : err instanceof Error
758
- ? err.message
759
- : String(err);
760
- return {
1033
+ const touchedFiles = [
1034
+ ...new Set([...fromActivities, ...attributedFiles, ...fromGit]),
1035
+ ];
1036
+
1037
+ const fallbackError = err instanceof Error ? err.message : String(err);
1038
+ const failure = resolveRunFailure(
1039
+ currentCancellationSource(),
1040
+ fallbackError,
1041
+ deadlineError,
1042
+ stallError,
1043
+ );
1044
+ return finishResult({
761
1045
  output: partialOutput || "(no output)",
762
- error: msg,
1046
+ error: failure.error,
763
1047
  durationMs: Date.now() - startTime,
764
1048
  tokens: usage.totalTokens,
765
1049
  usage,
766
1050
  touchedFiles,
767
1051
  attributedFiles,
768
- failureKind: signal?.aborted
769
- ? undefined
770
- : deadlineExceeded
771
- ? "deadline_exceeded"
772
- : stalled
773
- ? "stalled"
774
- : undefined,
1052
+ fileAttributions,
1053
+ failureKind: failure.failureKind,
775
1054
  prompted,
776
- };
1055
+ });
777
1056
  } finally {
778
1057
  clearStallWatchdog();
779
1058
  clearDeadlineWatchdog();
780
1059
  if (signal && abortHandler)
781
1060
  signal.removeEventListener("abort", abortHandler);
782
- unsubscribe();
1061
+ removeFullListener();
783
1062
  }
784
1063
  }