@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/README.md +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +25 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +107 -21
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +559 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +243 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +63 -50
- package/telemetry.ts +1 -1
- package/ticket-format.ts +15 -9
- package/tickets.ts +36 -14
- package/tools.ts +5 -4
- package/types.ts +92 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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(
|
|
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.
|