@bermudi/pi-delegate 0.1.0

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 ADDED
@@ -0,0 +1,420 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type {
4
+ AgentSession,
5
+ SessionManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { fmtDuration, fmtTokens, shortenPath } from "./format.ts";
8
+
9
+ // ── Public value types (cross the seam) ───────────────────────────────────
10
+
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}.
13
+ * Reuse always validates cwd/thinking/tools and validates an explicitly
14
+ * requested model or base prompt against this frozen configuration. */
15
+ export interface FrozenConfig {
16
+ systemPrompt: string;
17
+ model: Model<Api>;
18
+ thinking: ThinkingLevel;
19
+ tools: string[];
20
+ cwd: string;
21
+ }
22
+
23
+ /** The subset a reuse request supplies for validation. `model` and
24
+ * `systemPrompt` are present only when this call explicitly requested them;
25
+ * omission means continue with the frozen live session configuration. */
26
+ export interface ConfigCandidate {
27
+ cwd: string;
28
+ thinking: ThinkingLevel;
29
+ tools: string[];
30
+ model?: Model<Api>;
31
+ systemPrompt?: string;
32
+ }
33
+
34
+ /** One field-level diff from a reuse that conflicts with the frozen config. The
35
+ * pool computes these; the caller formats the error string. */
36
+ export interface ConfigMismatch {
37
+ field: "cwd" | "thinking" | "tools" | "model" | "systemPrompt";
38
+ frozen: string;
39
+ requested: string;
40
+ }
41
+
42
+ /** Result of {@link checkout}. Discriminated so the caller handles
43
+ * hit/miss/mismatch without parsing. checkout is PURE — it does not bump
44
+ * lastUsed (that is commit's job), so a speculative checkout that bails leaves
45
+ * no trace. */
46
+ export type CheckoutResult =
47
+ | {
48
+ status: "hit";
49
+ session: AgentSession;
50
+ sessionManager: SessionManager;
51
+ sessionFile: string;
52
+ /** Frozen model id — caller uses it for the progress row / display. */
53
+ modelId: string;
54
+ }
55
+ | { status: "miss" }
56
+ | { status: "mismatch"; mismatches: ConfigMismatch[] };
57
+
58
+ /** Payload for {@link commit}. The caller always assembles the full payload on
59
+ * a successful run; commit decides insert-vs-recordUse by map presence (sound
60
+ * because the per-session lock serializes same-sessionId tasks). */
61
+ export interface CommitPayload {
62
+ session: AgentSession;
63
+ sessionManager: SessionManager | undefined;
64
+ sessionFile: string | undefined;
65
+ frozen: FrozenConfig;
66
+ tokens: number;
67
+ }
68
+
69
+ // ── Internal state (PRIVATE — callers cross the seam, never the Map) ───────
70
+
71
+ interface PooledAgent {
72
+ /** The live AgentSession — reused across prompts for this sessionId. */
73
+ session: AgentSession;
74
+ sessionManager: SessionManager;
75
+ sessionFile: string;
76
+ /** Config frozen at creation time — validated against on reuse. */
77
+ config: FrozenConfig;
78
+ lastUsed: number;
79
+ createdAt: number;
80
+ /** Total tokens consumed across all prompts on this session. */
81
+ totalTokens: number;
82
+ /** Number of prompts sent to this session. */
83
+ promptCount: number;
84
+ }
85
+
86
+ /** Module-level pool — lives for the entire Pi session. Not exported: callers
87
+ * use checkout/commit/configFor/closePooledAgent/closeAllPooledAgents/listPooledAgents. */
88
+ const agentPool = new Map<string, PooledAgent>();
89
+
90
+ // AgentSession.abort() normally settles promptly, but a provider or tool can
91
+ // ignore cancellation. Cleanup must not make parent-session shutdown hostage to
92
+ // that promise, so close operations use a bounded wait before forced disposal.
93
+ const DEFAULT_POOL_ABORT_TIMEOUT_MS = 10_000;
94
+ let poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
95
+
96
+ /** Per-session lock — serializes access to a pooled agent so concurrent
97
+ * delegate calls with the same sessionId queue instead of interleaving. */
98
+ const sessionLocks = new Map<string, Promise<void>>();
99
+
100
+ function now(): number {
101
+ return Date.now();
102
+ }
103
+
104
+ // ── Read + validate ───────────────────────────────────────────────────────
105
+
106
+ /** Look up a pooled session and validate a reuse request against its frozen
107
+ * config. PURE: no lastUsed bump, no sweep — safe to call speculatively, even
108
+ * outside the session lock (the frozen config is write-once, so a concurrent
109
+ * commit cannot tear the read).
110
+ *
111
+ * - hit → pooled, and {cwd,thinking,tools} match frozen. Returns the live
112
+ * handles + the frozen model id.
113
+ * - miss → not pooled (never inserted, closed, or parent session ended).
114
+ * Caller materializes.
115
+ * - mismatch → pooled but its immutable configuration conflicts with this
116
+ * request. Caller formats the structured diff into an error.
117
+ *
118
+ * lastUsed is bumped by commit() on a successful run, not here — so a checkout
119
+ * that bails (e.g. a resumeFrom conflict at the caller) does not affect stats. */
120
+ export function checkout(
121
+ sessionId: string,
122
+ candidate: ConfigCandidate,
123
+ ): CheckoutResult {
124
+ const pooled = agentPool.get(sessionId);
125
+ if (!pooled) return { status: "miss" };
126
+
127
+ const frozen = pooled.config;
128
+ const mismatches: ConfigMismatch[] = [];
129
+ if (frozen.cwd !== candidate.cwd) {
130
+ mismatches.push({
131
+ field: "cwd",
132
+ frozen: frozen.cwd,
133
+ requested: candidate.cwd,
134
+ });
135
+ }
136
+ if (frozen.thinking !== candidate.thinking) {
137
+ mismatches.push({
138
+ field: "thinking",
139
+ frozen: frozen.thinking,
140
+ requested: candidate.thinking,
141
+ });
142
+ }
143
+ // Tools are compared as order-independent sets (sorted join), so a reuse that
144
+ // lists the same tools in a different order is not a false mismatch.
145
+ const frozenTools = [...frozen.tools].sort().join(",");
146
+ const requestedTools = [...candidate.tools].sort().join(",");
147
+ if (frozenTools !== requestedTools) {
148
+ mismatches.push({
149
+ field: "tools",
150
+ frozen: frozenTools,
151
+ requested: requestedTools,
152
+ });
153
+ }
154
+ if (
155
+ candidate.model &&
156
+ (frozen.model.provider !== candidate.model.provider ||
157
+ frozen.model.id !== candidate.model.id)
158
+ ) {
159
+ mismatches.push({
160
+ field: "model",
161
+ frozen: `${frozen.model.provider}/${frozen.model.id}`,
162
+ requested: `${candidate.model.provider}/${candidate.model.id}`,
163
+ });
164
+ }
165
+ if (
166
+ candidate.systemPrompt !== undefined &&
167
+ frozen.systemPrompt !== candidate.systemPrompt
168
+ ) {
169
+ // Prompts can be large and sensitive; callers need the conflicting field,
170
+ // not the instruction text in their tool-result context.
171
+ mismatches.push({
172
+ field: "systemPrompt",
173
+ frozen: "<frozen>",
174
+ requested: "<requested>",
175
+ });
176
+ }
177
+ if (mismatches.length) return { status: "mismatch", mismatches };
178
+
179
+ return {
180
+ status: "hit",
181
+ session: pooled.session,
182
+ sessionManager: pooled.sessionManager,
183
+ sessionFile: pooled.sessionFile,
184
+ modelId: frozen.model.id,
185
+ };
186
+ }
187
+
188
+ // ── The sole mutator (besides close) ──────────────────────────────────────
189
+
190
+ /** Record the outcome of a run against a sessionId. Decides insert-vs-recordUse
191
+ * internally by map presence:
192
+ * - present (pool hit) → bump lastUsed, totalTokens, promptCount.
193
+ * - absent (fresh/resume success) → insert with the frozen config.
194
+ *
195
+ * MUST be called inside withSessionLock(sessionId, …) — the map-presence
196
+ * decision is sound only because the lock serializes same-sessionId tasks, so
197
+ * no concurrent commit can race the insert. MUST only be called on run success
198
+ * (insert-only-on-success is caller-gated). Returns true when the session is
199
+ * now pool-owned, false when a fresh session could not be inserted because it
200
+ * lacks the manager/file required by a pooled entry. */
201
+ export function commit(sessionId: string, payload: CommitPayload): boolean {
202
+ const existing = agentPool.get(sessionId);
203
+ if (existing) {
204
+ // Pool hit: session already pooled, just bump stats.
205
+ existing.lastUsed = now();
206
+ existing.totalTokens += payload.tokens;
207
+ existing.promptCount++;
208
+ return true;
209
+ }
210
+ // Miss → success: insert. A pooled entry needs a concrete file/manager.
211
+ if (!payload.sessionManager || !payload.sessionFile) return false;
212
+ agentPool.set(sessionId, {
213
+ session: payload.session,
214
+ sessionManager: payload.sessionManager,
215
+ sessionFile: payload.sessionFile,
216
+ config: payload.frozen,
217
+ lastUsed: now(),
218
+ createdAt: now(),
219
+ totalTokens: payload.tokens,
220
+ promptCount: 1,
221
+ });
222
+ return true;
223
+ }
224
+
225
+ // ── Read-only defaults (for task-resolution) ──────────────────────────────
226
+
227
+ /** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
228
+ * safe because the frozen config is write-only at insert and never mutated
229
+ * thereafter (only stats mutate, via commit, and those do not touch the
230
+ * returned object). Used by resolveTasks to default {systemPrompt, model,
231
+ * thinking, tools} for a task that supplies only a sessionId. */
232
+ export function configFor(
233
+ sessionId: string,
234
+ ): Readonly<FrozenConfig> | undefined {
235
+ return agentPool.get(sessionId)?.config;
236
+ }
237
+
238
+ // ── Lock primitive (D1) ───────────────────────────────────────────────────
239
+
240
+ /** Per-session lock — serializes concurrent calls with the same sessionId.
241
+ * Different ids run in parallel. Exported as a primitive so the caller
242
+ * (lifecycle) can bracket the ENTIRE acquire/run/commit flow; checkout/commit
243
+ * do NOT lock internally because their only caller is already inside this
244
+ * bracket. Close is also invoked under this lock, so it never disposes an
245
+ * in-flight prompt. */
246
+ export async function withSessionLock<T>(
247
+ sessionId: string,
248
+ fn: () => Promise<T>,
249
+ ): Promise<T> {
250
+ const prev = sessionLocks.get(sessionId);
251
+ let resolve!: () => void;
252
+ const promise = new Promise<void>((r) => {
253
+ resolve = r;
254
+ });
255
+ // Install ourselves BEFORE awaiting — so the next waiter queues behind us,
256
+ // not behind the same predecessor we're waiting on.
257
+ sessionLocks.set(sessionId, promise);
258
+ try {
259
+ if (prev) await prev;
260
+ return await fn();
261
+ } finally {
262
+ resolve();
263
+ // Only clean up if no one queued behind us (our promise is still current).
264
+ // If a waiter installed their own promise, leave it — deleting would
265
+ // clobber their map entry and break the chain.
266
+ if (sessionLocks.get(sessionId) === promise) {
267
+ sessionLocks.delete(sessionId);
268
+ }
269
+ }
270
+ }
271
+
272
+ // ── Teardown / display ────────────────────────────────────────────────────
273
+
274
+ interface AbortOutcome {
275
+ error?: unknown;
276
+ timedOut?: boolean;
277
+ }
278
+
279
+ /** Start cancellation without letting a cleanup failure become unhandled while
280
+ * a holder of the per-session lock is still unwinding. A wedged provider/tool
281
+ * cannot keep disposal or parent shutdown waiting forever. */
282
+ function beginAbort(session: AgentSession): Promise<AbortOutcome> {
283
+ return new Promise((resolve) => {
284
+ let settled = false;
285
+ let timer: ReturnType<typeof setTimeout> | undefined;
286
+ const finish = (outcome: AbortOutcome): void => {
287
+ if (settled) return;
288
+ settled = true;
289
+ if (timer !== undefined) clearTimeout(timer);
290
+ resolve(outcome);
291
+ };
292
+
293
+ timer = setTimeout(() => finish({ timedOut: true }), poolAbortTimeoutMs);
294
+ try {
295
+ void session.abort().then(
296
+ () => finish({}),
297
+ (error: unknown) => finish({ error }),
298
+ );
299
+ } catch (error) {
300
+ finish({ error });
301
+ }
302
+ });
303
+ }
304
+
305
+ /** Close and dispose one pooled session. A caller holds the per-session lock
306
+ * while closing, so abort cannot race a reuse. All cleanup is attempted before
307
+ * an error is surfaced; a removed session is never silently retained. */
308
+ async function closePooledAgentAfterAbort(
309
+ sessionId: string,
310
+ abort: Promise<AbortOutcome>,
311
+ ): Promise<boolean> {
312
+ const pooled = agentPool.get(sessionId);
313
+ if (!pooled) return false;
314
+
315
+ const failures: unknown[] = [];
316
+ let abortOutcome: AbortOutcome;
317
+ try {
318
+ abortOutcome = await abort;
319
+ } catch (error) {
320
+ // beginAbort currently normalizes rejection, but keep the teardown seam
321
+ // fail-closed if a future caller supplies a raw abort promise.
322
+ failures.push(error);
323
+ abortOutcome = {};
324
+ }
325
+ if (abortOutcome.error !== undefined) failures.push(abortOutcome.error);
326
+ if (abortOutcome.timedOut) {
327
+ failures.push(
328
+ new Error(
329
+ `Timed out after ${poolAbortTimeoutMs}ms waiting for session '${sessionId}' to abort.`,
330
+ ),
331
+ );
332
+ }
333
+ try {
334
+ pooled.session.dispose();
335
+ } catch (error) {
336
+ failures.push(error);
337
+ }
338
+ if (agentPool.get(sessionId) === pooled) agentPool.delete(sessionId);
339
+
340
+ if (failures.length) {
341
+ throw new AggregateError(
342
+ failures,
343
+ `Failed to close pooled session '${sessionId}'.`,
344
+ );
345
+ }
346
+ return true;
347
+ }
348
+
349
+ /** Abort, dispose, and remove one pooled session. Returns false when the id
350
+ * is already absent; cleanup failures are aggregated after removal. */
351
+ export async function closePooledAgent(sessionId: string): Promise<boolean> {
352
+ const pooled = agentPool.get(sessionId);
353
+ if (!pooled) return false;
354
+ return closePooledAgentAfterAbort(sessionId, beginAbort(pooled.session));
355
+ }
356
+
357
+ /** Dispose every live pooled session when the parent Pi session ends. First
358
+ * request cancellation immediately, then acquire each session's lock before
359
+ * disposal. The lock prevents an in-flight lifecycle from committing a live
360
+ * session after shutdown has removed it. Attempts all cleanup before reporting
361
+ * any failures. */
362
+ export async function closeAllPooledAgents(): Promise<void> {
363
+ const aborts = new Map<string, Promise<AbortOutcome>>(
364
+ [...agentPool].map(([sessionId, pooled]) => [
365
+ sessionId,
366
+ beginAbort(pooled.session),
367
+ ]),
368
+ );
369
+ const results = await Promise.allSettled(
370
+ [...aborts].map(([sessionId, abort]) =>
371
+ withSessionLock(sessionId, () =>
372
+ closePooledAgentAfterAbort(sessionId, abort),
373
+ ),
374
+ ),
375
+ );
376
+ const failures = results
377
+ .filter(
378
+ (result): result is PromiseRejectedResult => result.status === "rejected",
379
+ )
380
+ .map((result) => result.reason);
381
+ if (failures.length) {
382
+ throw new AggregateError(
383
+ failures,
384
+ "Failed to close one or more pooled sessions.",
385
+ );
386
+ }
387
+ }
388
+
389
+ /** List live pooled agents. Sessions remain available until explicit close or
390
+ * parent-session shutdown; idle/age are observability statistics, not expiry. */
391
+ export function listPooledAgents(): string[] {
392
+ const lines: string[] = [];
393
+ if (agentPool.size === 0) return ["_(no active sessions)_"];
394
+ const t = now();
395
+ for (const [id, pooled] of agentPool) {
396
+ const idle = fmtDuration(t - pooled.lastUsed);
397
+ const age = fmtDuration(t - pooled.createdAt);
398
+ lines.push(
399
+ `- **${id}** · ${pooled.promptCount} prompts · ${fmtTokens(pooled.totalTokens)} tokens · idle ${idle} · age ${age} · ${shortenPath(pooled.sessionFile)}`,
400
+ );
401
+ }
402
+ return lines;
403
+ }
404
+
405
+ // ── Test seam (internal — imported directly by tests, not re-exported) ────
406
+
407
+ /** @internal Override the abort wait in tests without waiting ten seconds. */
408
+ export function _setPoolAbortTimeoutForTesting(
409
+ timeoutMs: number | undefined,
410
+ ): void {
411
+ poolAbortTimeoutMs = timeoutMs ?? DEFAULT_POOL_ABORT_TIMEOUT_MS;
412
+ }
413
+
414
+ /** @internal Clear all pool state for test isolation. Tests use fake sessions,
415
+ * so no live resource teardown is needed here. */
416
+ export function _resetPoolForTesting(): void {
417
+ agentPool.clear();
418
+ sessionLocks.clear();
419
+ poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
420
+ }