@inixiative/agent-session 0.6.0 → 0.6.1
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 +15 -13
- package/package.json +3 -3
- package/src/claude-agent-sdk-session.ts +8 -0
- package/src/claude-code-session.ts +79 -12
- package/src/claude-primed.ts +153 -35
- package/src/codex-session.ts +103 -609
- package/src/harness-session.ts +1 -1
- package/src/index.ts +1 -4
- package/src/pool.ts +1 -1
- package/src/primed.ts +1 -1
- package/src/transport.ts +5 -34
- package/src/transports.ts +1 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Drive coding-agent CLIs as **persistent, streaming, event-captured sessions** —
|
|
4
4
|
one interface, any agent, over interchangeable **transports**. Claude Code
|
|
5
|
-
(CLI or Agent SDK) and Codex (
|
|
5
|
+
(CLI or Agent SDK) and Codex (app-server) today; Gemini and Grok planned.
|
|
6
6
|
Also: warm **primed decision sessions**, **subscription pools** with
|
|
7
7
|
deterministic routing, **limit polling** and **session continuity**.
|
|
8
8
|
|
|
@@ -64,20 +64,23 @@ arrive as `rate_limit` events.
|
|
|
64
64
|
```ts
|
|
65
65
|
import { createSession, TRANSPORTS } from "@inixiative/agent-session";
|
|
66
66
|
|
|
67
|
-
const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk
|
|
67
|
+
const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk
|
|
68
68
|
if (TRANSPORTS[kind].capabilities.interrupt === "acknowledged") { /* … */ }
|
|
69
69
|
const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOME: profile } });
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
| | claude-cli | codex-
|
|
73
|
-
|
|
74
|
-
| Status | ✅ live | ✅ live | ✅ live |
|
|
75
|
-
| Resume / fork | native / native |
|
|
76
|
-
| Interrupt | acknowledged |
|
|
77
|
-
| Push | – |
|
|
78
|
-
| Approvals | callback |
|
|
79
|
-
| Limits stream / poll | ✅ / ✅ |
|
|
80
|
-
| Primed reset | fork |
|
|
72
|
+
| | claude-cli | codex-app-server | claude-agent-sdk | acp | api |
|
|
73
|
+
|---|---|---|---|---|---|
|
|
74
|
+
| Status | ✅ live | ✅ live | ✅ live | stub | stub |
|
|
75
|
+
| Resume / fork | native / native | native / – | native / native | – | – |
|
|
76
|
+
| Interrupt | acknowledged | acknowledged | acknowledged | – | – |
|
|
77
|
+
| Push | – | steer | – | – | – |
|
|
78
|
+
| Approvals | callback | callback | callback | – | – |
|
|
79
|
+
| Limits stream / poll | ✅ / ✅ | ✅ / ✅ | ✅ / – | – | – |
|
|
80
|
+
| Primed reset | fork | fork | – | – | – |
|
|
81
|
+
|
|
82
|
+
Codex runs only over `codex app-server --listen stdio://` (`CodexAppServerSession`);
|
|
83
|
+
Codex CLI 0.155 has no `mcp-server` subcommand.
|
|
81
84
|
|
|
82
85
|
Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
|
|
83
86
|
|
|
@@ -174,7 +177,6 @@ snapshots in the artifact but do not fabricate a completed-turn total.
|
|
|
174
177
|
| Runtime | Status |
|
|
175
178
|
|---|---|
|
|
176
179
|
| Claude Code | ✅ shipped |
|
|
177
|
-
| Codex CLI (`codex mcp-server`, JSON-RPC) | ✅ shipped (`CodexSession`) |
|
|
178
180
|
| Codex app-server | ✅ shipped (`CodexAppServerSession`, `CodexPrimedSessions`) |
|
|
179
181
|
| Claude Agent SDK | ✅ shipped (`ClaudeAgentSdkSession`; caller supplies `query`) |
|
|
180
182
|
| ACP agents / direct API | typed stubs |
|
|
@@ -188,7 +190,7 @@ Opaque runtimes degrade gracefully — fewer event types, never a hard failure.
|
|
|
188
190
|
## Provenance
|
|
189
191
|
|
|
190
192
|
Extracted from [`inixiative/foundry`](https://github.com/inixiative/foundry),
|
|
191
|
-
where this drives the Artificer session. Consumed by foundry,
|
|
193
|
+
where this drives the Artificer session. Consumed by foundry, oracle,
|
|
192
194
|
and the inixiative bench.
|
|
193
195
|
|
|
194
196
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
],
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@inixiative/json-rules": "^3.5.
|
|
39
|
+
"@inixiative/json-rules": "^3.5.4"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "2.5.2",
|
|
43
|
-
"@inixiative/config": "^0.5.
|
|
43
|
+
"@inixiative/config": "^0.5.78",
|
|
44
44
|
"@types/bun": "1.4.2",
|
|
45
45
|
"lefthook": "2.1.9",
|
|
46
46
|
"typescript": "6.0.3"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// ClaudeAgentSdkSession — Claude through @anthropic-ai/claude-agent-sdk
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
@@ -90,6 +91,13 @@ export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unkn
|
|
|
90
91
|
case '--append-system-prompt':
|
|
91
92
|
options.systemPrompt = { type: 'preset', preset: 'claude_code', append: value() };
|
|
92
93
|
break;
|
|
94
|
+
case '--append-system-prompt-file':
|
|
95
|
+
options.systemPrompt = {
|
|
96
|
+
type: 'preset',
|
|
97
|
+
preset: 'claude_code',
|
|
98
|
+
append: readFileSync(value(), 'utf8'),
|
|
99
|
+
};
|
|
100
|
+
break;
|
|
93
101
|
case '--no-session-persistence':
|
|
94
102
|
options.persistSession = false;
|
|
95
103
|
break;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import {
|
|
2
5
|
claudeRateLimitSnapshot,
|
|
3
6
|
claudeUsageSnapshot,
|
|
@@ -92,7 +95,8 @@ export interface ClaudeCodeSessionConfig {
|
|
|
92
95
|
/**
|
|
93
96
|
* Base context to pre-load at session startup.
|
|
94
97
|
*
|
|
95
|
-
* Injected via --append-system-prompt on process spawn
|
|
98
|
+
* Injected via --append-system-prompt-file on process spawn (a private 0600 file, removed when the
|
|
99
|
+
* process ends), so a base past Linux's 128 KiB per-argument limit still starts. Persists for the
|
|
96
100
|
* entire session lifetime. Include all stable context here (system,
|
|
97
101
|
* conventions, memory, architecture) so per-message delta is minimal.
|
|
98
102
|
*/
|
|
@@ -258,6 +262,8 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
258
262
|
private _permissionMode: string;
|
|
259
263
|
private _defaultTimeout: number;
|
|
260
264
|
private _baseContext?: string;
|
|
265
|
+
/** The private directory holding the base context file while a process runs. */
|
|
266
|
+
private _baseContextDir?: string;
|
|
261
267
|
private _forking = false;
|
|
262
268
|
private _awaitingForkIdentity = false;
|
|
263
269
|
private _spawn?: ClaudeCodeSessionConfig['spawn'];
|
|
@@ -448,16 +454,21 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
448
454
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
449
455
|
for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
|
|
450
456
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
457
|
+
try {
|
|
458
|
+
if (this._spawn) {
|
|
459
|
+
this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
|
|
460
|
+
} else {
|
|
461
|
+
this._proc = Bun.spawn([this._bin, ...args], {
|
|
462
|
+
cwd: this._cwd,
|
|
463
|
+
stdin: 'pipe',
|
|
464
|
+
stdout: 'pipe',
|
|
465
|
+
stderr: 'pipe',
|
|
466
|
+
env,
|
|
467
|
+
}) as unknown as PipedSubprocess;
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
this._removeBaseContext();
|
|
471
|
+
throw error;
|
|
461
472
|
}
|
|
462
473
|
|
|
463
474
|
this._alive = true;
|
|
@@ -470,7 +481,10 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
470
481
|
this._readStderr();
|
|
471
482
|
|
|
472
483
|
// Monitor process exit for cleanup
|
|
484
|
+
// The process read its base context file at startup; it goes when the process does.
|
|
485
|
+
const baseDir = this._baseContextDir;
|
|
473
486
|
proc.exited.then(async (code) => {
|
|
487
|
+
this._removeBaseContext(baseDir);
|
|
474
488
|
if (this._proc !== proc || !this._alive) return;
|
|
475
489
|
this._alive = false;
|
|
476
490
|
await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
|
|
@@ -734,6 +748,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
734
748
|
/* already dead */
|
|
735
749
|
}
|
|
736
750
|
this._proc = null;
|
|
751
|
+
this._removeBaseContext();
|
|
737
752
|
|
|
738
753
|
this._emit({ kind: 'session_end', timestamp: Date.now() });
|
|
739
754
|
}
|
|
@@ -1328,12 +1343,32 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1328
1343
|
|
|
1329
1344
|
// Stable base context injected once at process startup
|
|
1330
1345
|
if (this._baseContext) {
|
|
1331
|
-
args.push('--append-system-prompt', this._baseContext);
|
|
1346
|
+
args.push('--append-system-prompt-file', this._writeBaseContext(this._baseContext));
|
|
1332
1347
|
}
|
|
1333
1348
|
|
|
1334
1349
|
return args;
|
|
1335
1350
|
}
|
|
1336
1351
|
|
|
1352
|
+
/** The base context as a private file: argv carries its path, never its text. */
|
|
1353
|
+
private _writeBaseContext(text: string): string {
|
|
1354
|
+
this._removeBaseContext();
|
|
1355
|
+
if (!swept) {
|
|
1356
|
+
swept = true;
|
|
1357
|
+
sweepOrphanedBaseContexts();
|
|
1358
|
+
}
|
|
1359
|
+
this._baseContextDir = mkdtempSync(join(tmpdir(), `${BASE_CONTEXT_PREFIX}${process.pid}-`));
|
|
1360
|
+
const file = join(this._baseContextDir, 'append-system-prompt.md');
|
|
1361
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
1362
|
+
return file;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** Remove a spawn's base context directory: the current one by default. */
|
|
1366
|
+
private _removeBaseContext(dir = this._baseContextDir): void {
|
|
1367
|
+
if (!dir) return;
|
|
1368
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1369
|
+
if (dir === this._baseContextDir) this._baseContextDir = undefined;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1337
1372
|
// ---------------------------------------------------------------------------
|
|
1338
1373
|
// Private — event classification
|
|
1339
1374
|
// ---------------------------------------------------------------------------
|
|
@@ -1491,3 +1526,35 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1491
1526
|
}
|
|
1492
1527
|
}
|
|
1493
1528
|
}
|
|
1529
|
+
|
|
1530
|
+
const BASE_CONTEXT_PREFIX = 'agent-session-base-';
|
|
1531
|
+
let swept = false;
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Remove base context files a crashed or killed process left behind (each holds a whole composed
|
|
1535
|
+
* context). Runs once per process; a directory belongs to the pid in its name and goes only when
|
|
1536
|
+
* that process is gone.
|
|
1537
|
+
*/
|
|
1538
|
+
export function sweepOrphanedBaseContexts(dir = tmpdir()): void {
|
|
1539
|
+
let entries: string[];
|
|
1540
|
+
try {
|
|
1541
|
+
entries = readdirSync(dir);
|
|
1542
|
+
} catch {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
const pid = Number(new RegExp(`^${BASE_CONTEXT_PREFIX}(\\d+)-`).exec(entry)?.[1]);
|
|
1547
|
+
if (!pid || pid === process.pid || processAlive(pid)) continue;
|
|
1548
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function processAlive(pid: number): boolean {
|
|
1553
|
+
try {
|
|
1554
|
+
process.kill(pid, 0);
|
|
1555
|
+
return true;
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
// EPERM: it exists under another user.
|
|
1558
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
1559
|
+
}
|
|
1560
|
+
}
|
package/src/claude-primed.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// decision pays no process start. Forks send the primed prefix
|
|
14
14
|
// byte-for-byte, so it is read from the prompt cache.
|
|
15
15
|
//
|
|
16
|
+
// A key's decisions run concurrently, like Codex forks: priming is
|
|
17
|
+
// single-flight per key, the first decision takes the spare, and an
|
|
18
|
+
// overlapping one starts its own fork (process start, no re-prime). One key
|
|
19
|
+
// therefore serves every phase of a role without one phase queueing behind
|
|
20
|
+
// another, and still holds a single idle spare.
|
|
21
|
+
//
|
|
16
22
|
// Verified live with claude 2.1.281: forks read the full primed prefix from
|
|
17
23
|
// cache (cache_read equal to the primer's cache write) and write nothing to
|
|
18
24
|
// history.
|
|
@@ -87,7 +93,10 @@ interface Primed {
|
|
|
87
93
|
cleanup?: Promise<void>;
|
|
88
94
|
model?: string;
|
|
89
95
|
lastUsed: number;
|
|
90
|
-
|
|
96
|
+
/** Decisions currently forked from this session. */
|
|
97
|
+
inUse: number;
|
|
98
|
+
/** Replaced or evicted while in use: its transcript is deleted when the last decision finishes. */
|
|
99
|
+
stale?: 'idle' | 'capacity' | 'reprime' | 'requested' | 'close';
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export class ClaudePrimedSessions implements PrimedSessions {
|
|
@@ -104,6 +113,8 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
104
113
|
private _idleTimer?: ReturnType<typeof setInterval>;
|
|
105
114
|
/** Branches and primers with a live process, so close() can stop them. */
|
|
106
115
|
private _live = new Set<Owned>();
|
|
116
|
+
/** Sessions dropped while decisions still fork from them: retired by the last one, or by close(). */
|
|
117
|
+
private _stale = new Set<Primed>();
|
|
107
118
|
private _swept = false;
|
|
108
119
|
private _lastPoll = 0;
|
|
109
120
|
|
|
@@ -168,14 +179,14 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
168
179
|
Math.min(this._config.idleMs, 60_000),
|
|
169
180
|
);
|
|
170
181
|
(this._idleTimer as { unref?: () => void }).unref?.();
|
|
171
|
-
return
|
|
182
|
+
return (async () => {
|
|
172
183
|
const release = await this._slots.acquire(deadline, request.signal);
|
|
173
184
|
try {
|
|
174
185
|
return await this._decide(spec, request, started, deadline);
|
|
175
186
|
} finally {
|
|
176
187
|
release();
|
|
177
188
|
}
|
|
178
|
-
});
|
|
189
|
+
})();
|
|
179
190
|
}
|
|
180
191
|
|
|
181
192
|
async evict(key: string): Promise<void> {
|
|
@@ -195,6 +206,8 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
195
206
|
// In-flight branches and primers are stopped, not awaited to completion.
|
|
196
207
|
await Promise.all([...this._live].map((owned) => this._dispose(owned)));
|
|
197
208
|
await Promise.all([...this._sessions.values()].map((s) => this._drop(s, 'close')));
|
|
209
|
+
// Their forks are stopped, so no decision still reads a stale transcript.
|
|
210
|
+
for (const session of [...this._stale]) this._retire(session, session.stale ?? 'close');
|
|
198
211
|
}
|
|
199
212
|
|
|
200
213
|
// -------------------------------------------------------------------------
|
|
@@ -358,7 +371,7 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
358
371
|
hash,
|
|
359
372
|
instructions: spec.instructions,
|
|
360
373
|
lastUsed: Date.now(),
|
|
361
|
-
|
|
374
|
+
inUse: 0,
|
|
362
375
|
};
|
|
363
376
|
this._sessions.set(spec.key, primed);
|
|
364
377
|
this._emit({ type: 'primed', key: spec.key, hash, ms: 0 });
|
|
@@ -406,7 +419,7 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
406
419
|
base: base.session,
|
|
407
420
|
model: observedModel(result),
|
|
408
421
|
lastUsed: Date.now(),
|
|
409
|
-
|
|
422
|
+
inUse: 0,
|
|
410
423
|
};
|
|
411
424
|
this._sessions.set(spec.key, primed);
|
|
412
425
|
this._emit({ type: 'primed', key: spec.key, hash, ms: Math.round(performance.now() - t0) });
|
|
@@ -422,14 +435,78 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
422
435
|
const spare = session.spare;
|
|
423
436
|
session.spare = undefined;
|
|
424
437
|
await this._dispose(spare);
|
|
438
|
+
// Decisions still forking from it keep its transcript until the last one finishes; on close
|
|
439
|
+
// their processes are already stopped.
|
|
440
|
+
if (session.inUse > 0 && reason !== 'close') {
|
|
441
|
+
session.stale ??= reason;
|
|
442
|
+
this._stale.add(session);
|
|
443
|
+
} else this._retire(session, reason);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private _retire(session: Primed, reason: NonNullable<Primed['stale']>): void {
|
|
447
|
+
this._stale.delete(session);
|
|
448
|
+
session.stale = undefined;
|
|
425
449
|
this._deleteTranscript(session.base?.externalSessionId);
|
|
426
450
|
this._emit({ type: 'evicted', key: session.key, reason });
|
|
427
451
|
}
|
|
428
452
|
|
|
453
|
+
/** A decision's reference is returned; the last one out retires a stale session. */
|
|
454
|
+
private _release(session: Primed): void {
|
|
455
|
+
session.inUse--;
|
|
456
|
+
session.lastUsed = Date.now();
|
|
457
|
+
if (session.stale && !session.inUse) this._retire(session, session.stale);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Wait for `task` until the deadline or the signal; neither cancels it. Nothing is dispatched on expiry. */
|
|
461
|
+
private _bounded<T>(task: Promise<T>, deadline: number, signal?: AbortSignal): Promise<T> {
|
|
462
|
+
return new Promise<T>((resolve, reject) => {
|
|
463
|
+
const fail = (error: DecisionError) => {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
signal?.removeEventListener('abort', onAbort);
|
|
466
|
+
reject(error);
|
|
467
|
+
};
|
|
468
|
+
const onAbort = () =>
|
|
469
|
+
fail(
|
|
470
|
+
new DecisionError(
|
|
471
|
+
this._closed ? 'Primed sessions closed' : 'Decision aborted before dispatch',
|
|
472
|
+
this._closed ? 'closed' : 'aborted',
|
|
473
|
+
'not-dispatched',
|
|
474
|
+
true,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
const timer = setTimeout(
|
|
478
|
+
() =>
|
|
479
|
+
fail(
|
|
480
|
+
new DecisionError(
|
|
481
|
+
'Decision deadline passed waiting for its primed session',
|
|
482
|
+
'timeout',
|
|
483
|
+
'not-dispatched',
|
|
484
|
+
true,
|
|
485
|
+
),
|
|
486
|
+
),
|
|
487
|
+
Math.max(0, deadline - Date.now()),
|
|
488
|
+
);
|
|
489
|
+
if (signal?.aborted) return onAbort();
|
|
490
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
491
|
+
task.then(
|
|
492
|
+
(value) => {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
signal?.removeEventListener('abort', onAbort);
|
|
495
|
+
resolve(value);
|
|
496
|
+
},
|
|
497
|
+
(error) => {
|
|
498
|
+
clearTimeout(timer);
|
|
499
|
+
signal?.removeEventListener('abort', onAbort);
|
|
500
|
+
reject(error);
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
429
506
|
private async _evictIdle(): Promise<void> {
|
|
430
507
|
const cutoff = Date.now() - this._config.idleMs;
|
|
431
508
|
for (const session of [...this._sessions.values()])
|
|
432
|
-
if (!session.
|
|
509
|
+
if (!session.inUse && session.lastUsed < cutoff) await this._drop(session, 'idle');
|
|
433
510
|
}
|
|
434
511
|
|
|
435
512
|
private async _decide(
|
|
@@ -455,36 +532,72 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
455
532
|
await this._checkLimits();
|
|
456
533
|
this._sweepTranscripts();
|
|
457
534
|
const hash = primeHash(spec);
|
|
458
|
-
let
|
|
459
|
-
prime: 'warm' | 'cold' = 'warm',
|
|
535
|
+
let prime: 'warm' | 'cold' = 'warm',
|
|
460
536
|
primeMs = 0;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
537
|
+
// Only finding or priming the key's session is serialized: priming is single-flight per key,
|
|
538
|
+
// and the decision itself runs beside any other decision on the key. The wait is bounded by
|
|
539
|
+
// this decision's deadline and signal; a prime it started runs on (host timeout) for the next.
|
|
540
|
+
let abandoned = false,
|
|
541
|
+
taken = false;
|
|
542
|
+
const lookup = this._queue.run(spec.key, async () => {
|
|
543
|
+
if (this._closed || abandoned) throw closed();
|
|
544
|
+
let found = this._sessions.get(spec.key);
|
|
545
|
+
if (found && found.hash !== hash) {
|
|
546
|
+
await this._drop(found, 'reprime');
|
|
547
|
+
found = undefined;
|
|
472
548
|
}
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
549
|
+
if (!found) {
|
|
550
|
+
while (this._sessions.size >= this._config.maxSessions) {
|
|
551
|
+
const idle = [...this._sessions.values()]
|
|
552
|
+
.filter((s) => !s.inUse)
|
|
553
|
+
.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
554
|
+
if (!idle) break;
|
|
555
|
+
await this._drop(idle, 'capacity');
|
|
556
|
+
}
|
|
557
|
+
if (this._closed || abandoned) throw closed();
|
|
558
|
+
const t = performance.now();
|
|
559
|
+
found = await this._prime(spec, hash, Date.now() + this._config.timeoutMs);
|
|
560
|
+
primeMs = Math.round(performance.now() - t);
|
|
561
|
+
prime = 'cold';
|
|
562
|
+
// Closed while priming: the drop above has run, so this session is dropped here.
|
|
563
|
+
if (this._closed) {
|
|
564
|
+
await this._drop(found, 'close');
|
|
565
|
+
throw closed();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (abandoned) return found;
|
|
569
|
+
found.inUse++;
|
|
570
|
+
taken = true;
|
|
571
|
+
return found;
|
|
572
|
+
});
|
|
573
|
+
lookup.catch(() => undefined);
|
|
574
|
+
const session = await this._bounded(lookup, deadline, signal).catch((error: unknown) => {
|
|
575
|
+
abandoned = true;
|
|
576
|
+
// Granted between the deadline firing and this handler: hand the reference back.
|
|
577
|
+
void lookup.then(
|
|
578
|
+
(found) => {
|
|
579
|
+
if (taken) this._release(found);
|
|
580
|
+
},
|
|
581
|
+
() => undefined,
|
|
582
|
+
);
|
|
583
|
+
throw error;
|
|
584
|
+
});
|
|
585
|
+
let branch: Owned | undefined;
|
|
486
586
|
let admissionId: string | undefined;
|
|
587
|
+
const t1 = performance.now();
|
|
487
588
|
try {
|
|
589
|
+
await this._bounded(session.cleanup ?? Promise.resolve(), deadline, signal);
|
|
590
|
+
if (this._closed || signal?.aborted) throw closed();
|
|
591
|
+
if (Date.now() >= deadline)
|
|
592
|
+
throw new DecisionError(
|
|
593
|
+
'Decision deadline passed before dispatch',
|
|
594
|
+
'timeout',
|
|
595
|
+
'not-dispatched',
|
|
596
|
+
true,
|
|
597
|
+
);
|
|
598
|
+
session.lastUsed = Date.now();
|
|
599
|
+
branch = session.spare;
|
|
600
|
+
session.spare = undefined;
|
|
488
601
|
if (!branch?.session.alive) {
|
|
489
602
|
await this._dispose(branch);
|
|
490
603
|
branch = this._branch(session);
|
|
@@ -627,13 +740,18 @@ export class ClaudePrimedSessions implements PrimedSessions {
|
|
|
627
740
|
timing: { waitMs, primeMs, branchMs, turnMs },
|
|
628
741
|
};
|
|
629
742
|
} finally {
|
|
630
|
-
session
|
|
631
|
-
session.lastUsed = Date.now();
|
|
743
|
+
this._release(session);
|
|
632
744
|
// The decision settled on its native terminal; process exit and the next spare are off the caller's path.
|
|
633
745
|
const done = branch;
|
|
634
746
|
session.cleanup = (async () => {
|
|
635
747
|
await this._dispose(done);
|
|
636
|
-
|
|
748
|
+
// One spare per key: an overlapping decision that already refilled it wins.
|
|
749
|
+
if (
|
|
750
|
+
this._config.spare &&
|
|
751
|
+
!this._closed &&
|
|
752
|
+
!session.spare &&
|
|
753
|
+
this._sessions.get(spec.key) === session
|
|
754
|
+
) {
|
|
637
755
|
const spare = this._branch(session);
|
|
638
756
|
session.spare = spare;
|
|
639
757
|
await spare.session.start().catch(() => {
|