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