@bermudi/pi-delegate 0.1.17 → 0.1.19

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/pool.ts CHANGED
@@ -9,7 +9,7 @@ import { fmtDuration, fmtTokens, shortenPath } from "./format.ts";
9
9
  // ── Public value types (cross the seam) ───────────────────────────────────
10
10
 
11
11
  /** Immutable config captured when a session enters the pool. Write-once: once
12
- * stored it never changes — only stats mutate, and only via {@link commit}.
12
+ * stored it never changes — only stats mutate, and only via {@link SessionPool.commit}.
13
13
  * Reuse always validates cwd/thinking/tools and validates an explicitly
14
14
  * requested model or base prompt against this frozen configuration. */
15
15
  export interface FrozenConfig {
@@ -51,7 +51,7 @@ export interface ConfigMismatch {
51
51
  requested: string;
52
52
  }
53
53
 
54
- /** Result of {@link checkout}. Discriminated so the caller handles
54
+ /** Result of {@link SessionPool.checkout}. Discriminated so the caller handles
55
55
  * hit/miss/mismatch without parsing. checkout is PURE — it does not bump
56
56
  * lastUsed (that is commit's job), so a speculative checkout that bails leaves
57
57
  * no trace. */
@@ -67,7 +67,7 @@ export type CheckoutResult =
67
67
  | { status: "miss" }
68
68
  | { status: "mismatch"; mismatches: ConfigMismatch[] };
69
69
 
70
- /** Payload for {@link commit}. The caller always assembles the full payload on
70
+ /** Payload for {@link SessionPool.commit}. The caller always assembles the full payload on
71
71
  * a successful run; commit decides insert-vs-recordUse by map presence (sound
72
72
  * because the per-session lock serializes same-sessionId tasks). */
73
73
  export interface CommitPayload {
@@ -78,8 +78,6 @@ export interface CommitPayload {
78
78
  tokens: number;
79
79
  }
80
80
 
81
- // ── Internal state (PRIVATE — callers cross the seam, never the Map) ───────
82
-
83
81
  interface PooledAgent {
84
82
  /** The live AgentSession — reused across prompts for this sessionId. */
85
83
  session: AgentSession;
@@ -95,497 +93,563 @@ interface PooledAgent {
95
93
  promptCount: number;
96
94
  }
97
95
 
98
- /** Module-level pool — lives for the entire Pi session. Not exported: callers
99
- * use checkout/commit/configFor/closePooledAgent/closeAllPooledAgents/listPooledAgents. */
100
- const agentPool = new Map<string, PooledAgent>();
101
-
102
- // AgentSession.abort() normally settles promptly, but a provider or tool can
103
- // ignore cancellation. Cleanup must not make parent-session shutdown hostage to
104
- // that promise, so close operations use a bounded wait before forced disposal.
105
- const DEFAULT_POOL_ABORT_TIMEOUT_MS = 10_000;
106
- let poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
107
-
108
- /** Per-session lock — serializes access to a pooled agent so concurrent
109
- * delegate calls with the same sessionId queue instead of interleaving. */
110
- const sessionLocks = new Map<string, Promise<void>>();
111
-
112
96
  type PoolShutdownState = "open" | "closing" | "closed";
113
- let poolState: PoolShutdownState = "open";
114
- let closePromise: Promise<void> | null = null;
115
97
 
116
- function now(): number {
117
- return Date.now();
98
+ interface AbortOutcome {
99
+ error?: unknown;
100
+ timedOut?: boolean;
118
101
  }
119
102
 
120
- function assertPoolOpenForNormalWork(): void {
121
- if (poolState !== "open") {
122
- throw new Error("Session pool is not accepting new work.");
103
+ /** Encapsulated pooled-session state and policy. Each instance owns its own
104
+ * entries, per-session locks, shutdown state/promise, and abort timeout so
105
+ * injected runtimes are fully isolated from one another and from the default
106
+ * module-level pool. */
107
+ export class SessionPool {
108
+ static readonly DEFAULT_ABORT_TIMEOUT_MS = 10_000;
109
+
110
+ private entries = new Map<string, PooledAgent>();
111
+ private readonly sessionLocks = new Map<string, Promise<void>>();
112
+ private state: PoolShutdownState = "open";
113
+ private closePromise: Promise<void> | null = null;
114
+ private abortTimeoutMs = SessionPool.DEFAULT_ABORT_TIMEOUT_MS;
115
+ private quarantineWithoutDisposalOverride:
116
+ ((sessionId: string, expectedSession: AgentSession) => boolean) | undefined;
117
+
118
+ private now(): number {
119
+ return Date.now();
123
120
  }
124
- }
125
121
 
126
- function waitForActiveSessionLocks(): Promise<void> {
127
- const locks = [...sessionLocks.values()];
128
- return Promise.all(locks).then(() => undefined);
129
- }
130
-
131
- function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
132
- if (typeof value !== "object" || value === null || seen.has(value)) {
133
- return value;
122
+ private assertPoolOpenForNormalWork(): void {
123
+ if (this.state !== "open") {
124
+ throw new Error("Session pool is not accepting new work.");
125
+ }
134
126
  }
135
- seen.add(value);
136
- const record = value as Record<PropertyKey, unknown>;
137
- for (const key of Reflect.ownKeys(value)) deepFreeze(record[key], seen);
138
- return Object.freeze(value);
139
- }
140
-
141
- /** Defensive value copy for the capability configuration crossing the pool seam. */
142
- function cloneFrozenConfig(config: FrozenConfig): FrozenConfig {
143
- return deepFreeze(structuredClone(config));
144
- }
145
127
 
146
- // ── Read + validate ───────────────────────────────────────────────────────
147
-
148
- /** Look up a pooled session and validate a reuse request against its frozen
149
- * config. PURE: no lastUsed bump, no sweep — safe to call speculatively, even
150
- * outside the session lock (the frozen config is write-once, so a concurrent
151
- * commit cannot tear the read).
152
- *
153
- * - hit → pooled, and {cwd,thinking,tools} match frozen. Returns the live
154
- * handles + the frozen model id.
155
- * - miss → not pooled (never inserted, closed, or parent session ended).
156
- * Caller materializes.
157
- * - mismatch → pooled but its immutable configuration conflicts with this
158
- * request. Caller formats the structured diff into an error.
159
- *
160
- * lastUsed is bumped by recordUse() after a completed pool hit, not here — so
161
- * a checkout that bails (e.g. a resumeFrom conflict) does not affect stats. */
162
- export function checkout(
163
- sessionId: string,
164
- candidate: ConfigCandidate,
165
- ): CheckoutResult {
166
- const pooled = agentPool.get(sessionId);
167
- if (!pooled) return { status: "miss" };
168
-
169
- const frozen = pooled.config;
170
- const mismatches: ConfigMismatch[] = [];
171
- if (frozen.cwd !== candidate.cwd) {
172
- mismatches.push({
173
- field: "cwd",
174
- frozen: frozen.cwd,
175
- requested: candidate.cwd,
176
- });
177
- }
178
- if (frozen.thinking !== candidate.thinking) {
179
- mismatches.push({
180
- field: "thinking",
181
- frozen: frozen.thinking,
182
- requested: candidate.thinking,
183
- });
184
- }
185
- // Tools are compared as order-independent sets (sorted join), so a reuse that
186
- // lists the same tools in a different order is not a false mismatch.
187
- const frozenTools = [...frozen.tools].sort().join(",");
188
- const requestedTools = [...candidate.tools].sort().join(",");
189
- if (frozenTools !== requestedTools) {
190
- mismatches.push({
191
- field: "tools",
192
- frozen: frozenTools,
193
- requested: requestedTools,
194
- });
195
- }
196
- if (
197
- candidate.model &&
198
- (frozen.model.provider !== candidate.model.provider ||
199
- frozen.model.id !== candidate.model.id)
200
- ) {
201
- mismatches.push({
202
- field: "model",
203
- frozen: `${frozen.model.provider}/${frozen.model.id}`,
204
- requested: `${candidate.model.provider}/${candidate.model.id}`,
205
- });
128
+ private waitForActiveSessionLocks(): Promise<void> {
129
+ const locks = [...this.sessionLocks.values()];
130
+ return Promise.all(locks).then(() => undefined);
206
131
  }
207
- if (
208
- candidate.systemPrompt !== undefined &&
209
- frozen.systemPrompt !== candidate.systemPrompt
210
- ) {
211
- // Prompts can be large and sensitive; callers need the conflicting field,
212
- // not the instruction text in their tool-result context.
213
- mismatches.push({
214
- field: "systemPrompt",
215
- frozen: "<frozen>",
216
- requested: "<requested>",
217
- });
218
- }
219
- if (frozen.providerExtensions !== candidate.providerExtensions) {
220
- // This field is derived from configured package sources, which may contain
221
- // credentials. Never expose either the sources or their digest through the
222
- // public checkout result; a digest can still enable dictionary guessing.
223
- mismatches.push({
224
- field: "providerExtensions",
225
- frozen: "<redacted>",
226
- requested: "<redacted>",
227
- });
132
+
133
+ private deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
134
+ if (typeof value !== "object" || value === null || seen.has(value)) {
135
+ return value;
136
+ }
137
+ seen.add(value);
138
+ const record = value as Record<PropertyKey, unknown>;
139
+ for (const key of Reflect.ownKeys(value))
140
+ this.deepFreeze(record[key], seen);
141
+ return Object.freeze(value);
228
142
  }
229
- if (mismatches.length) return { status: "mismatch", mismatches };
230
-
231
- return {
232
- status: "hit",
233
- session: pooled.session,
234
- sessionManager: pooled.sessionManager,
235
- sessionFile: pooled.sessionFile,
236
- modelId: frozen.model.id,
237
- };
238
- }
239
143
 
240
- // ── The sole mutator (besides close) ──────────────────────────────────────
144
+ /** Defensive value copy for the capability configuration crossing the pool seam. */
145
+ private cloneFrozenConfig(config: FrozenConfig): FrozenConfig {
146
+ return this.deepFreeze(structuredClone(config));
147
+ }
241
148
 
242
- /**
243
- * Insert the first successful prompt for a fresh/resumed session into the pool.
244
- *
245
- * MUST be called inside withSessionLock(sessionId, …) and only when this run
246
- * should transfer ownership from lifecycle to the pool. Returns true when the
247
- * session is now pool-owned, false when insertion is blocked (shutdown) or
248
- * impossible (missing manager/file).
249
- */
250
- export function commit(sessionId: string, payload: CommitPayload): boolean {
251
- // Shutdown-aware policy: once shutdown has started, inserting a new pool entry
252
- // is unsafe. The lifecycle handles the still-owned session (abort/cleanup) and
253
- // should dispose it instead of inserting it after the barrier begins.
254
- if (poolState !== "open") {
255
- return false;
149
+ /** Look up a pooled session and validate a reuse request against its frozen
150
+ * config. PURE: no lastUsed bump, no sweep safe to call speculatively, even
151
+ * outside the session lock (the frozen config is write-once, so a concurrent
152
+ * commit cannot tear the read).
153
+ *
154
+ * - hit → pooled, and {cwd,thinking,tools} match frozen. Returns the live
155
+ * handles + the frozen model id.
156
+ * - miss → not pooled (never inserted, closed, or parent session ended).
157
+ * Caller materializes.
158
+ * - mismatch pooled but its immutable configuration conflicts with this
159
+ * request. Caller formats the structured diff into an error.
160
+ *
161
+ * lastUsed is bumped by recordUse() after a completed pool hit, not here — so
162
+ * a checkout that bails (e.g. a resumeFrom conflict) does not affect stats. */
163
+ checkout(sessionId: string, candidate: ConfigCandidate): CheckoutResult {
164
+ const pooled = this.entries.get(sessionId);
165
+ if (!pooled) return { status: "miss" };
166
+
167
+ const frozen = pooled.config;
168
+ const mismatches: ConfigMismatch[] = [];
169
+ if (frozen.cwd !== candidate.cwd) {
170
+ mismatches.push({
171
+ field: "cwd",
172
+ frozen: frozen.cwd,
173
+ requested: candidate.cwd,
174
+ });
175
+ }
176
+ if (frozen.thinking !== candidate.thinking) {
177
+ mismatches.push({
178
+ field: "thinking",
179
+ frozen: frozen.thinking,
180
+ requested: candidate.thinking,
181
+ });
182
+ }
183
+ // Tools are compared as order-independent sets (sorted join), so a reuse that
184
+ // lists the same tools in a different order is not a false mismatch.
185
+ const frozenTools = [...frozen.tools].sort().join(",");
186
+ const requestedTools = [...candidate.tools].sort().join(",");
187
+ if (frozenTools !== requestedTools) {
188
+ mismatches.push({
189
+ field: "tools",
190
+ frozen: frozenTools,
191
+ requested: requestedTools,
192
+ });
193
+ }
194
+ if (
195
+ candidate.model &&
196
+ (frozen.model.provider !== candidate.model.provider ||
197
+ frozen.model.id !== candidate.model.id)
198
+ ) {
199
+ mismatches.push({
200
+ field: "model",
201
+ frozen: `${frozen.model.provider}/${frozen.model.id}`,
202
+ requested: `${candidate.model.provider}/${candidate.model.id}`,
203
+ });
204
+ }
205
+ if (
206
+ candidate.systemPrompt !== undefined &&
207
+ frozen.systemPrompt !== candidate.systemPrompt
208
+ ) {
209
+ // Prompts can be large and sensitive; callers need the conflicting field,
210
+ // not the instruction text in their tool-result context.
211
+ mismatches.push({
212
+ field: "systemPrompt",
213
+ frozen: "<frozen>",
214
+ requested: "<requested>",
215
+ });
216
+ }
217
+ if (frozen.providerExtensions !== candidate.providerExtensions) {
218
+ // This field is derived from configured package sources, which may contain
219
+ // credentials. Never expose either the sources or their digest through the
220
+ // public checkout result; a digest can still enable dictionary guessing.
221
+ mismatches.push({
222
+ field: "providerExtensions",
223
+ frozen: "<redacted>",
224
+ requested: "<redacted>",
225
+ });
226
+ }
227
+ if (mismatches.length) return { status: "mismatch", mismatches };
228
+
229
+ return {
230
+ status: "hit",
231
+ session: pooled.session,
232
+ sessionManager: pooled.sessionManager,
233
+ sessionFile: pooled.sessionFile,
234
+ modelId: frozen.model.id,
235
+ };
256
236
  }
257
237
 
258
- // Miss → success: insert. A pooled entry needs a concrete file/manager.
259
- if (!payload.sessionManager || !payload.sessionFile) return false;
260
-
261
- if (agentPool.has(sessionId)) return false;
262
- agentPool.set(sessionId, {
263
- session: payload.session,
264
- sessionManager: payload.sessionManager,
265
- sessionFile: payload.sessionFile,
266
- config: cloneFrozenConfig(payload.frozen),
267
- lastUsed: now(),
268
- createdAt: now(),
269
- totalTokens: payload.tokens,
270
- promptCount: 1,
271
- });
272
- return true;
273
- }
238
+ /**
239
+ * Insert the first successful prompt for a fresh/resumed session into the pool.
240
+ *
241
+ * MUST be called inside withSessionLock(sessionId, …) and only when this run
242
+ * should transfer ownership from lifecycle to the pool. Returns true when the
243
+ * session is now pool-owned, false when insertion is blocked (shutdown) or
244
+ * impossible (missing manager/file).
245
+ */
246
+ commit(sessionId: string, payload: CommitPayload): boolean {
247
+ // Shutdown-aware policy: once shutdown has started, inserting a new pool entry
248
+ // is unsafe. The lifecycle handles the still-owned session (abort/cleanup) and
249
+ // should dispose it instead of inserting it after the barrier begins.
250
+ if (this.state !== "open") {
251
+ return false;
252
+ }
274
253
 
275
- /** Record a completed run against an existing pooled session. This is separate
276
- * from commit() so lifecycle can distinguish ownership transfer from hit
277
- * accounting. Returns true only when the sessionId is currently pooled.
278
- */
279
- export function recordUse(sessionId: string, tokens: number): boolean {
280
- const existing = agentPool.get(sessionId);
281
- if (!existing) return false;
254
+ // Miss success: insert. A pooled entry needs a concrete file/manager.
255
+ if (!payload.sessionManager || !payload.sessionFile) return false;
256
+
257
+ if (this.entries.has(sessionId)) return false;
258
+ this.entries.set(sessionId, {
259
+ session: payload.session,
260
+ sessionManager: payload.sessionManager,
261
+ sessionFile: payload.sessionFile,
262
+ config: this.cloneFrozenConfig(payload.frozen),
263
+ lastUsed: this.now(),
264
+ createdAt: this.now(),
265
+ totalTokens: payload.tokens,
266
+ promptCount: 1,
267
+ });
268
+ return true;
269
+ }
282
270
 
283
- existing.lastUsed = now();
284
- existing.totalTokens += tokens;
285
- existing.promptCount++;
286
- return true;
287
- }
271
+ /** Record a completed run against an existing pooled session. This is separate
272
+ * from commit() so lifecycle can distinguish ownership transfer from hit
273
+ * accounting. Returns true only when the sessionId is currently pooled. */
274
+ recordUse(sessionId: string, tokens: number): boolean {
275
+ const existing = this.entries.get(sessionId);
276
+ if (!existing) return false;
277
+
278
+ existing.lastUsed = this.now();
279
+ existing.totalTokens += tokens;
280
+ existing.promptCount++;
281
+ return true;
282
+ }
288
283
 
289
- /** Remove an exact live session from reuse without aborting or disposing it.
290
- * Lifecycle uses this only after runner reports quiescence abandonment: any
291
- * ordinary pool close would race provider/extension work that may still be
292
- * running. The caller retains the detached session until its background safety
293
- * promise resolves. */
294
- export function _quarantinePooledAgentWithoutDisposal(
295
- sessionId: string,
296
- expectedSession: AgentSession,
297
- ): boolean {
298
- const existing = agentPool.get(sessionId);
299
- if (!existing || existing.session !== expectedSession) return false;
300
- agentPool.delete(sessionId);
301
- return true;
302
- }
284
+ /** Remove an exact live session from reuse without aborting or disposing it.
285
+ * Lifecycle uses this only after runner reports quiescence abandonment: any
286
+ * ordinary pool close would race provider/extension work that may still be
287
+ * running. The caller retains the detached session until its background safety
288
+ * promise resolves. */
289
+ quarantinePooledAgentWithoutDisposal(
290
+ sessionId: string,
291
+ expectedSession: AgentSession,
292
+ ): boolean {
293
+ if (this.quarantineWithoutDisposalOverride) {
294
+ return this.quarantineWithoutDisposalOverride(sessionId, expectedSession);
295
+ }
296
+ const existing = this.entries.get(sessionId);
297
+ if (!existing || existing.session !== expectedSession) return false;
298
+ this.entries.delete(sessionId);
299
+ return true;
300
+ }
303
301
 
304
- // ── Read-only defaults (for task-resolution) ──────────────────────────────
302
+ /** @internal Test-only override for the quarantine-without-disposal path. */
303
+ _setQuarantinePooledAgentWithoutDisposalForTesting(
304
+ override:
305
+ | ((sessionId: string, expectedSession: AgentSession) => boolean)
306
+ | undefined,
307
+ ): void {
308
+ this.quarantineWithoutDisposalOverride = override;
309
+ }
305
310
 
306
- /** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
307
- * safe because the stored config is a deeply frozen defensive copy. Callers
308
- * receive another frozen copy so values crossing the pool seam cannot mutate
309
- * its capability contract. Used by resolveTasks to default {systemPrompt,
310
- * model, thinking, tools} for a task that supplies only a sessionId. */
311
- export function configFor(
312
- sessionId: string,
313
- ): Readonly<FrozenConfig> | undefined {
314
- const config = agentPool.get(sessionId)?.config;
315
- return config ? cloneFrozenConfig(config) : undefined;
316
- }
311
+ /** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
312
+ * safe because the stored config is a deeply frozen defensive copy. Callers
313
+ * receive another frozen copy so values crossing the pool seam cannot mutate
314
+ * its capability contract. Used by resolveTasks to default {systemPrompt,
315
+ * model, thinking, tools} for a task that supplies only a sessionId. */
316
+ configFor(sessionId: string): Readonly<FrozenConfig> | undefined {
317
+ const config = this.entries.get(sessionId)?.config;
318
+ return config ? this.cloneFrozenConfig(config) : undefined;
319
+ }
317
320
 
318
- // ── Lock primitive (D1) ───────────────────────────────────────────────────
319
-
320
- /** Per-session lock serializes concurrent calls with the same sessionId.
321
- * Different ids run in parallel. Exported as a primitive so the caller
322
- * (lifecycle) can bracket the ENTIRE acquire/run/commit flow; checkout/commit
323
- * do NOT lock internally because their only caller is already inside this
324
- * bracket. Close is also invoked under this lock, so it never disposes an
325
- * in-flight prompt.
326
- *
327
- * External callers must go through this exported path only while the pool is
328
- * open. Internal callers that are already synchronized by a higher-level lock
329
- * can use `withSessionLockInternal`.
330
- */
331
- export async function withSessionLock<T>(
332
- sessionId: string,
333
- fn: () => Promise<T>,
334
- ): Promise<T> {
335
- assertPoolOpenForNormalWork();
336
- return withSessionLockInternal(sessionId, fn);
337
- }
321
+ /** Per-session lock serializes concurrent calls with the same sessionId.
322
+ * Different ids run in parallel. Exported as a primitive so the caller
323
+ * (lifecycle) can bracket the ENTIRE acquire/run/commit flow; checkout/commit
324
+ * do NOT lock internally because their only caller is already inside this
325
+ * bracket. Close is also invoked under this lock, so it never disposes an
326
+ * in-flight prompt.
327
+ *
328
+ * External callers must go through this exported path only while the pool is
329
+ * open. Internal callers that are already synchronized by a higher-level lock
330
+ * can use `withSessionLockInternal`.
331
+ */
332
+ withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
333
+ this.assertPoolOpenForNormalWork();
334
+ return this.withSessionLockInternal(sessionId, fn);
335
+ }
338
336
 
339
- /** Internal lock variant that does not reject during shutdown. Use only from
340
- * lifecycle/control paths that already account for pool shutdown state.
341
- */
342
- async function withSessionLockInternal<T>(
343
- sessionId: string,
344
- fn: () => Promise<T>,
345
- ): Promise<T> {
346
- const prev = sessionLocks.get(sessionId);
347
- let resolve!: () => void;
348
- const promise = new Promise<void>((r) => {
349
- resolve = r;
350
- });
351
- // Install ourselves BEFORE awaiting — so the next waiter queues behind us,
352
- // not behind the same predecessor we're waiting on.
353
- sessionLocks.set(sessionId, promise);
354
- try {
355
- if (prev) await prev;
356
- return await fn();
357
- } finally {
358
- resolve();
359
- // Only clean up if no one queued behind us (our promise is still current).
360
- // If a waiter installed their own promise, leave it deleting would
361
- // clobber their map entry and break the chain.
362
- if (sessionLocks.get(sessionId) === promise) {
363
- sessionLocks.delete(sessionId);
337
+ /** Internal lock variant that does not reject during shutdown. Use only from
338
+ * lifecycle/control paths that already account for pool shutdown state. */
339
+ private async withSessionLockInternal<T>(
340
+ sessionId: string,
341
+ fn: () => Promise<T>,
342
+ ): Promise<T> {
343
+ const prev = this.sessionLocks.get(sessionId);
344
+ let resolve!: () => void;
345
+ const promise = new Promise<void>((r) => {
346
+ resolve = r;
347
+ });
348
+ // Install ourselves BEFORE awaiting — so the next waiter queues behind us,
349
+ // not behind the same predecessor we’re waiting on.
350
+ this.sessionLocks.set(sessionId, promise);
351
+ try {
352
+ if (prev) await prev;
353
+ return await fn();
354
+ } finally {
355
+ resolve();
356
+ // Only clean up if no one queued behind us (our promise is still current).
357
+ // If a waiter installed their own promise, leave it deleting would
358
+ // clobber their map entry and break the chain.
359
+ if (this.sessionLocks.get(sessionId) === promise) {
360
+ this.sessionLocks.delete(sessionId);
361
+ }
364
362
  }
365
363
  }
366
- }
367
364
 
368
- // ── Teardown / display ────────────────────────────────────────────────────
369
-
370
- interface AbortOutcome {
371
- error?: unknown;
372
- timedOut?: boolean;
373
- }
374
-
375
- /** Start cancellation without letting a cleanup failure become unhandled while
376
- * a holder of the per-session lock is still unwinding. A wedged provider/tool
377
- * cannot keep disposal or parent shutdown waiting forever. */
378
- function beginAbort(session: AgentSession): Promise<AbortOutcome> {
379
- return new Promise((resolve) => {
380
- let settled = false;
381
- let timer: ReturnType<typeof setTimeout> | undefined;
382
- const finish = (outcome: AbortOutcome): void => {
383
- if (settled) return;
384
- settled = true;
385
- if (timer !== undefined) clearTimeout(timer);
386
- resolve(outcome);
387
- };
365
+ /** Start cancellation without letting a cleanup failure become unhandled while
366
+ * a holder of the per-session lock is still unwinding. A wedged provider/tool
367
+ * cannot keep disposal or parent shutdown waiting forever. */
368
+ private beginAbort(session: AgentSession): Promise<AbortOutcome> {
369
+ return new Promise((resolve) => {
370
+ let settled = false;
371
+ let timer: ReturnType<typeof setTimeout> | undefined;
372
+ const finish = (outcome: AbortOutcome): void => {
373
+ if (settled) return;
374
+ settled = true;
375
+ if (timer !== undefined) clearTimeout(timer);
376
+ resolve(outcome);
377
+ };
378
+
379
+ timer = setTimeout(() => finish({ timedOut: true }), this.abortTimeoutMs);
380
+ try {
381
+ void session.abort().then(
382
+ () => finish({}),
383
+ (error: unknown) => finish({ error }),
384
+ );
385
+ } catch (error) {
386
+ finish({ error });
387
+ }
388
+ });
389
+ }
388
390
 
389
- timer = setTimeout(() => finish({ timedOut: true }), poolAbortTimeoutMs);
391
+ /** Close and dispose one pooled session. A caller holds the per-session lock
392
+ * while closing, so abort cannot race a reuse. All cleanup is attempted before
393
+ * an error is surfaced; a removed session is never silently retained. */
394
+ private async closePooledAgentAfterAbort(
395
+ sessionId: string,
396
+ abort: Promise<AbortOutcome>,
397
+ ): Promise<boolean> {
398
+ const pooled = this.entries.get(sessionId);
399
+ if (!pooled) return false;
400
+
401
+ const failures: unknown[] = [];
402
+ let abortOutcome: AbortOutcome;
390
403
  try {
391
- void session.abort().then(
392
- () => finish({}),
393
- (error: unknown) => finish({ error }),
404
+ abortOutcome = await abort;
405
+ } catch (error) {
406
+ // beginAbort currently normalizes rejection, but keep the teardown seam
407
+ // fail-closed if a future caller supplies a raw abort promise.
408
+ failures.push(error);
409
+ abortOutcome = {};
410
+ }
411
+ if (abortOutcome.error !== undefined) failures.push(abortOutcome.error);
412
+ if (abortOutcome.timedOut) {
413
+ failures.push(
414
+ new Error(
415
+ `Timed out after ${this.abortTimeoutMs}ms waiting for session '${sessionId}' to abort.`,
416
+ ),
394
417
  );
418
+ }
419
+ try {
420
+ pooled.session.dispose();
395
421
  } catch (error) {
396
- finish({ error });
422
+ failures.push(error);
397
423
  }
398
- });
399
- }
424
+ if (this.entries.get(sessionId) === pooled) this.entries.delete(sessionId);
400
425
 
401
- /** Close and dispose one pooled session. A caller holds the per-session lock
402
- * while closing, so abort cannot race a reuse. All cleanup is attempted before
403
- * an error is surfaced; a removed session is never silently retained. */
404
- async function closePooledAgentAfterAbort(
405
- sessionId: string,
406
- abort: Promise<AbortOutcome>,
407
- ): Promise<boolean> {
408
- const pooled = agentPool.get(sessionId);
409
- if (!pooled) return false;
410
-
411
- const failures: unknown[] = [];
412
- let abortOutcome: AbortOutcome;
413
- try {
414
- abortOutcome = await abort;
415
- } catch (error) {
416
- // beginAbort currently normalizes rejection, but keep the teardown seam
417
- // fail-closed if a future caller supplies a raw abort promise.
418
- failures.push(error);
419
- abortOutcome = {};
426
+ if (failures.length) {
427
+ throw new AggregateError(
428
+ failures,
429
+ `Failed to close pooled session '${sessionId}'.`,
430
+ );
431
+ }
432
+ return true;
420
433
  }
421
- if (abortOutcome.error !== undefined) failures.push(abortOutcome.error);
422
- if (abortOutcome.timedOut) {
423
- failures.push(
424
- new Error(
425
- `Timed out after ${poolAbortTimeoutMs}ms waiting for session '${sessionId}' to abort.`,
426
- ),
434
+
435
+ /** Internal close helper for callers that already own (or are obtaining) the
436
+ * session lock. */
437
+ private closePooledAgentAfterLock(sessionId: string): Promise<boolean> {
438
+ const pooled = this.entries.get(sessionId);
439
+ if (!pooled) return Promise.resolve(false);
440
+ return this.closePooledAgentAfterAbort(
441
+ sessionId,
442
+ this.beginAbort(pooled.session),
427
443
  );
428
444
  }
429
- try {
430
- pooled.session.dispose();
431
- } catch (error) {
432
- failures.push(error);
433
- }
434
- if (agentPool.get(sessionId) === pooled) agentPool.delete(sessionId);
435
445
 
436
- if (failures.length) {
437
- throw new AggregateError(
438
- failures,
439
- `Failed to close pooled session '${sessionId}'.`,
440
- );
446
+ /** @internal Close while the caller already owns the per-session lock. */
447
+ closePooledAgentWithoutLock(sessionId: string): Promise<boolean> {
448
+ return this.closePooledAgentAfterLock(sessionId);
441
449
  }
442
- return true;
443
- }
444
450
 
445
- /** Internal close helper for callers that already own (or are obtaining) the
446
- * session lock. */
447
- async function closePooledAgentAfterLock(sessionId: string): Promise<boolean> {
448
- const pooled = agentPool.get(sessionId);
449
- if (!pooled) return false;
450
- return closePooledAgentAfterAbort(sessionId, beginAbort(pooled.session));
451
- }
451
+ /** Abort, dispose, and remove one pooled session. Returns false when the id
452
+ * is already absent; cleanup failures are aggregated after removal.
453
+ * During shutdown or after close, this waits for shutdown to finish and
454
+ * returns `false` instead of throwing.
455
+ */
456
+ async closePooledAgent(sessionId: string): Promise<boolean> {
457
+ if (this.state !== "open") {
458
+ const existing = this.closePromise;
459
+ if (existing) {
460
+ try {
461
+ await existing;
462
+ } catch {
463
+ // Keep public close idempotent during and after shutdown.
464
+ }
465
+ }
466
+ return false;
467
+ }
452
468
 
453
- /** @internal Close while the caller already owns the per-session lock. */
454
- export async function _closePooledAgentWithoutLock(
455
- sessionId: string,
456
- ): Promise<boolean> {
457
- return closePooledAgentAfterLock(sessionId);
458
- }
469
+ let awaitShutdown: Promise<void> | undefined;
470
+ let closed = false;
459
471
 
460
- /** Abort, dispose, and remove one pooled session. Returns false when the id
461
- * is already absent; cleanup failures are aggregated after removal.
462
- * During shutdown or after close, this waits for shutdown to finish and
463
- * returns `false` instead of throwing.
464
- */
465
- export async function closePooledAgent(sessionId: string): Promise<boolean> {
466
- if (poolState !== "open") {
467
- const existing = closePromise;
468
- if (existing) {
472
+ await this.withSessionLockInternal(sessionId, async () => {
473
+ // A shutdown can begin while this call waits for its lock. Avoid waiting
474
+ // for closePromise inside this lock: do that work after releasing it, so
475
+ // closeAll can acquire the lock and dispose the same session.
476
+ if (this.state !== "open") {
477
+ awaitShutdown = this.closePromise ?? undefined;
478
+ return;
479
+ }
480
+ closed = await this.closePooledAgentAfterLock(sessionId);
481
+ });
482
+
483
+ if (awaitShutdown) {
469
484
  try {
470
- await existing;
485
+ await awaitShutdown;
471
486
  } catch {
472
487
  // Keep public close idempotent during and after shutdown.
473
488
  }
474
489
  }
475
- return false;
476
- }
477
490
 
478
- let awaitShutdown: Promise<void> | undefined;
479
- let closed = false;
491
+ return closed;
492
+ }
480
493
 
481
- await withSessionLockInternal(sessionId, async () => {
482
- // A shutdown can begin while this call waits for its lock. Avoid waiting
483
- // for closePromise inside this lock: do that work after releasing it, so
484
- // closeAll can acquire the lock and dispose the same session.
485
- if (poolState !== "open") {
486
- awaitShutdown = closePromise ?? undefined;
487
- return;
494
+ /** Dispose every live pooled session when the parent Pi session ends. First
495
+ * request cancellation immediately, then wait for all in-flight session locks,
496
+ * then acquire each remaining session's lock before disposal. Attempts are
497
+ * executed for all sessions before reporting failures. Idempotent callers share
498
+ * the same completion promise, including failures.
499
+ */
500
+ async closeAllPooledAgents(): Promise<void> {
501
+ if (this.closePromise) {
502
+ return this.closePromise;
488
503
  }
489
- closed = await closePooledAgentAfterLock(sessionId);
490
- });
491
-
492
- if (awaitShutdown) {
493
- try {
494
- await awaitShutdown;
495
- } catch {
496
- // Keep public close idempotent during and after shutdown.
504
+ if (this.state === "closed") {
505
+ const completed = Promise.resolve();
506
+ this.closePromise = completed;
507
+ return completed;
497
508
  }
509
+
510
+ this.state = "closing";
511
+
512
+ const completion = (async () => {
513
+ const aborts = new Map<string, Promise<AbortOutcome>>(
514
+ [...this.entries].map(([sessionId, pooled]) => [
515
+ sessionId,
516
+ this.beginAbort(pooled.session),
517
+ ]),
518
+ );
519
+
520
+ await this.waitForActiveSessionLocks();
521
+
522
+ const results = await Promise.allSettled(
523
+ [...aborts].map(([sessionId, abort]) =>
524
+ this.withSessionLockInternal(sessionId, () =>
525
+ this.closePooledAgentAfterAbort(sessionId, abort),
526
+ ),
527
+ ),
528
+ );
529
+ const failures = results
530
+ .filter(
531
+ (result): result is PromiseRejectedResult =>
532
+ result.status === "rejected",
533
+ )
534
+ .map((result) => result.reason);
535
+ if (failures.length) {
536
+ throw new AggregateError(
537
+ failures,
538
+ "Failed to close one or more pooled sessions.",
539
+ );
540
+ }
541
+ })();
542
+
543
+ this.closePromise = completion.finally(() => {
544
+ this.state = "closed";
545
+ });
546
+ return this.closePromise;
498
547
  }
499
548
 
500
- return closed;
501
- }
549
+ /** List live pooled agents. Sessions remain available until explicit close or
550
+ * parent-session shutdown; idle/age are observability statistics, not expiry. */
551
+ listPooledAgents(): string[] {
552
+ const lines: string[] = [];
553
+ if (this.entries.size === 0) return ["_(no active sessions)_"];
554
+ const t = this.now();
555
+ for (const [id, pooled] of this.entries) {
556
+ const idle = fmtDuration(t - pooled.lastUsed);
557
+ const age = fmtDuration(t - pooled.createdAt);
558
+ lines.push(
559
+ `- **${id}** · ${pooled.promptCount} prompts · ${fmtTokens(pooled.totalTokens)} tokens · idle ${idle} · age ${age} · ${shortenPath(pooled.sessionFile)}`,
560
+ );
561
+ }
562
+ return lines;
563
+ }
502
564
 
503
- /** Dispose every live pooled session when the parent Pi session ends. First
504
- * request cancellation immediately, then wait for all in-flight session locks,
505
- * then acquire each remaining session's lock before disposal. Attempts are
506
- * executed for all sessions before reporting failures. Idempotent callers share
507
- * the same completion promise, including failures.
508
- */
509
- export async function closeAllPooledAgents(): Promise<void> {
510
- if (closePromise) {
511
- return closePromise;
565
+ /** @internal Clear all pool state for test isolation. Tests use fake sessions,
566
+ * so no live resource teardown is needed here. */
567
+ resetForTesting(): void {
568
+ this.entries.clear();
569
+ this.sessionLocks.clear();
570
+ this.state = "open";
571
+ this.closePromise = null;
572
+ this.abortTimeoutMs = SessionPool.DEFAULT_ABORT_TIMEOUT_MS;
573
+ this.quarantineWithoutDisposalOverride = undefined;
512
574
  }
513
- if (poolState === "closed") {
514
- const completed = Promise.resolve();
515
- closePromise = completed;
516
- return completed;
575
+
576
+ /** @internal Override the abort wait in tests without waiting ten seconds. */
577
+ setAbortTimeoutForTesting(timeoutMs: number | undefined): void {
578
+ this.abortTimeoutMs = timeoutMs ?? SessionPool.DEFAULT_ABORT_TIMEOUT_MS;
517
579
  }
580
+ }
518
581
 
519
- poolState = "closing";
582
+ // ── Default module-level pool and compatibility wrappers ──────────────────
520
583
 
521
- const completion = (async () => {
522
- const aborts = new Map<string, Promise<AbortOutcome>>(
523
- [...agentPool].map(([sessionId, pooled]) => [
524
- sessionId,
525
- beginAbort(pooled.session),
526
- ]),
527
- );
584
+ /** Default pool instance used by the module-level wrappers and the default
585
+ * {@link getDefaultDelegateRuntime}. Exported so callers can build a
586
+ * {@link DelegateRuntime} that shares the same default pool. */
587
+ export const defaultSessionPool = new SessionPool();
528
588
 
529
- await waitForActiveSessionLocks();
589
+ export function checkout(
590
+ sessionId: string,
591
+ candidate: ConfigCandidate,
592
+ ): CheckoutResult {
593
+ return defaultSessionPool.checkout(sessionId, candidate);
594
+ }
530
595
 
531
- const results = await Promise.allSettled(
532
- [...aborts].map(([sessionId, abort]) =>
533
- withSessionLockInternal(sessionId, () =>
534
- closePooledAgentAfterAbort(sessionId, abort),
535
- ),
536
- ),
537
- );
538
- const failures = results
539
- .filter(
540
- (result): result is PromiseRejectedResult =>
541
- result.status === "rejected",
542
- )
543
- .map((result) => result.reason);
544
- if (failures.length) {
545
- throw new AggregateError(
546
- failures,
547
- "Failed to close one or more pooled sessions.",
548
- );
549
- }
550
- })();
596
+ export function commit(sessionId: string, payload: CommitPayload): boolean {
597
+ return defaultSessionPool.commit(sessionId, payload);
598
+ }
551
599
 
552
- closePromise = completion.finally(() => {
553
- poolState = "closed";
554
- });
555
- return closePromise;
600
+ export function recordUse(sessionId: string, tokens: number): boolean {
601
+ return defaultSessionPool.recordUse(sessionId, tokens);
602
+ }
603
+
604
+ export function configFor(
605
+ sessionId: string,
606
+ ): Readonly<FrozenConfig> | undefined {
607
+ return defaultSessionPool.configFor(sessionId);
608
+ }
609
+
610
+ export function withSessionLock<T>(
611
+ sessionId: string,
612
+ fn: () => Promise<T>,
613
+ ): Promise<T> {
614
+ return defaultSessionPool.withSessionLock(sessionId, fn);
615
+ }
616
+
617
+ export async function closePooledAgent(sessionId: string): Promise<boolean> {
618
+ return defaultSessionPool.closePooledAgent(sessionId);
619
+ }
620
+
621
+ export async function closeAllPooledAgents(): Promise<void> {
622
+ return defaultSessionPool.closeAllPooledAgents();
556
623
  }
557
624
 
558
- /** List live pooled agents. Sessions remain available until explicit close or
559
- * parent-session shutdown; idle/age are observability statistics, not expiry. */
560
625
  export function listPooledAgents(): string[] {
561
- const lines: string[] = [];
562
- if (agentPool.size === 0) return ["_(no active sessions)_"];
563
- const t = now();
564
- for (const [id, pooled] of agentPool) {
565
- const idle = fmtDuration(t - pooled.lastUsed);
566
- const age = fmtDuration(t - pooled.createdAt);
567
- lines.push(
568
- `- **${id}** · ${pooled.promptCount} prompts · ${fmtTokens(pooled.totalTokens)} tokens · idle ${idle} · age ${age} · ${shortenPath(pooled.sessionFile)}`,
569
- );
570
- }
571
- return lines;
626
+ return defaultSessionPool.listPooledAgents();
627
+ }
628
+
629
+ export function _quarantinePooledAgentWithoutDisposal(
630
+ sessionId: string,
631
+ expectedSession: AgentSession,
632
+ ): boolean {
633
+ return defaultSessionPool.quarantinePooledAgentWithoutDisposal(
634
+ sessionId,
635
+ expectedSession,
636
+ );
572
637
  }
573
638
 
574
- // ── Test seam (internal — imported directly by tests, not re-exported) ────
639
+ export function _closePooledAgentWithoutLock(
640
+ sessionId: string,
641
+ ): Promise<boolean> {
642
+ return defaultSessionPool.closePooledAgentWithoutLock(sessionId);
643
+ }
575
644
 
576
645
  /** @internal Override the abort wait in tests without waiting ten seconds. */
577
646
  export function _setPoolAbortTimeoutForTesting(
578
647
  timeoutMs: number | undefined,
579
648
  ): void {
580
- poolAbortTimeoutMs = timeoutMs ?? DEFAULT_POOL_ABORT_TIMEOUT_MS;
649
+ defaultSessionPool.setAbortTimeoutForTesting(timeoutMs);
581
650
  }
582
651
 
583
- /** @internal Clear all pool state for test isolation. Tests use fake sessions,
584
- * so no live resource teardown is needed here. */
652
+ /** @internal Clear all default pool state for test isolation. */
585
653
  export function _resetPoolForTesting(): void {
586
- agentPool.clear();
587
- sessionLocks.clear();
588
- poolState = "open";
589
- closePromise = null;
590
- poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
654
+ defaultSessionPool.resetForTesting();
591
655
  }