@bermudi/pi-delegate 0.1.13 → 0.1.14

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
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import {
3
4
  createAgentSession,
4
5
  SessionManager,
@@ -20,7 +21,10 @@ import {
20
21
  persistSessionHeader,
21
22
  } from "./sessions.ts";
22
23
  import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
23
- import { getGitChangedFiles } from "./file-tracking.ts";
24
+ import {
25
+ getGitChangedFiles,
26
+ projectedAttributionPath,
27
+ } from "./file-tracking.ts";
24
28
  import { getHostDeps } from "./host.ts";
25
29
  import { resolveCwd, validateResumeFromPath } from "./utils.ts";
26
30
  import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
@@ -28,6 +32,17 @@ import { addUsage, emptyUsage } from "./usage.ts";
28
32
  import { scheduleDeadline } from "./timer.ts";
29
33
  import { recordTask } from "./telemetry.ts";
30
34
  import { createScratchWorkspace, ScratchDeadlineError } from "./workspace.ts";
35
+ import {
36
+ isResumeFromIdentityQuarantined,
37
+ isSessionIdQuarantined,
38
+ markSessionQuarantined,
39
+ observeQuarantineSafety,
40
+ propagateSessionQuarantine,
41
+ reserveSessionQuarantine,
42
+ sessionQuarantineOf,
43
+ withResumeTranscriptLock,
44
+ type SessionQuarantine,
45
+ } from "./session-quarantine.ts";
31
46
 
32
47
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
33
48
  type RunAgentSession = typeof runAgentSession;
@@ -35,6 +50,10 @@ let runAgentSessionForTesting: RunAgentSession = runAgentSession;
35
50
  type CreateScratchWorkspace = typeof createScratchWorkspace;
36
51
  let createScratchWorkspaceForTesting: CreateScratchWorkspace =
37
52
  createScratchWorkspace;
53
+ type DetachQuarantinedPooledSession =
54
+ typeof pool._quarantinePooledAgentWithoutDisposal;
55
+ let detachQuarantinedPooledSessionForTesting: DetachQuarantinedPooledSession =
56
+ pool._quarantinePooledAgentWithoutDisposal;
38
57
 
39
58
  export function _setRunAgentSessionForTesting(
40
59
  override: RunAgentSession | undefined,
@@ -49,6 +68,14 @@ export function _setCreateScratchWorkspaceForTesting(
49
68
  createScratchWorkspaceForTesting = override ?? createScratchWorkspace;
50
69
  }
51
70
 
71
+ /** @internal Simulate a pooled-detachment invariant failure in lifecycle tests. */
72
+ export function _setQuarantinePooledSessionDetachForTesting(
73
+ override: DetachQuarantinedPooledSession | undefined,
74
+ ): void {
75
+ detachQuarantinedPooledSessionForTesting =
76
+ override ?? pool._quarantinePooledAgentWithoutDisposal;
77
+ }
78
+
52
79
  /**
53
80
  * Test-only overrides for whole-task retry settings. When set, these bypass
54
81
  * the config-driven values so retry integration tests don't sleep real seconds.
@@ -86,12 +113,14 @@ function failTask(
86
113
  task: ResolvedTask,
87
114
  error: string,
88
115
  sessionFile?: string,
116
+ failureKind?: TaskResult["failureKind"],
89
117
  ): TaskResult {
90
118
  return {
91
119
  id: task.id,
92
120
  agent: task.agentName,
93
121
  output: "",
94
122
  error,
123
+ failureKind,
95
124
  durationMs: 0,
96
125
  tokens: 0,
97
126
  usage: emptyUsage(),
@@ -101,6 +130,66 @@ function failTask(
101
130
  };
102
131
  }
103
132
 
133
+ function scratchSetupFailureResult(
134
+ task: ResolvedTask,
135
+ error: unknown,
136
+ signalAborted: boolean,
137
+ startedAt: number,
138
+ ): TaskResult {
139
+ let message = error instanceof Error ? error.message : String(error);
140
+ let failureKind: TaskResult["failureKind"];
141
+ if (signalAborted) {
142
+ message = "Aborted";
143
+ failureKind = "cancelled";
144
+ } else if (error instanceof ScratchDeadlineError) {
145
+ message = formatDeadlineExceededError(task.deadlineMs ?? 0);
146
+ failureKind = "deadline_exceeded";
147
+ }
148
+ return {
149
+ ...failTask(task, message, undefined, failureKind),
150
+ durationMs: Date.now() - startedAt,
151
+ };
152
+ }
153
+
154
+ /** Preserve every core evidence channel when the outer scratch projection
155
+ * itself fails. Paths cannot be trusted as projected, so structured evidence
156
+ * is retained verbatim and downgraded to uncertain rather than erased. */
157
+ function scratchProjectionFailureResult(
158
+ task: ResolvedTask,
159
+ result: TaskResult | undefined,
160
+ error: unknown,
161
+ startedAt: number,
162
+ ): TaskResult {
163
+ const reason = error instanceof Error ? error.message : String(error);
164
+ const projectionError = `Scratch evidence projection failed: ${reason}`;
165
+ if (!result) {
166
+ return {
167
+ ...failTask(task, projectionError),
168
+ workspace: "scratch",
169
+ durationMs: Date.now() - startedAt,
170
+ };
171
+ }
172
+
173
+ let combinedError = projectionError;
174
+ if (result.error) combinedError = `${result.error}\n${projectionError}`;
175
+ const fallback: TaskResult = {
176
+ ...result,
177
+ error: combinedError,
178
+ workspace: "scratch",
179
+ sessionFile: undefined,
180
+ durationMs: Math.max(result.durationMs, Date.now() - startedAt),
181
+ touchedFiles: [...result.touchedFiles],
182
+ attributedFiles: result.attributedFiles
183
+ ? [...result.attributedFiles]
184
+ : undefined,
185
+ fileAttributions: result.fileAttributions?.map((entry) => ({
186
+ ...entry,
187
+ uncertain: true,
188
+ })),
189
+ };
190
+ return propagateSessionQuarantine(result, fallback);
191
+ }
192
+
104
193
  /** Build a successful TaskResult for session-management actions (close/list).
105
194
  * Pass elapsedMs to record wall time since delegate started (matches the live progress UI). */
106
195
  function completeSessionAction(
@@ -125,13 +214,80 @@ function completeSessionAction(
125
214
  * Pool hits and successfully committed sessions remain pool-owned. */
126
215
  function disposeOwnedSession(acquired: AcquiredSession): void {
127
216
  if (!acquired.lifecycleOwnsSession) return;
217
+ disposeSession(acquired.session, "uncommitted subagent");
218
+ }
219
+
220
+ function disposeSession(session: AgentSession, description: string): void {
128
221
  try {
129
- acquired.session.dispose();
222
+ session.dispose();
130
223
  } catch (error) {
131
224
  // Cleanup must not replace the task's primary result, but it must emit a
132
225
  // signal: extension-bearing sessions can retain callbacks/resources when a
133
226
  // provider's dispose implementation misbehaves.
134
- console.error("[delegate] uncommitted subagent disposal failed", error);
227
+ console.error(`[delegate] ${description} disposal failed`, error);
228
+ }
229
+ }
230
+
231
+ /** Detach an abandoned session from every owner immediately, then dispose it
232
+ * only after runner's background termination monitor proves quiescence. */
233
+ function quarantineAcquiredSession(
234
+ task: ResolvedTask,
235
+ acquired: AcquiredSession,
236
+ quarantine: SessionQuarantine,
237
+ ): void {
238
+ let mayDisposeAfterSafety = acquired.lifecycleOwnsSession;
239
+ if (!acquired.lifecycleOwnsSession) {
240
+ const detached = task.sessionId
241
+ ? detachQuarantinedPooledSessionForTesting(
242
+ task.sessionId,
243
+ acquired.session,
244
+ )
245
+ : false;
246
+ mayDisposeAfterSafety = detached;
247
+ if (!detached) {
248
+ // The pool may still index this exact object. Keep its quarantine
249
+ // reservation fail-closed until safety, but never schedule disposal of an
250
+ // object that another owner still retains. Once safe, normal pooled reuse
251
+ // is preferable to poisoning the indexed session with a deferred dispose.
252
+ console.error(
253
+ `[delegate] CRITICAL: could not detach abandoned pooled AgentSession${task.sessionId ? ` '${task.sessionId}'` : ""}; retaining the indexed session without disposal until it is safe for pooled reuse`,
254
+ );
255
+ }
256
+ }
257
+
258
+ // Publish the admission reservation before this task can return and its
259
+ // active dispatch reservation is released. task.resumeFrom is already the
260
+ // canonical path passed to acquisition; never resolve the caller's alias
261
+ // again here.
262
+ reserveSessionQuarantine(task, quarantine, task.resumeFrom);
263
+ console.error(
264
+ mayDisposeAfterSafety
265
+ ? `[delegate] AgentSession${task.sessionId ? ` '${task.sessionId}'` : ""} quarantined; deferred disposal is waiting for background quiescence confirmation`
266
+ : `[delegate] AgentSession${task.sessionId ? ` '${task.sessionId}'` : ""} quarantined; pooled reuse remains blocked until background quiescence confirmation`,
267
+ );
268
+ const onSafetyFailure = (error: unknown): void => {
269
+ // A rejected safety proof is not permission to clean anything.
270
+ console.error(
271
+ "[delegate] quarantined AgentSession safety monitor failed; retaining the session indefinitely",
272
+ error,
273
+ );
274
+ };
275
+ if (mayDisposeAfterSafety) {
276
+ observeQuarantineSafety(
277
+ quarantine,
278
+ "quarantined AgentSession disposal",
279
+ () => disposeSession(acquired.session, "quarantined subagent"),
280
+ onSafetyFailure,
281
+ );
282
+ } else {
283
+ // Observe only for a loud monitor failure. No disposal callback may be
284
+ // attached while the pool still indexes the session object.
285
+ observeQuarantineSafety(
286
+ quarantine,
287
+ "retained pooled AgentSession safety",
288
+ () => {},
289
+ onSafetyFailure,
290
+ );
135
291
  }
136
292
  }
137
293
 
@@ -206,6 +362,7 @@ function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
206
362
  p.tokens = r.tokens;
207
363
  p.error = r.error;
208
364
  p.failureKind = r.failureKind;
365
+ p.incomplete = r.incomplete;
209
366
  }
210
367
 
211
368
  /** Outcome of one logical task run: the final result plus how many same-model
@@ -346,6 +503,8 @@ function canRetryWholeTask(
346
503
  // restrictive for retry safety — touched-file accounting and observed activity
347
504
  // are the direct side-effect signals.
348
505
  return (
506
+ !sessionQuarantineOf(result) &&
507
+ result.failureKind !== "cancelled" &&
349
508
  result.failureKind !== "stalled" &&
350
509
  result.failureKind !== "model_error" &&
351
510
  result.failureKind !== "deadline_exceeded" &&
@@ -597,6 +756,15 @@ async function acquireAgentSession(
597
756
  return createFreshSession(env, task);
598
757
  }
599
758
 
759
+ let acquireAgentSessionForTesting = acquireAgentSession;
760
+
761
+ /** @internal Test-only session-acquisition seam. */
762
+ export function _setAcquireAgentSessionForTesting(
763
+ override: typeof acquireAgentSession | undefined,
764
+ ): void {
765
+ acquireAgentSessionForTesting = override ?? acquireAgentSession;
766
+ }
767
+
600
768
  /**
601
769
  * Resolve the `sessionFile` to report on a TaskResult.
602
770
  *
@@ -622,21 +790,86 @@ function resolveResumableSessionFile(
622
790
 
623
791
  /** Run a single resolved task. Single source of truth for the per-task lifecycle.
624
792
  * Used by both sync (params.async === false) and async (params.async === true) paths.
625
- * When task.sessionId is set, the entire acquire/run/close lifecycle runs under
626
- * a per-session mutex so concurrent tasks with the same sessionId serialize
627
- * cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
793
+ * sessionId work is serialized by the pool lock; resumeFrom work is also
794
+ * serialized by canonical transcript identity. The quarantine check happens
795
+ * only after both applicable locks are held, closing the validation-to-run
796
+ * race for a queued call. */
628
797
  export async function runResolvedTask(
629
798
  env: TaskRunEnv,
630
799
  task: ResolvedTask,
631
800
  p: TaskProgress,
632
801
  taskIndex: number,
633
802
  ): Promise<TaskResult> {
634
- if (task.sessionId) {
635
- return pool.withSessionLock(task.sessionId, () =>
636
- runResolvedTaskUnlocked(env, task, p, taskIndex),
637
- );
638
- }
639
- return runResolvedTaskUnlocked(env, task, p, taskIndex);
803
+ return withResumeTranscriptLock(task.resumeFrom, async (transcript) => {
804
+ let executionTask = task;
805
+ if (task.resumeFrom) {
806
+ const resumeFromPathError = validateResumeFromPath(task.resumeFrom);
807
+ if (resumeFromPathError) {
808
+ return recordTaskOutcome(
809
+ env,
810
+ p,
811
+ task,
812
+ finishTask(
813
+ env,
814
+ p,
815
+ failTask(
816
+ task,
817
+ `resumeFrom: invalid session path: ${resumeFromPathError}; got ${JSON.stringify(task.resumeFrom)}`,
818
+ ),
819
+ ),
820
+ );
821
+ }
822
+ if (!transcript?.canonicalPath || !transcript.exists) {
823
+ const missingPath =
824
+ transcript?.lexicalPath ?? resolveCwd(task.resumeFrom);
825
+ return recordTaskOutcome(
826
+ env,
827
+ p,
828
+ task,
829
+ finishTask(
830
+ env,
831
+ p,
832
+ failTask(
833
+ task,
834
+ `resumeFrom: file not found or could not be resolved: ${missingPath}`,
835
+ missingPath,
836
+ ),
837
+ ),
838
+ );
839
+ }
840
+ // Every check, acquisition, and quarantine publication below uses this
841
+ // one physical identity. Never consult the caller's mutable alias again.
842
+ executionTask = { ...task, resumeFrom: transcript.canonicalPath };
843
+ }
844
+
845
+ const runLocked = async (): Promise<TaskResult> => {
846
+ let quarantineError: string | undefined;
847
+ if (
848
+ executionTask.sessionId &&
849
+ isSessionIdQuarantined(executionTask.sessionId)
850
+ ) {
851
+ quarantineError = `SessionId '${executionTask.sessionId}' is quarantined after abandonment. Wait for background safety confirmation before reusing it.`;
852
+ } else if (
853
+ executionTask.resumeFrom &&
854
+ isResumeFromIdentityQuarantined(executionTask.resumeFrom)
855
+ ) {
856
+ quarantineError = `resumeFrom transcript '${task.resumeFrom}' is quarantined after abandonment. Wait for background safety confirmation before resuming it.`;
857
+ }
858
+ if (quarantineError) {
859
+ return recordTaskOutcome(
860
+ env,
861
+ p,
862
+ executionTask,
863
+ finishTask(env, p, failTask(executionTask, quarantineError)),
864
+ );
865
+ }
866
+ return runResolvedTaskUnlocked(env, executionTask, p, taskIndex);
867
+ };
868
+
869
+ return executionTask.sessionId
870
+ ? pool.withSessionLock(executionTask.sessionId, runLocked)
871
+ : runLocked();
872
+ });
640
873
  }
641
874
 
642
875
  async function runResolvedTaskUnlocked(
@@ -696,26 +929,13 @@ async function runResolvedTaskUnlocked(
696
929
  deadlineAt,
697
930
  );
698
931
  } catch (error) {
699
- const setupError = error instanceof Error ? error.message : String(error);
700
- const deadlineExceeded =
701
- !env.signal?.aborted && error instanceof ScratchDeadlineError;
702
- return recordTaskOutcome(
703
- env,
704
- p,
932
+ const setupFailure = scratchSetupFailureResult(
705
933
  task,
706
- finishTask(env, p, {
707
- ...failTask(
708
- task,
709
- env.signal?.aborted
710
- ? "Aborted"
711
- : deadlineExceeded
712
- ? formatDeadlineExceededError(task.deadlineMs ?? 0)
713
- : setupError,
714
- ),
715
- failureKind: deadlineExceeded ? "deadline_exceeded" : undefined,
716
- durationMs: Date.now() - startedAt,
717
- }),
934
+ error,
935
+ env.signal?.aborted === true,
936
+ startedAt,
718
937
  );
938
+ return recordTaskOutcome(env, p, task, finishTask(env, p, setupFailure));
719
939
  }
720
940
 
721
941
  const executionTask: ResolvedTask = {
@@ -734,46 +954,161 @@ async function runResolvedTaskUnlocked(
734
954
  // Keep the pre-mapping result in `result` so the catch below can preserve
735
955
  // its paid-for counters if path mapping throws mid-rewrite.
736
956
  result = outcome.result;
957
+ const fileAttributions = result.fileAttributions ?? [];
958
+ const attributionByLexical = new Map(
959
+ fileAttributions.map((entry) => [path.resolve(entry.lexicalPath), entry]),
960
+ );
961
+ const attributedPhysicalPaths = new Set(
962
+ fileAttributions
963
+ .filter(
964
+ (entry) =>
965
+ entry.preExecutionPhysicalPath !== undefined &&
966
+ path.resolve(entry.preExecutionPhysicalPath) !==
967
+ path.resolve(entry.lexicalPath),
968
+ )
969
+ .map((entry) => path.resolve(entry.preExecutionPhysicalPath!)),
970
+ );
971
+ const logProjectionFailure = (
972
+ kind: string,
973
+ candidate: string,
974
+ error: unknown,
975
+ ) => {
976
+ const safeJson = (value: string) =>
977
+ JSON.stringify(
978
+ value.length > 1_024
979
+ ? `${value.slice(0, 1_024)}…[truncated ${value.length - 1_024} chars]`
980
+ : value,
981
+ )
982
+ .replace(/\u2028/g, "\\u2028")
983
+ .replace(/\u2029/g, "\\u2029");
984
+ const reason = error instanceof Error ? error.message : String(error);
985
+ console.error(
986
+ `[delegate] scratch ${kind} projection failed for ${safeJson(candidate)} (reason=${safeJson(reason)}); retaining conservative lexical evidence`,
987
+ );
988
+ };
989
+ const attributionSettled = await Promise.allSettled(
990
+ fileAttributions.map((entry) =>
991
+ workspace.resolveFileAttribution
992
+ ? workspace.resolveFileAttribution(entry)
993
+ : Promise.resolve(entry),
994
+ ),
995
+ );
996
+ const projectedAttributions = attributionSettled
997
+ .map((settled, index) => {
998
+ if (settled.status === "fulfilled") return settled.value;
999
+ const entry = fileAttributions[index]!;
1000
+ logProjectionFailure("attribution", entry.lexicalPath, settled.reason);
1001
+ return {
1002
+ ...entry,
1003
+ lexicalPath: workspace.mapPathToSource(entry.lexicalPath),
1004
+ preExecutionPhysicalPath: entry.preExecutionPhysicalPath
1005
+ ? workspace.mapPathToSource(entry.preExecutionPhysicalPath)
1006
+ : undefined,
1007
+ uncertain: true,
1008
+ };
1009
+ })
1010
+ .filter(
1011
+ (entry): entry is NonNullable<typeof entry> => entry !== undefined,
1012
+ );
1013
+ const touchedSettled = await Promise.allSettled(
1014
+ result.touchedFiles.map(async (file) => {
1015
+ const absolute = path.resolve(file);
1016
+ // Preserve this touched-file evidence, but project it lexically: a
1017
+ // realpath now could follow a replacement symlink. attributedFiles
1018
+ // separately decides whether the physical target was disposable.
1019
+ if (attributedPhysicalPaths.has(absolute)) {
1020
+ return workspace.mapPathToSource(absolute);
1021
+ }
1022
+ const attribution = attributionByLexical.get(absolute);
1023
+ if (attribution && workspace.resolveAttributedLexicalTouch) {
1024
+ return workspace.resolveAttributedLexicalTouch(attribution);
1025
+ }
1026
+ return workspace.resolveReportedPath(file);
1027
+ }),
1028
+ );
1029
+ const projectedTouched = touchedSettled
1030
+ .map((settled, index) => {
1031
+ if (settled.status === "fulfilled") return settled.value;
1032
+ const file = result!.touchedFiles[index]!;
1033
+ logProjectionFailure("touched-path", file, settled.reason);
1034
+ return workspace.mapPathToSource(file);
1035
+ })
1036
+ .filter((file): file is string => file !== undefined);
1037
+ let projectedAttributedFiles: string[];
1038
+ if (fileAttributions.length) {
1039
+ projectedAttributedFiles = projectedAttributions.map(
1040
+ projectedAttributionPath,
1041
+ );
1042
+ } else {
1043
+ const legacy = result.attributedFiles ?? [];
1044
+ const legacySettled = await Promise.allSettled(
1045
+ legacy.map((file) => workspace.resolveAttributedPath(file)),
1046
+ );
1047
+ projectedAttributedFiles = legacySettled
1048
+ .map((settled, index) => {
1049
+ if (settled.status === "fulfilled") return settled.value;
1050
+ const file = legacy[index]!;
1051
+ logProjectionFailure("attributed-path", file, settled.reason);
1052
+ return workspace.mapPathToSource(file);
1053
+ })
1054
+ .filter((file): file is string => file !== undefined);
1055
+ }
737
1056
  result = {
738
1057
  ...result,
739
1058
  workspace: "scratch",
740
1059
  sessionFile: undefined,
741
- touchedFiles: await Promise.all(
742
- result.touchedFiles.map((file) => workspace.resolveReportedPath(file)),
743
- ),
744
- // Writes inside scratch are discarded and cannot conflict. Explicit
745
- // writes outside scratch (for example an absolute host path) persist and
746
- // must remain attributable for overlap warnings. Resolve those paths
747
- // physically so aliases to the same host file compare equally.
748
- attributedFiles: (
749
- await Promise.all(
750
- (result.attributedFiles ?? []).map((file) =>
751
- workspace.resolveAttributedPath(file),
752
- ),
753
- )
754
- ).filter((file): file is string => file !== undefined),
1060
+ fileAttributions: projectedAttributions,
1061
+ touchedFiles: [
1062
+ ...new Set([
1063
+ ...projectedTouched,
1064
+ ...(fileAttributions.length ? projectedAttributedFiles : []),
1065
+ ]),
1066
+ ],
1067
+ // Certain writes inside scratch are discarded. External and uncertain
1068
+ // evidence remains attributable without re-resolving physical snapshots.
1069
+ attributedFiles: [...new Set(projectedAttributedFiles)],
755
1070
  };
756
1071
  } catch (error) {
757
- result = {
758
- ...failTask(task, error instanceof Error ? error.message : String(error)),
759
- ...(result
760
- ? {
761
- tokens: result.tokens,
762
- usage: result.usage,
763
- }
764
- : {}),
765
- workspace: "scratch",
766
- durationMs: Date.now() - startedAt,
767
- };
1072
+ result = scratchProjectionFailureResult(task, result, error, startedAt);
768
1073
  // runResolvedTaskCore may already have notified a successful result
769
1074
  // before path mapping failed. Correct that observable outcome below.
770
1075
  needsCorrection = true;
771
1076
  } finally {
772
- try {
773
- await workspace.cleanup();
774
- } catch (error) {
775
- cleanupError = `Scratch workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
776
- console.error("[delegate] scratch workspace cleanup failed", error);
1077
+ const quarantine = sessionQuarantineOf(result);
1078
+ if (quarantine) {
1079
+ console.error(
1080
+ `[delegate] retaining abandoned scratch workspace '${workspace.scratchRoot}' until its AgentSession is confirmed quiescent`,
1081
+ );
1082
+ observeQuarantineSafety(
1083
+ quarantine,
1084
+ "deferred scratch workspace cleanup",
1085
+ async () => {
1086
+ try {
1087
+ await workspace.cleanup();
1088
+ console.error(
1089
+ `[delegate] safely cleaned deferred scratch workspace '${workspace.scratchRoot}'`,
1090
+ );
1091
+ } catch (error) {
1092
+ console.error(
1093
+ `[delegate] deferred scratch workspace cleanup failed for '${workspace.scratchRoot}'; retaining it`,
1094
+ error,
1095
+ );
1096
+ }
1097
+ },
1098
+ (error) => {
1099
+ console.error(
1100
+ `[delegate] scratch AgentSession safety monitor failed; retaining workspace '${workspace.scratchRoot}' indefinitely`,
1101
+ error,
1102
+ );
1103
+ },
1104
+ );
1105
+ } else {
1106
+ try {
1107
+ await workspace.cleanup();
1108
+ } catch (error) {
1109
+ cleanupError = `Scratch workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
1110
+ console.error("[delegate] scratch workspace cleanup failed", error);
1111
+ }
777
1112
  }
778
1113
  }
779
1114
 
@@ -911,9 +1246,21 @@ function deadlineExceededResult(
911
1246
  sessionFile: prior?.sessionFile,
912
1247
  touchedFiles: prior?.touchedFiles ?? [],
913
1248
  attributedFiles: prior?.attributedFiles ?? [],
1249
+ fileAttributions: prior?.fileAttributions,
914
1250
  };
915
1251
  }
916
1252
 
1253
+ function abandonedAcquisitionResult(
1254
+ task: ResolvedTask,
1255
+ timing: AttemptTiming,
1256
+ reason: "parent-aborted" | "deadline",
1257
+ ): TaskResult {
1258
+ if (reason === "parent-aborted") {
1259
+ return failTask(task, "Aborted", undefined, "cancelled");
1260
+ }
1261
+ return deadlineExceededResult(task, timing);
1262
+ }
1263
+
917
1264
  function noteAttemptProgress(
918
1265
  env: TaskRunEnv,
919
1266
  p: TaskProgress,
@@ -986,8 +1333,8 @@ async function settlePooledAttempt(
986
1333
  // A pre-prompt deadline (runner never called session.prompt()) left
987
1334
  // the session in its pre-task state, so return it to the pool intact.
988
1335
  if (
1336
+ r.failureKind === "cancelled" ||
989
1337
  r.failureKind === "stalled" ||
990
- r.error === "Aborted" ||
991
1338
  (r.failureKind === "deadline_exceeded" && r.prompted !== false)
992
1339
  ) {
993
1340
  try {
@@ -1018,6 +1365,99 @@ async function settlePooledAttempt(
1018
1365
  return sessionReleased;
1019
1366
  }
1020
1367
 
1368
+ type AcquisitionOutcome =
1369
+ | { status: "acquired"; value: AcquireResult }
1370
+ | { status: "abandoned"; reason: "parent-aborted" | "deadline" };
1371
+
1372
+ /**
1373
+ * Wait for materialization only while the task remains live. Host dependency or
1374
+ * session construction can wedge without yielding an AgentSession to abort, so
1375
+ * cancellation/deadline must have an explicit abandoned-acquisition path too.
1376
+ * A session that materializes late is disposed immediately and is never
1377
+ * prompted or pooled.
1378
+ */
1379
+ async function awaitSessionAcquisition(
1380
+ acquisition: Promise<AcquireResult>,
1381
+ signal: AbortSignal | undefined,
1382
+ deadlineAt: number | undefined,
1383
+ ): Promise<AcquisitionOutcome> {
1384
+ let stop!: (reason: "parent-aborted" | "deadline") => void;
1385
+ const stopped = new Promise<"parent-aborted" | "deadline">((resolve) => {
1386
+ stop = resolve;
1387
+ });
1388
+ const onAbort = () => stop("parent-aborted");
1389
+ signal?.addEventListener("abort", onAbort, { once: true });
1390
+ let clearDeadline: (() => void) | undefined;
1391
+ if (deadlineAt !== undefined) {
1392
+ clearDeadline = scheduleDeadline(deadlineAt, () => stop("deadline"));
1393
+ }
1394
+ if (signal?.aborted) onAbort();
1395
+ else if (deadlineAt !== undefined && Date.now() >= deadlineAt)
1396
+ stop("deadline");
1397
+
1398
+ try {
1399
+ return await Promise.race([
1400
+ acquisition.then((value): AcquisitionOutcome => ({
1401
+ status: "acquired",
1402
+ value,
1403
+ })),
1404
+ stopped.then((reason): AcquisitionOutcome => ({
1405
+ status: "abandoned",
1406
+ reason,
1407
+ })),
1408
+ ]);
1409
+ } finally {
1410
+ clearDeadline?.();
1411
+ signal?.removeEventListener("abort", onAbort);
1412
+ }
1413
+ }
1414
+
1415
+ /** Publish ownership for an acquisition that outlived its task. The safety
1416
+ * promise intentionally uses no polling timer: a construction promise that
1417
+ * never settles retains the reservation but cannot by itself keep Node alive.
1418
+ * A late lifecycle-owned session must finish disposal before safety fulfills. */
1419
+ function quarantineAbandonedAcquisition(
1420
+ task: ResolvedTask,
1421
+ acquisition: Promise<AcquireResult>,
1422
+ ): SessionQuarantine {
1423
+ const safe = acquisition.then(
1424
+ async (late) => {
1425
+ if ("error" in late || !late.lifecycleOwnsSession) return;
1426
+ try {
1427
+ // AgentSession.dispose is currently synchronous, but awaiting its
1428
+ // runtime return also handles extension implementations that perform
1429
+ // asynchronous teardown or reject.
1430
+ await (late.session.dispose as unknown as () => void | Promise<void>)();
1431
+ } catch (error) {
1432
+ console.error(
1433
+ "[delegate] late abandoned subagent disposal failed; retaining quarantine",
1434
+ error,
1435
+ );
1436
+ throw error;
1437
+ }
1438
+ },
1439
+ (error) => {
1440
+ // Rejection proves that acquisition produced no session. Handle it here
1441
+ // so the abandoned promise can never become an unhandled rejection.
1442
+ try {
1443
+ console.error(
1444
+ "[delegate] abandoned subagent acquisition settled with an error",
1445
+ error,
1446
+ );
1447
+ } catch {
1448
+ // Logging replacements must not turn a safely settled acquisition into
1449
+ // a rejected safety proof.
1450
+ }
1451
+ },
1452
+ );
1453
+ const quarantine = { safe };
1454
+ // Publish before returning the terminal task result. This bridges shared
1455
+ // admission from the active dispatch reservation to quarantine atomically.
1456
+ // task.resumeFrom is the exact identity supplied to acquisition.
1457
+ reserveSessionQuarantine(task, quarantine, task.resumeFrom);
1458
+ return quarantine;
1459
+ }
1460
+
1021
1461
  /** Acquire, prompt, and settle one attempt. Mutates `accounting` on success. */
1022
1462
  async function runTaskAttempt(
1023
1463
  env: TaskRunEnv,
@@ -1032,7 +1472,24 @@ async function runTaskAttempt(
1032
1472
  noteAttemptProgress(env, p, u, timing, accounting);
1033
1473
  };
1034
1474
 
1035
- const acquired = await acquireAgentSession(env, task, p);
1475
+ const acquisition = acquireAgentSessionForTesting(env, task, p);
1476
+ const acquisitionOutcome = await awaitSessionAcquisition(
1477
+ acquisition,
1478
+ env.signal,
1479
+ timing.deadlineAt,
1480
+ );
1481
+ if (acquisitionOutcome.status === "abandoned") {
1482
+ // Acquisition itself is not cancellable upstream. Its quarantine remains
1483
+ // active until the promise settles and any late session finishes disposal.
1484
+ const quarantine = quarantineAbandonedAcquisition(task, acquisition);
1485
+ const result = abandonedAcquisitionResult(
1486
+ task,
1487
+ timing,
1488
+ acquisitionOutcome.reason,
1489
+ );
1490
+ return markSessionQuarantined(result, quarantine);
1491
+ }
1492
+ const acquired = acquisitionOutcome.value;
1036
1493
  if ("error" in acquired) return acquired.error;
1037
1494
 
1038
1495
  // A pool hit is already owned by the pool. Fresh/resumed sessions belong
@@ -1045,7 +1502,7 @@ async function runTaskAttempt(
1045
1502
  // cancelled ticket should not even start the subagent (no file writes, no
1046
1503
  // pool insert).
1047
1504
  if (env.signal?.aborted) {
1048
- return failTask(task, "Aborted");
1505
+ return failTask(task, "Aborted", undefined, "cancelled");
1049
1506
  }
1050
1507
 
1051
1508
  // Snapshot git status before the run so touchedFiles can diff after.
@@ -1072,21 +1529,33 @@ async function runTaskAttempt(
1072
1529
  // collecting post-prompt evidence. Keep cancellation from looking like
1073
1530
  // success; finally below releases any uncommitted session.
1074
1531
  if (env.signal?.aborted && !r.error) {
1075
- r = { ...r, error: "Aborted" };
1532
+ r = { ...r, error: "Aborted", failureKind: "cancelled" };
1076
1533
  }
1077
1534
 
1078
- const sessionFile = resolveResumableSessionFile(
1079
- acquired.sessionFile,
1080
- acquired.sessionManager,
1081
- r.error,
1082
- );
1083
-
1084
- sessionReleased = await settlePooledAttempt(
1085
- task,
1086
- acquired,
1087
- r,
1088
- sessionReleased,
1089
- );
1535
+ const quarantine = sessionQuarantineOf(r);
1536
+ // Persisting or advertising a resumable transcript while its session is
1537
+ // still mutating would create another owner of unsafe state.
1538
+ const sessionFile = quarantine
1539
+ ? undefined
1540
+ : resolveResumableSessionFile(
1541
+ acquired.sessionFile,
1542
+ acquired.sessionManager,
1543
+ r.error,
1544
+ );
1545
+
1546
+ if (quarantine) {
1547
+ quarantineAcquiredSession(task, acquired, quarantine);
1548
+ // Neither lifecycle nor pool owns it now. The deferred safety callback is
1549
+ // the sole owner and finally below must not dispose it early.
1550
+ sessionReleased = true;
1551
+ } else {
1552
+ sessionReleased = await settlePooledAttempt(
1553
+ task,
1554
+ acquired,
1555
+ r,
1556
+ sessionReleased,
1557
+ );
1558
+ }
1090
1559
 
1091
1560
  accounting.accumulatedUsage = addUsage(
1092
1561
  accounting.accumulatedUsage,
@@ -1094,7 +1563,7 @@ async function runTaskAttempt(
1094
1563
  );
1095
1564
  accounting.cumulativeToolUses += attemptToolUsesObserved;
1096
1565
 
1097
- return {
1566
+ return propagateSessionQuarantine(r, {
1098
1567
  id: task.id,
1099
1568
  agent: task.agentName,
1100
1569
  output: r.output,
@@ -1110,13 +1579,15 @@ async function runTaskAttempt(
1110
1579
  (r.error && isModelAttributableError(r.error)
1111
1580
  ? "model_error"
1112
1581
  : undefined),
1582
+ incomplete: r.incomplete,
1113
1583
  durationMs: r.durationMs,
1114
1584
  tokens: r.tokens,
1115
1585
  usage: r.usage,
1116
1586
  sessionFile,
1117
1587
  touchedFiles: r.touchedFiles,
1118
1588
  attributedFiles: r.attributedFiles ?? [],
1119
- };
1589
+ fileAttributions: r.fileAttributions,
1590
+ });
1120
1591
  } finally {
1121
1592
  // This runs for ordinary success, normal provider failure, whole-task
1122
1593
  // retry attempts, abort races, stalls, and unexpected throws. Pool hits
@@ -1210,7 +1681,7 @@ async function runWithWholeTaskRetries(
1210
1681
  // while recording that the retry loop was aborted. The task already
1211
1682
  // paid for every completed attempt, including the one before sleep;
1212
1683
  // counters are reconciled by the single return below.
1213
- result = { ...result, error: "Aborted" };
1684
+ result = { ...result, error: "Aborted", failureKind: "cancelled" };
1214
1685
  break;
1215
1686
  }
1216
1687
 
@@ -1247,7 +1718,11 @@ async function runResolvedTaskCore(
1247
1718
  ): Promise<TaskOutcome> {
1248
1719
  try {
1249
1720
  if (env.signal?.aborted) {
1250
- return finishTask(env, p, failTask(task, "Aborted"));
1721
+ return finishTask(
1722
+ env,
1723
+ p,
1724
+ failTask(task, "Aborted", undefined, "cancelled"),
1725
+ );
1251
1726
  }
1252
1727
 
1253
1728
  // Primary busy validation is in execute() before ticket creation.