@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/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(). Keep a small
105
- // internal event seam so the runner can wait without polling throughout a
106
- // potentially long remote compaction. The generation closes the race between
107
- // checking session state and installing the next waiter.
108
- let abortRequestedGeneration = -1;
109
- const noteSessionEvent = () => {
110
- sessionEventGeneration++;
111
- const wake = wakeSessionEvent;
112
- wakeSessionEvent = undefined;
113
- wake?.();
114
- };
115
- const waitForSessionEventAfter = async (
116
- generation: number,
117
- ): Promise<void> => {
118
- if (sessionEventGeneration !== generation) return;
119
- await new Promise<void>((resolve) => {
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
- noteSessionEvent();
159
- // Fire the abort before recording the generation. session.abort() may
160
- // synchronously emit events (e.g. a final message_update as the stream
161
- // unwinds) that increment the generation. Recording after the call
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
- // Record the generation after abort is dispatched. If new session events
168
- // fire after this point (e.g. a continuation prompt started by an
169
- // extension's onComplete callback delayed by async auth), the quiescence
170
- // barrier re-aborts to cancel that continuation rather than letting it run
171
- // — and potentially mutate files — after the task is considered cancelled.
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
- noteSessionEvent();
422
+ barrier.noteEvent();
531
423
  switch (event.type) {
532
424
  case "tool_execution_start": {
533
425
  const now = Date.now();