@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/README.md +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -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 +232 -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 +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -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,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
|
-
|
|
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(
|
|
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
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}),
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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(
|
|
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.
|