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