@bermudi/pi-delegate 0.1.9 → 0.1.11
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 +22 -7
- package/agents.ts +140 -14
- package/delegate.ts +3 -2
- package/dispatch.ts +26 -27
- package/extension.ts +2 -4
- package/format.ts +2 -2
- package/host-cache.ts +70 -0
- package/host.ts +164 -811
- package/lifecycle.ts +653 -508
- package/manual.ts +40 -10
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/provider-extensions.ts +528 -0
- package/quiescence.ts +262 -0
- package/runner.ts +32 -140
- package/schema.ts +77 -153
- package/task-resolution.ts +60 -16
- package/ticket-format.ts +323 -0
- package/tickets.ts +79 -248
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +13 -17
package/quiescence.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post-prompt quiescence barrier.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `AgentSession.prompt()` can resolve while work it started is still running.
|
|
7
|
+
* Pi awaits `agent_settled` extension handlers, but an extension's handler is
|
|
8
|
+
* free to call `ctx.compact()` and return immediately — and in pi 0.84
|
|
9
|
+
* `ctx.compact()` is `void (async () => { … })()`. The session tracks that
|
|
10
|
+
* compaction in `isCompacting`, but it holds no promise for it, and the
|
|
11
|
+
* `onComplete` callback that fires afterwards may start a *continuation*
|
|
12
|
+
* prompt. So `await prompt()` is not "the session is done with my task".
|
|
13
|
+
*
|
|
14
|
+
* If the runner returned ownership at that point, lifecycle would dispose or
|
|
15
|
+
* re-pool a session that is still mutating files, spending tokens, and reading
|
|
16
|
+
* a context that disposal has already invalidated. See
|
|
17
|
+
* `docs/pi-codex-compaction-stale-context-defect.md`.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it is a heuristic
|
|
20
|
+
*
|
|
21
|
+
* Pi exposes no primitive for "pending extension work" — no counter, no
|
|
22
|
+
* promise registry, no `hasPendingExtensionWork()`. `waitForIdle()` is not a
|
|
23
|
+
* substitute: `isIdle` is false only while a run is *active*, so a detached
|
|
24
|
+
* continuation that has not started yet is indistinguishable from one that
|
|
25
|
+
* will never start. All this barrier can observe is `isIdle`, `isCompacting`,
|
|
26
|
+
* and the session event stream.
|
|
27
|
+
*
|
|
28
|
+
* It therefore waits for **stability** rather than completion: the session must
|
|
29
|
+
* be idle, non-compacting, and event-quiet across N consecutive event-loop
|
|
30
|
+
* turns. Two turns matter because `compaction_end` is emitted *before*
|
|
31
|
+
* `ctx.compact`'s `onComplete` runs, and that callback may start a continuation
|
|
32
|
+
* in the very next turn.
|
|
33
|
+
*
|
|
34
|
+
* This is a **mitigation, not a proof**. A deterministic fix belongs upstream
|
|
35
|
+
* (make `ctx.compact()` awaitable, or expose pending extension work); until
|
|
36
|
+
* then, detached work that pauses longer than the grace period can still
|
|
37
|
+
* outlast the barrier.
|
|
38
|
+
*
|
|
39
|
+
* ## Bounded unwind
|
|
40
|
+
*
|
|
41
|
+
* While the task is healthy the barrier waits indefinitely — a legitimate
|
|
42
|
+
* remote compaction can take minutes, and cutting it short re-opens the
|
|
43
|
+
* disposal race. Unbounded waiting is safe there because the runner's stall
|
|
44
|
+
* watchdog is armed: a wedged session gets cancelled, which moves the barrier
|
|
45
|
+
* into its bounded mode.
|
|
46
|
+
*
|
|
47
|
+
* Once cancellation has been requested the session is supposed to be tearing
|
|
48
|
+
* down, so the wait is bounded by `cancelledUnwindBudgetMs`. Without a bound,
|
|
49
|
+
* an extension that keeps launching continuations keeps resetting progress and
|
|
50
|
+
* the barrier never returns — hanging the delegate task forever, which is
|
|
51
|
+
* strictly worse than reporting a cancelled task whose session may still be
|
|
52
|
+
* active. On expiry the barrier logs and returns `"abandoned"`.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** Why the runner asked the session to stop. */
|
|
56
|
+
export type CancellationSource = "parent-aborted" | "stalled" | "deadline";
|
|
57
|
+
|
|
58
|
+
/** The only session state the barrier observes. */
|
|
59
|
+
export type QuiescenceObservable = {
|
|
60
|
+
readonly isIdle: boolean;
|
|
61
|
+
readonly isCompacting: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type QuiescenceTimings = {
|
|
65
|
+
/** Consecutive unchanged event-loop turns required to declare quiescence. */
|
|
66
|
+
requiredQuietTurns: number;
|
|
67
|
+
/**
|
|
68
|
+
* One-shot wait, after cancellation, before quiet turns may accumulate. Two
|
|
69
|
+
* event-loop turns pass in microseconds — far faster than a continuation
|
|
70
|
+
* delayed by async auth — so without this the barrier would return before a
|
|
71
|
+
* delayed continuation ever became observable.
|
|
72
|
+
*/
|
|
73
|
+
cancelledGraceMs: number;
|
|
74
|
+
/**
|
|
75
|
+
* Liveness fallback for host versions that clear an internal busy flag
|
|
76
|
+
* without emitting a corresponding public event. Normal transitions arrive
|
|
77
|
+
* as events, so this only bounds the pathological case.
|
|
78
|
+
*/
|
|
79
|
+
eventProbeMs: number;
|
|
80
|
+
/** Ceiling on a cancelled unwind before the barrier gives up. */
|
|
81
|
+
cancelledUnwindBudgetMs: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_QUIESCENCE_TIMINGS: QuiescenceTimings = {
|
|
85
|
+
requiredQuietTurns: 2,
|
|
86
|
+
cancelledGraceMs: 50,
|
|
87
|
+
eventProbeMs: 250,
|
|
88
|
+
cancelledUnwindBudgetMs: 30_000,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `"quiescent"` — the session went idle and stayed quiet; ownership may be
|
|
93
|
+
* returned to the caller. `"abandoned"` — the cancelled-unwind budget expired
|
|
94
|
+
* while work was still starting; the caller regains ownership of a session
|
|
95
|
+
* that may still be active.
|
|
96
|
+
*/
|
|
97
|
+
export type QuiescenceOutcome = "quiescent" | "abandoned";
|
|
98
|
+
|
|
99
|
+
export type QuiescenceBarrierOptions = {
|
|
100
|
+
session: QuiescenceObservable;
|
|
101
|
+
/** The active cancellation source, or `undefined` while the task is healthy. */
|
|
102
|
+
cancellation: () => CancellationSource | undefined;
|
|
103
|
+
/** Re-request cooperative cancellation of work that started post-cancellation. */
|
|
104
|
+
cancel: (source: CancellationSource) => void;
|
|
105
|
+
timings?: Partial<QuiescenceTimings>;
|
|
106
|
+
now?: () => number;
|
|
107
|
+
/** Overridable for tests; defaults to a `console.error` trace. */
|
|
108
|
+
onAbandon?: (info: {
|
|
109
|
+
source: CancellationSource;
|
|
110
|
+
waitedMs: number;
|
|
111
|
+
reAborts: number;
|
|
112
|
+
}) => void;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type QuiescenceBarrier = {
|
|
116
|
+
/**
|
|
117
|
+
* Record any observed session event. Every event counts, including ones the
|
|
118
|
+
* runner ignores for progress: an event is evidence that something is alive.
|
|
119
|
+
*/
|
|
120
|
+
noteEvent(): void;
|
|
121
|
+
/**
|
|
122
|
+
* Record that cooperative cancellation was just dispatched. Must be called
|
|
123
|
+
* *after* `session.abort()` is invoked, so events the abort itself emits
|
|
124
|
+
* synchronously are attributed to the abort rather than mistaken for new
|
|
125
|
+
* work.
|
|
126
|
+
*/
|
|
127
|
+
noteCancellationRequested(): void;
|
|
128
|
+
/** Wait for stability. Safe to call more than once per task. */
|
|
129
|
+
wait(): Promise<QuiescenceOutcome>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function createQuiescenceBarrier(
|
|
133
|
+
options: QuiescenceBarrierOptions,
|
|
134
|
+
): QuiescenceBarrier {
|
|
135
|
+
const { session, cancellation, cancel } = options;
|
|
136
|
+
const timings = { ...DEFAULT_QUIESCENCE_TIMINGS, ...options.timings };
|
|
137
|
+
const now = options.now ?? Date.now;
|
|
138
|
+
const onAbandon =
|
|
139
|
+
options.onAbandon ??
|
|
140
|
+
(({ source, waitedMs, reAborts }) => {
|
|
141
|
+
console.error(
|
|
142
|
+
`[delegate] quiescence barrier gave up after ${waitedMs}ms and ${reAborts} re-abort(s) unwinding a ${source} subagent; the session may still be running detached extension work`,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Monotonic counter over observed session events. Comparing a sampled
|
|
147
|
+
// generation to the current one closes the race between "check session
|
|
148
|
+
// state" and "install the next waiter": an event that lands in between
|
|
149
|
+
// changes the generation, so it can never be missed.
|
|
150
|
+
let generation = 0;
|
|
151
|
+
let wake: (() => void) | undefined;
|
|
152
|
+
// Generation at the moment cancellation was last dispatched. Any later
|
|
153
|
+
// event means new work started despite the abort. -1 = never cancelled.
|
|
154
|
+
let cancelledAtGeneration = -1;
|
|
155
|
+
|
|
156
|
+
const noteEvent = () => {
|
|
157
|
+
generation++;
|
|
158
|
+
const pending = wake;
|
|
159
|
+
wake = undefined;
|
|
160
|
+
pending?.();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const sleep = (ms: number) =>
|
|
164
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
165
|
+
const nextEventLoopTurn = () =>
|
|
166
|
+
new Promise<void>((resolve) => setImmediate(resolve));
|
|
167
|
+
|
|
168
|
+
/** Resolve on the next session event, or after the liveness probe. */
|
|
169
|
+
const waitForEventAfter = (sampled: number, probeMs: number) => {
|
|
170
|
+
if (generation !== sampled) return Promise.resolve();
|
|
171
|
+
return new Promise<void>((resolve) => {
|
|
172
|
+
let settled = false;
|
|
173
|
+
const finish = () => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
clearTimeout(probe);
|
|
177
|
+
if (wake === finish) wake = undefined;
|
|
178
|
+
resolve();
|
|
179
|
+
};
|
|
180
|
+
const probe = setTimeout(finish, probeMs);
|
|
181
|
+
if (generation !== sampled) {
|
|
182
|
+
finish();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
wake = finish;
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const wait = async (): Promise<QuiescenceOutcome> => {
|
|
190
|
+
let quietTurns = 0;
|
|
191
|
+
let graceWaited = false;
|
|
192
|
+
let reAborts = 0;
|
|
193
|
+
let unwindStartedAt: number | undefined;
|
|
194
|
+
// Remaining cancelled-unwind budget, or Infinity while healthy. Timed
|
|
195
|
+
// waits are clamped to it so expiry is not overshot by a full probe.
|
|
196
|
+
let budgetLeft = Number.POSITIVE_INFINITY;
|
|
197
|
+
|
|
198
|
+
while (quietTurns < timings.requiredQuietTurns) {
|
|
199
|
+
const sampled = generation;
|
|
200
|
+
await nextEventLoopTurn();
|
|
201
|
+
|
|
202
|
+
const source = cancellation();
|
|
203
|
+
if (source) {
|
|
204
|
+
unwindStartedAt ??= now();
|
|
205
|
+
const waitedMs = now() - unwindStartedAt;
|
|
206
|
+
budgetLeft = timings.cancelledUnwindBudgetMs - waitedMs;
|
|
207
|
+
if (budgetLeft <= 0) {
|
|
208
|
+
onAbandon({ source, waitedMs, reAborts });
|
|
209
|
+
return "abandoned";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Re-abort work that started after the last cancellation request —
|
|
213
|
+
// typically a continuation from an extension's onComplete callback,
|
|
214
|
+
// possibly delayed by async auth. Checked *before* the idle check so a
|
|
215
|
+
// fast continuation that already finished between samples is still
|
|
216
|
+
// caught: the generation moved even though the session is idle again.
|
|
217
|
+
// Re-aborting resets progress so any further continuation is seen too.
|
|
218
|
+
if (
|
|
219
|
+
cancelledAtGeneration >= 0 &&
|
|
220
|
+
generation !== cancelledAtGeneration
|
|
221
|
+
) {
|
|
222
|
+
cancel(source);
|
|
223
|
+
reAborts++;
|
|
224
|
+
quietTurns = 0;
|
|
225
|
+
graceWaited = false;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const idle = session.isIdle && !session.isCompacting;
|
|
231
|
+
if (idle && generation === sampled) {
|
|
232
|
+
if (source && !graceWaited) {
|
|
233
|
+
// A single timed wait, not a busy-spin: the event loop stays free to
|
|
234
|
+
// process a delayed continuation's events while we sleep.
|
|
235
|
+
graceWaited = true;
|
|
236
|
+
await sleep(Math.min(timings.cancelledGraceMs, budgetLeft));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
quietTurns++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
quietTurns = 0;
|
|
244
|
+
graceWaited = false;
|
|
245
|
+
if (!idle) {
|
|
246
|
+
await waitForEventAfter(
|
|
247
|
+
generation,
|
|
248
|
+
Math.min(timings.eventProbeMs, budgetLeft),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return "quiescent";
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
noteEvent,
|
|
257
|
+
noteCancellationRequested: () => {
|
|
258
|
+
cancelledAtGeneration = generation;
|
|
259
|
+
},
|
|
260
|
+
wait,
|
|
261
|
+
};
|
|
262
|
+
}
|
package/runner.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { snapshotSessionUsage, usageDelta, emptyUsage } from "./usage.ts";
|
|
|
12
12
|
import { getStallTimeoutMs } from "./config.ts";
|
|
13
13
|
import { fmtDuration } from "./format.ts";
|
|
14
14
|
import { scheduleDeadline } from "./timer.ts";
|
|
15
|
+
import {
|
|
16
|
+
createQuiescenceBarrier,
|
|
17
|
+
type CancellationSource,
|
|
18
|
+
} from "./quiescence.ts";
|
|
15
19
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
16
20
|
import type {
|
|
17
21
|
AgentProgressUpdate,
|
|
@@ -40,7 +44,7 @@ export function formatDeadlineExceededError(budgetMs: number): string {
|
|
|
40
44
|
* `AgentProgressUpdate` / `ToolActivity` shapes,
|
|
41
45
|
* 2. wires the parent abort signal to `session.abort()`,
|
|
42
46
|
* 3. waits for extension-started post-run compaction/continuations to become
|
|
43
|
-
* quiescent before returning ownership to lifecycle,
|
|
47
|
+
* quiescent before returning ownership to lifecycle (`quiescence.ts`),
|
|
44
48
|
* 4. snapshots usage before/after the prompt for token delta accounting, and
|
|
45
49
|
* 5. computes touched files from activity + git diff.
|
|
46
50
|
*
|
|
@@ -97,51 +101,26 @@ export async function runAgentSession(
|
|
|
97
101
|
let prompted = false;
|
|
98
102
|
const activities: ToolActivity[] = [];
|
|
99
103
|
const pendingById = new Map<string, ToolActivity>();
|
|
100
|
-
let sessionEventGeneration = 0;
|
|
101
|
-
let wakeSessionEvent: (() => void) | undefined;
|
|
102
104
|
|
|
103
105
|
// AgentSession.prompt() can return while an agent_settled extension callback
|
|
104
|
-
// is still running fire-and-forget work through ctx.compact().
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
let settled = false;
|
|
121
|
-
const probe = setTimeout(() => finish(), 250);
|
|
122
|
-
const finish = () => {
|
|
123
|
-
if (settled) return;
|
|
124
|
-
settled = true;
|
|
125
|
-
clearTimeout(probe);
|
|
126
|
-
if (wakeSessionEvent === finish) wakeSessionEvent = undefined;
|
|
127
|
-
resolve();
|
|
128
|
-
};
|
|
129
|
-
if (sessionEventGeneration !== generation) {
|
|
130
|
-
finish();
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
// AgentSession normally emits every transition we care about. The slow
|
|
134
|
-
// probe is a liveness fallback for host versions that clear an internal
|
|
135
|
-
// busy flag without a corresponding public event.
|
|
136
|
-
wakeSessionEvent = finish;
|
|
137
|
-
});
|
|
138
|
-
};
|
|
139
|
-
const nextEventLoopTurn = () =>
|
|
140
|
-
new Promise<void>((resolve) => setImmediate(resolve));
|
|
106
|
+
// is still running fire-and-forget work through ctx.compact(). The barrier
|
|
107
|
+
// owns the "is the session really done?" reasoning; see quiescence.ts for
|
|
108
|
+
// why it cannot be answered deterministically against today's host.
|
|
109
|
+
const barrier = createQuiescenceBarrier({
|
|
110
|
+
session,
|
|
111
|
+
cancellation: () =>
|
|
112
|
+
signal?.aborted
|
|
113
|
+
? "parent-aborted"
|
|
114
|
+
: deadlineExceeded
|
|
115
|
+
? "deadline"
|
|
116
|
+
: stalled
|
|
117
|
+
? "stalled"
|
|
118
|
+
: undefined,
|
|
119
|
+
cancel: (source) => requestSessionCancellation(source),
|
|
120
|
+
});
|
|
121
|
+
const waitForSessionQuiescence = () => barrier.wait();
|
|
141
122
|
|
|
142
|
-
const requestSessionCancellation = (
|
|
143
|
-
source: "parent-aborted" | "stalled" | "deadline",
|
|
144
|
-
): void => {
|
|
123
|
+
const requestSessionCancellation = (source: CancellationSource): void => {
|
|
145
124
|
const logFailure = (operation: string, error: unknown) => {
|
|
146
125
|
console.error(`[delegate] ${source} subagent ${operation} failed`, error);
|
|
147
126
|
};
|
|
@@ -155,106 +134,19 @@ export async function runAgentSession(
|
|
|
155
134
|
} catch (error) {
|
|
156
135
|
logFailure("branch-summary cancellation", error);
|
|
157
136
|
}
|
|
158
|
-
|
|
159
|
-
// Fire the abort before recording the
|
|
160
|
-
// synchronously emit events (e.g. a final message_update as the stream
|
|
161
|
-
// unwinds)
|
|
162
|
-
// ensures those abort-caused events are accounted for, so the quiescence
|
|
137
|
+
barrier.noteEvent();
|
|
138
|
+
// Fire the abort before recording the cancellation point. session.abort()
|
|
139
|
+
// may synchronously emit events (e.g. a final message_update as the stream
|
|
140
|
+
// unwinds); recording after the call attributes those to the abort, so the
|
|
163
141
|
// barrier's re-abort check doesn't loop on the abort's own events.
|
|
164
142
|
void session.abort().catch((error: unknown) => {
|
|
165
143
|
logFailure("agent cancellation", error);
|
|
166
144
|
});
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
abortRequestedGeneration = sessionEventGeneration;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Wait until the session is idle and non-compacting for two unchanged event
|
|
177
|
-
* loop turns. The quiet turns are significant: AgentSession emits
|
|
178
|
-
* compaction_end before ctx.compact's onComplete/onError callback runs, and a
|
|
179
|
-
* successful callback may immediately start a continuation prompt.
|
|
180
|
-
*
|
|
181
|
-
* Cancellation re-abort: if the task has been cancelled (parent abort or
|
|
182
|
-
* stall), any session event after the last cancellation request means new
|
|
183
|
-
* work started — typically a continuation from an extension's onComplete
|
|
184
|
-
* callback, possibly delayed by async auth. The barrier re-aborts to cancel
|
|
185
|
-
* it. This check runs *before* the idle check so a fast continuation that
|
|
186
|
-
* already completed between samples is still caught: the generation changed
|
|
187
|
-
* even though the session is idle again, and re-abort resets the tracker to
|
|
188
|
-
* catch any further continuations.
|
|
189
|
-
*
|
|
190
|
-
* Cancelled grace period: when cancelled and idle, a single 50 ms wait is
|
|
191
|
-
* required before quiet turns can accumulate. Without it, a continuation
|
|
192
|
-
* delayed by async auth or another extension handler can start after the
|
|
193
|
-
* runner returns — the two event-loop turns pass in microseconds, far faster
|
|
194
|
-
* than any realistic async-auth gap. This is a **mitigation, not a
|
|
195
|
-
* deterministic fix**: a deterministic solution would require the host to
|
|
196
|
-
* expose pending extension work (e.g. `AgentSession.hasPendingExtensionWork()`)
|
|
197
|
-
* so the barrier could wait on it explicitly. The grace period adds at most
|
|
198
|
-
* one 50 ms wait per cancellation/re-abort cycle — re-aborts reset the
|
|
199
|
-
* `graceWaited` flag, so a sequence of delayed continuations can cause
|
|
200
|
-
* multiple grace waits.
|
|
201
|
-
*/
|
|
202
|
-
const waitForSessionQuiescence = async (): Promise<void> => {
|
|
203
|
-
const cancelledGraceMs = 50;
|
|
204
|
-
const cancellationRequested = () =>
|
|
205
|
-
signal?.aborted || stalled || deadlineExceeded;
|
|
206
|
-
const cancellationSource = (): "parent-aborted" | "stalled" | "deadline" =>
|
|
207
|
-
signal?.aborted
|
|
208
|
-
? "parent-aborted"
|
|
209
|
-
: deadlineExceeded
|
|
210
|
-
? "deadline"
|
|
211
|
-
: "stalled";
|
|
212
|
-
let quietTurns = 0;
|
|
213
|
-
let graceWaited = false;
|
|
214
|
-
while (quietTurns < 2) {
|
|
215
|
-
const generation = sessionEventGeneration;
|
|
216
|
-
await nextEventLoopTurn();
|
|
217
|
-
|
|
218
|
-
// Re-abort if new activity started after the last cancellation request.
|
|
219
|
-
// This runs before the idle check so a fast continuation that completed
|
|
220
|
-
// between samples (generation changed, session idle again) is still
|
|
221
|
-
// caught. After re-aborting, restart the loop to recompute isIdle/
|
|
222
|
-
// isCompacting rather than falling through to the 250 ms event probe.
|
|
223
|
-
if (
|
|
224
|
-
cancellationRequested() &&
|
|
225
|
-
abortRequestedGeneration >= 0 &&
|
|
226
|
-
sessionEventGeneration !== abortRequestedGeneration
|
|
227
|
-
) {
|
|
228
|
-
requestSessionCancellation(cancellationSource());
|
|
229
|
-
quietTurns = 0;
|
|
230
|
-
graceWaited = false;
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const idle = session.isIdle && !session.isCompacting;
|
|
235
|
-
if (idle && sessionEventGeneration === generation) {
|
|
236
|
-
if (cancellationRequested() && !graceWaited) {
|
|
237
|
-
// Wait once for a grace period before accepting quiet turns. A
|
|
238
|
-
// continuation delayed by async auth can start after the immediate
|
|
239
|
-
// microtask batch settles. This is a single setTimeout, not a
|
|
240
|
-
// busy-spin — the event loop is free to process the continuation's
|
|
241
|
-
// events during the wait.
|
|
242
|
-
graceWaited = true;
|
|
243
|
-
await new Promise<void>((resolve) =>
|
|
244
|
-
setTimeout(resolve, cancelledGraceMs),
|
|
245
|
-
);
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
quietTurns++;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
quietTurns = 0;
|
|
253
|
-
graceWaited = false;
|
|
254
|
-
if (!idle) {
|
|
255
|
-
await waitForSessionEventAfter(sessionEventGeneration);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
145
|
+
// Any session event after this point means new work started despite the
|
|
146
|
+
// abort (e.g. a continuation prompt from an extension's onComplete
|
|
147
|
+
// callback delayed by async auth). The barrier re-aborts it rather than
|
|
148
|
+
// letting it mutate files after the task is considered cancelled.
|
|
149
|
+
barrier.noteCancellationRequested();
|
|
258
150
|
};
|
|
259
151
|
|
|
260
152
|
// Snapshot cumulative usage before the prompt so we can report only the
|
|
@@ -527,7 +419,7 @@ export async function runAgentSession(
|
|
|
527
419
|
// verbatim. Retry and compaction events are handled below; queue/bookkeeping
|
|
528
420
|
// events and thinking changes are intentionally ignored.
|
|
529
421
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
530
|
-
|
|
422
|
+
barrier.noteEvent();
|
|
531
423
|
switch (event.type) {
|
|
532
424
|
case "tool_execution_start": {
|
|
533
425
|
const now = Date.now();
|