@inixiative/agent-session 0.2.0 → 0.3.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/README.md +77 -6
- package/package.json +10 -4
- package/src/claude-agent-sdk-session.ts +316 -0
- package/src/claude-code-session.ts +584 -207
- package/src/claude-primed.ts +655 -0
- package/src/claude-usage.ts +3 -4
- package/src/codex-primed.ts +1482 -0
- package/src/codex-session.ts +1080 -395
- package/src/harness-session.ts +82 -35
- package/src/index.ts +106 -12
- package/src/json-rpc.ts +240 -0
- package/src/limits-probe.ts +78 -0
- package/src/limits.ts +216 -0
- package/src/pool.ts +652 -0
- package/src/primed.ts +284 -0
- package/src/retained-evidence.ts +2 -2
- package/src/routing.ts +273 -0
- package/src/transport.ts +215 -0
- package/src/transports.ts +97 -0
- package/src/turn-state.ts +55 -25
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ClaudePrimedSessions — primed decision sessions on the Claude CLI
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// The CLI serves one conversation per process, so each key owns:
|
|
6
|
+
//
|
|
7
|
+
// prime a text-only session with the role instructions as its appended
|
|
8
|
+
// system prompt, plus one primer turn carrying the stable context.
|
|
9
|
+
// That session is persisted; its process exits.
|
|
10
|
+
// decide a fork of the primed session (`--resume <primed> --fork-session
|
|
11
|
+
// --no-session-persistence`) receives the cycle input and exits. The
|
|
12
|
+
// next fork is spawned right away as a warm spare, so the following
|
|
13
|
+
// decision pays no process start. Forks send the primed prefix
|
|
14
|
+
// byte-for-byte, so it is read from the prompt cache.
|
|
15
|
+
//
|
|
16
|
+
// Verified live with claude 2.1.281: forks read the full primed prefix from
|
|
17
|
+
// cache (cache_read equal to the primer's cache write) and write nothing to
|
|
18
|
+
// history.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { readdirSync, realpathSync, unlinkSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
ClaudeCodeSession,
|
|
26
|
+
type ClaudeCodeSessionConfig,
|
|
27
|
+
type PipedSubprocess,
|
|
28
|
+
} from './claude-code-session';
|
|
29
|
+
import { type SessionResult, SessionTurnError } from './harness-session';
|
|
30
|
+
import { type LimitSnapshot, mergeLimits } from './limits';
|
|
31
|
+
import {
|
|
32
|
+
DecisionError,
|
|
33
|
+
type DecisionRequest,
|
|
34
|
+
type DecisionResult,
|
|
35
|
+
KeyedQueue,
|
|
36
|
+
type PrimedEvent,
|
|
37
|
+
type PrimedSessions,
|
|
38
|
+
type PrimedSnapshot,
|
|
39
|
+
type PrimeSpec,
|
|
40
|
+
primeHash,
|
|
41
|
+
Slots,
|
|
42
|
+
} from './primed';
|
|
43
|
+
|
|
44
|
+
const PRIMER_SUFFIX =
|
|
45
|
+
'\n\nThe above is standing context for the decisions that follow. Reply with OK only.';
|
|
46
|
+
|
|
47
|
+
export interface ClaudePrimedConfig {
|
|
48
|
+
bin?: string;
|
|
49
|
+
model: string;
|
|
50
|
+
effort?: string;
|
|
51
|
+
/** Private working directory for primed sessions. */
|
|
52
|
+
cwd: string;
|
|
53
|
+
/** Prepended to every key's instructions in the appended system prompt. */
|
|
54
|
+
baseInstructions?: string;
|
|
55
|
+
/** Merged over process.env (e.g. CLAUDE_CONFIG_DIR). API keys are always removed. */
|
|
56
|
+
env?: Record<string, string | undefined>;
|
|
57
|
+
spawn?: ClaudeCodeSessionConfig['spawn'];
|
|
58
|
+
/** Concurrent decisions across keys. Default 1 (one refresh owner per profile). */
|
|
59
|
+
maxConcurrent?: number;
|
|
60
|
+
/** Keys kept primed; least recently used idle keys are evicted beyond this. Default 64. */
|
|
61
|
+
maxSessions?: number;
|
|
62
|
+
/** Evict keys idle longer than this. Default 30 min. */
|
|
63
|
+
idleMs?: number;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** Pre-spawn the next fork after each decision. Default true. */
|
|
66
|
+
spare?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Delete primed transcripts this host persisted when a key is dropped, and
|
|
69
|
+
* transcripts a crashed host left for `cwd`, on first use. `cwd` must be
|
|
70
|
+
* dedicated to this host. Default true.
|
|
71
|
+
*/
|
|
72
|
+
sweepTranscripts?: boolean;
|
|
73
|
+
onEvent?: (event: PrimedEvent) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Owned {
|
|
77
|
+
session: ClaudeCodeSession;
|
|
78
|
+
exited?: Promise<number>;
|
|
79
|
+
}
|
|
80
|
+
interface Primed {
|
|
81
|
+
readonly key: string;
|
|
82
|
+
readonly hash: string;
|
|
83
|
+
readonly instructions: string;
|
|
84
|
+
readonly base?: ClaudeCodeSession;
|
|
85
|
+
spare?: Owned;
|
|
86
|
+
/** Disposal of the last branch and start of its spare; the key's next decision waits for it. */
|
|
87
|
+
cleanup?: Promise<void>;
|
|
88
|
+
model?: string;
|
|
89
|
+
lastUsed: number;
|
|
90
|
+
busy: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ClaudePrimedSessions implements PrimedSessions {
|
|
94
|
+
readonly runtime = 'claude' as const;
|
|
95
|
+
private readonly _config: ClaudePrimedConfig &
|
|
96
|
+
Required<
|
|
97
|
+
Pick<ClaudePrimedConfig, 'maxConcurrent' | 'maxSessions' | 'idleMs' | 'timeoutMs' | 'spare'>
|
|
98
|
+
>;
|
|
99
|
+
private _sessions = new Map<string, Primed>();
|
|
100
|
+
private _queue = new KeyedQueue();
|
|
101
|
+
private _slots: Slots;
|
|
102
|
+
private _limits?: LimitSnapshot;
|
|
103
|
+
private _closed = false;
|
|
104
|
+
private _idleTimer?: ReturnType<typeof setInterval>;
|
|
105
|
+
/** Branches and primers with a live process, so close() can stop them. */
|
|
106
|
+
private _live = new Set<Owned>();
|
|
107
|
+
private _swept = false;
|
|
108
|
+
private _lastPoll = 0;
|
|
109
|
+
|
|
110
|
+
constructor(config: ClaudePrimedConfig) {
|
|
111
|
+
if (!config.cwd?.startsWith('/'))
|
|
112
|
+
throw Error('Primed Claude sessions require an absolute private working directory');
|
|
113
|
+
this._config = {
|
|
114
|
+
...config,
|
|
115
|
+
maxConcurrent: config.maxConcurrent ?? 1,
|
|
116
|
+
maxSessions: config.maxSessions ?? 64,
|
|
117
|
+
idleMs: config.idleMs ?? 30 * 60_000,
|
|
118
|
+
timeoutMs: config.timeoutMs ?? 30_000,
|
|
119
|
+
spare: config.spare ?? true,
|
|
120
|
+
};
|
|
121
|
+
this._slots = new Slots(this._config.maxConcurrent);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
limits(): LimitSnapshot | undefined {
|
|
125
|
+
return this._limits;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
snapshot(): PrimedSnapshot {
|
|
129
|
+
return {
|
|
130
|
+
runtime: 'claude',
|
|
131
|
+
closed: this._closed,
|
|
132
|
+
generation: 0,
|
|
133
|
+
processAlive: [...this._sessions.values()].some((s) => s.spare?.session.alive),
|
|
134
|
+
sessions: this._sessions.size,
|
|
135
|
+
active: this._slots.active,
|
|
136
|
+
waiting: this._slots.waiting,
|
|
137
|
+
...(this._limits ? { limits: this._limits } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** get_usage through a short-lived text-only session. No model turn. */
|
|
142
|
+
async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
|
|
143
|
+
if (this._closed)
|
|
144
|
+
throw new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true);
|
|
145
|
+
this._lastPoll = Date.now();
|
|
146
|
+
const probe = this._owned({ persistSession: false });
|
|
147
|
+
try {
|
|
148
|
+
await probe.session.start();
|
|
149
|
+
return await probe.session.readLimits(opts);
|
|
150
|
+
} finally {
|
|
151
|
+
await this._dispose(probe);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
decide(spec: PrimeSpec, request: DecisionRequest): Promise<DecisionResult> {
|
|
156
|
+
if (this._closed)
|
|
157
|
+
return Promise.reject(
|
|
158
|
+
new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true),
|
|
159
|
+
);
|
|
160
|
+
if (!spec.key || typeof spec.instructions !== 'string' || typeof request.input !== 'string')
|
|
161
|
+
return Promise.reject(
|
|
162
|
+
new DecisionError('Invalid decision request', 'admission', 'not-dispatched', true),
|
|
163
|
+
);
|
|
164
|
+
const started = Date.now(),
|
|
165
|
+
deadline = started + (request.timeoutMs ?? this._config.timeoutMs);
|
|
166
|
+
this._idleTimer ??= setInterval(
|
|
167
|
+
() => void this._evictIdle(),
|
|
168
|
+
Math.min(this._config.idleMs, 60_000),
|
|
169
|
+
);
|
|
170
|
+
(this._idleTimer as { unref?: () => void }).unref?.();
|
|
171
|
+
return this._queue.run(spec.key, async () => {
|
|
172
|
+
const release = await this._slots.acquire(deadline, request.signal);
|
|
173
|
+
try {
|
|
174
|
+
return await this._decide(spec, request, started, deadline);
|
|
175
|
+
} finally {
|
|
176
|
+
release();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async evict(key: string): Promise<void> {
|
|
182
|
+
await this._queue.run(key, async () => {
|
|
183
|
+
const s = this._sessions.get(key);
|
|
184
|
+
if (s) await this._drop(s, 'requested');
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async close(): Promise<void> {
|
|
189
|
+
if (this._closed) return;
|
|
190
|
+
this._closed = true;
|
|
191
|
+
clearInterval(this._idleTimer);
|
|
192
|
+
this._slots.drain(
|
|
193
|
+
new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true),
|
|
194
|
+
);
|
|
195
|
+
// In-flight branches and primers are stopped, not awaited to completion.
|
|
196
|
+
await Promise.all([...this._live].map((owned) => this._dispose(owned)));
|
|
197
|
+
await Promise.all([...this._sessions.values()].map((s) => this._drop(s, 'close')));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
private _emit(event: PrimedEvent): void {
|
|
203
|
+
try {
|
|
204
|
+
this._config.onEvent?.(event);
|
|
205
|
+
} catch {
|
|
206
|
+
/* observers only */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Refuse before dispatch while the account reports usage blocked; a poll (at most once a minute) can clear it. */
|
|
211
|
+
private async _checkLimits(): Promise<void> {
|
|
212
|
+
if (!this._limits?.blocked) return;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const resets = this._limits.windows
|
|
215
|
+
.filter((w) => w.usedPercent >= 100 && w.resetsAt)
|
|
216
|
+
.map((w) => w.resetsAt!);
|
|
217
|
+
if (resets.length && now >= Math.max(...resets)) {
|
|
218
|
+
this._limits = { ...this._limits, blocked: false };
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (now - this._lastPoll >= 60_000) {
|
|
222
|
+
try {
|
|
223
|
+
const polled = await this.readLimits({ timeoutMs: 10_000 });
|
|
224
|
+
if (polled) this._limits = mergeLimits(this._limits, polled);
|
|
225
|
+
} catch {
|
|
226
|
+
/* keep the blocked view */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (this._limits?.blocked)
|
|
230
|
+
throw new DecisionError(
|
|
231
|
+
'Claude subscription usage limit reached; no fallback',
|
|
232
|
+
'rate-limited',
|
|
233
|
+
'not-dispatched',
|
|
234
|
+
true,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A session whose process exit is observable, so settlement can be proven. */
|
|
239
|
+
private _owned(
|
|
240
|
+
overrides: Partial<ClaudeCodeSessionConfig> & { instructions?: string },
|
|
241
|
+
from?: ClaudeCodeSession,
|
|
242
|
+
): Owned {
|
|
243
|
+
const owned = {} as Owned;
|
|
244
|
+
const spawn: NonNullable<ClaudeCodeSessionConfig['spawn']> = (cmd, options) => {
|
|
245
|
+
const child: PipedSubprocess = this._config.spawn
|
|
246
|
+
? this._config.spawn(cmd, options)
|
|
247
|
+
: (Bun.spawn(cmd, {
|
|
248
|
+
...options,
|
|
249
|
+
stdin: 'pipe',
|
|
250
|
+
stdout: 'pipe',
|
|
251
|
+
stderr: 'pipe',
|
|
252
|
+
}) as unknown as PipedSubprocess);
|
|
253
|
+
owned.exited = child.exited;
|
|
254
|
+
return child;
|
|
255
|
+
};
|
|
256
|
+
const { instructions, ...rest } = overrides;
|
|
257
|
+
owned.session = from
|
|
258
|
+
? from.fork({ persistSession: false, spawn })
|
|
259
|
+
: new ClaudeCodeSession({
|
|
260
|
+
bin: this._config.bin,
|
|
261
|
+
model: this._config.model,
|
|
262
|
+
effort: this._config.effort,
|
|
263
|
+
cwd: this._config.cwd,
|
|
264
|
+
env: this._config.env,
|
|
265
|
+
textOnly: true,
|
|
266
|
+
maxTurns: 1,
|
|
267
|
+
timeout: this._config.timeoutMs,
|
|
268
|
+
baseContext:
|
|
269
|
+
[this._config.baseInstructions, instructions].filter(Boolean).join('\n\n') || undefined,
|
|
270
|
+
...rest,
|
|
271
|
+
spawn,
|
|
272
|
+
});
|
|
273
|
+
this._live.add(owned);
|
|
274
|
+
owned.session.onEvent((event) => {
|
|
275
|
+
if (event.kind === 'rate_limit' && event.limits) {
|
|
276
|
+
this._limits = mergeLimits(this._limits, event.limits);
|
|
277
|
+
this._emit({ type: 'limits', limits: this._limits });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
return owned;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Kill and wait (bounded) for exit; true when exit was observed. */
|
|
284
|
+
private async _dispose(owned: Owned | undefined): Promise<boolean> {
|
|
285
|
+
if (!owned) return true;
|
|
286
|
+
this._live.delete(owned);
|
|
287
|
+
owned.session.kill();
|
|
288
|
+
if (!owned.exited) return true;
|
|
289
|
+
return Promise.race([
|
|
290
|
+
owned.exited.then(
|
|
291
|
+
() => true,
|
|
292
|
+
() => false,
|
|
293
|
+
),
|
|
294
|
+
Bun.sleep(2_000).then(() => false),
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Where the CLI persists transcripts for this host's cwd. */
|
|
299
|
+
private _projectDirectory(): string | undefined {
|
|
300
|
+
try {
|
|
301
|
+
const configDir =
|
|
302
|
+
this._config.env?.CLAUDE_CONFIG_DIR ??
|
|
303
|
+
process.env.CLAUDE_CONFIG_DIR ??
|
|
304
|
+
join(homedir(), '.claude');
|
|
305
|
+
return join(
|
|
306
|
+
configDir,
|
|
307
|
+
'projects',
|
|
308
|
+
realpathSync(this._config.cwd).replace(/[^a-zA-Z0-9]/g, '-'),
|
|
309
|
+
);
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private _deleteTranscript(sessionId: string | undefined): void {
|
|
316
|
+
if (this._config.sweepTranscripts === false || !sessionId || !/^[a-zA-Z0-9-]+$/.test(sessionId))
|
|
317
|
+
return;
|
|
318
|
+
const directory = this._projectDirectory();
|
|
319
|
+
if (directory)
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(join(directory, `${sessionId}.jsonl`));
|
|
322
|
+
} catch {
|
|
323
|
+
/* already gone */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private _sweepTranscripts(): void {
|
|
328
|
+
if (this._swept || this._config.sweepTranscripts === false) return;
|
|
329
|
+
this._swept = true;
|
|
330
|
+
const directory = this._projectDirectory();
|
|
331
|
+
if (!directory) return;
|
|
332
|
+
let names: string[] = [];
|
|
333
|
+
try {
|
|
334
|
+
names = readdirSync(directory);
|
|
335
|
+
} catch {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
for (const name of names)
|
|
339
|
+
if (/^[a-zA-Z0-9-]+\.jsonl$/.test(name))
|
|
340
|
+
try {
|
|
341
|
+
unlinkSync(join(directory, name));
|
|
342
|
+
} catch {
|
|
343
|
+
/* best effort */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private _branch(session: Primed): Owned {
|
|
348
|
+
return session.base
|
|
349
|
+
? this._owned({}, session.base)
|
|
350
|
+
: this._owned({ persistSession: false, instructions: session.instructions });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async _prime(spec: PrimeSpec, hash: string, deadline: number): Promise<Primed> {
|
|
354
|
+
const t0 = performance.now();
|
|
355
|
+
if (!spec.context) {
|
|
356
|
+
const primed: Primed = {
|
|
357
|
+
key: spec.key,
|
|
358
|
+
hash,
|
|
359
|
+
instructions: spec.instructions,
|
|
360
|
+
lastUsed: Date.now(),
|
|
361
|
+
busy: false,
|
|
362
|
+
};
|
|
363
|
+
this._sessions.set(spec.key, primed);
|
|
364
|
+
this._emit({ type: 'primed', key: spec.key, hash, ms: 0 });
|
|
365
|
+
return primed;
|
|
366
|
+
}
|
|
367
|
+
const base = this._owned({ instructions: spec.instructions, persistSession: true });
|
|
368
|
+
let result: SessionResult | undefined;
|
|
369
|
+
try {
|
|
370
|
+
await base.session.start();
|
|
371
|
+
result = await base.session.send(`${spec.context}${PRIMER_SUFFIX}`, {
|
|
372
|
+
timeout: Math.max(1, deadline - Date.now()),
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const settled = await this._dispose(base);
|
|
376
|
+
this._deleteTranscript(base.session.externalSessionId);
|
|
377
|
+
throw new DecisionError(
|
|
378
|
+
`Priming ${spec.key} failed: ${(error as Error).message}`,
|
|
379
|
+
error instanceof SessionTurnError && error.attempt.localFailure === 'timeout'
|
|
380
|
+
? 'timeout'
|
|
381
|
+
: 'prime-failed',
|
|
382
|
+
'not-dispatched',
|
|
383
|
+
settled,
|
|
384
|
+
undefined,
|
|
385
|
+
{ cause: error },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const settled = await this._dispose(base);
|
|
389
|
+
if (
|
|
390
|
+
result.nativeOutcome !== 'completed' ||
|
|
391
|
+
!base.session.externalSessionId ||
|
|
392
|
+
result.events.some((e) => e.kind === 'tool_use')
|
|
393
|
+
) {
|
|
394
|
+
this._deleteTranscript(base.session.externalSessionId);
|
|
395
|
+
throw new DecisionError(
|
|
396
|
+
`Priming ${spec.key} did not complete`,
|
|
397
|
+
'prime-failed',
|
|
398
|
+
'not-dispatched',
|
|
399
|
+
settled,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const primed: Primed = {
|
|
403
|
+
key: spec.key,
|
|
404
|
+
hash,
|
|
405
|
+
instructions: spec.instructions,
|
|
406
|
+
base: base.session,
|
|
407
|
+
model: observedModel(result),
|
|
408
|
+
lastUsed: Date.now(),
|
|
409
|
+
busy: false,
|
|
410
|
+
};
|
|
411
|
+
this._sessions.set(spec.key, primed);
|
|
412
|
+
this._emit({ type: 'primed', key: spec.key, hash, ms: Math.round(performance.now() - t0) });
|
|
413
|
+
return primed;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private async _drop(
|
|
417
|
+
session: Primed,
|
|
418
|
+
reason: 'idle' | 'capacity' | 'reprime' | 'requested' | 'close',
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
if (this._sessions.get(session.key) === session) this._sessions.delete(session.key);
|
|
421
|
+
await session.cleanup;
|
|
422
|
+
const spare = session.spare;
|
|
423
|
+
session.spare = undefined;
|
|
424
|
+
await this._dispose(spare);
|
|
425
|
+
this._deleteTranscript(session.base?.externalSessionId);
|
|
426
|
+
this._emit({ type: 'evicted', key: session.key, reason });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async _evictIdle(): Promise<void> {
|
|
430
|
+
const cutoff = Date.now() - this._config.idleMs;
|
|
431
|
+
for (const session of [...this._sessions.values()])
|
|
432
|
+
if (!session.busy && session.lastUsed < cutoff) await this._drop(session, 'idle');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async _decide(
|
|
436
|
+
spec: PrimeSpec,
|
|
437
|
+
request: DecisionRequest,
|
|
438
|
+
started: number,
|
|
439
|
+
deadline: number,
|
|
440
|
+
): Promise<DecisionResult> {
|
|
441
|
+
const waitMs = Date.now() - started;
|
|
442
|
+
const signal = request.signal;
|
|
443
|
+
const closed = () =>
|
|
444
|
+
signal?.aborted && !this._closed
|
|
445
|
+
? new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true)
|
|
446
|
+
: new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true);
|
|
447
|
+
if (this._closed || signal?.aborted) throw closed();
|
|
448
|
+
if (Date.now() >= deadline)
|
|
449
|
+
throw new DecisionError(
|
|
450
|
+
'Decision deadline passed before dispatch',
|
|
451
|
+
'timeout',
|
|
452
|
+
'not-dispatched',
|
|
453
|
+
true,
|
|
454
|
+
);
|
|
455
|
+
await this._checkLimits();
|
|
456
|
+
this._sweepTranscripts();
|
|
457
|
+
const hash = primeHash(spec);
|
|
458
|
+
let session = this._sessions.get(spec.key),
|
|
459
|
+
prime: 'warm' | 'cold' = 'warm',
|
|
460
|
+
primeMs = 0;
|
|
461
|
+
if (session && session.hash !== hash) {
|
|
462
|
+
await this._drop(session, 'reprime');
|
|
463
|
+
session = undefined;
|
|
464
|
+
}
|
|
465
|
+
if (!session) {
|
|
466
|
+
while (this._sessions.size >= this._config.maxSessions) {
|
|
467
|
+
const idle = [...this._sessions.values()]
|
|
468
|
+
.filter((s) => !s.busy)
|
|
469
|
+
.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
470
|
+
if (!idle) break;
|
|
471
|
+
await this._drop(idle, 'capacity');
|
|
472
|
+
}
|
|
473
|
+
if (this._closed || signal?.aborted) throw closed();
|
|
474
|
+
const t = performance.now();
|
|
475
|
+
session = await this._prime(spec, hash, deadline);
|
|
476
|
+
primeMs = Math.round(performance.now() - t);
|
|
477
|
+
prime = 'cold';
|
|
478
|
+
}
|
|
479
|
+
await session.cleanup;
|
|
480
|
+
if (this._closed || signal?.aborted) throw closed();
|
|
481
|
+
session.busy = true;
|
|
482
|
+
session.lastUsed = Date.now();
|
|
483
|
+
const t1 = performance.now();
|
|
484
|
+
let branch = session.spare;
|
|
485
|
+
session.spare = undefined;
|
|
486
|
+
let admissionId: string | undefined;
|
|
487
|
+
try {
|
|
488
|
+
if (!branch?.session.alive) {
|
|
489
|
+
await this._dispose(branch);
|
|
490
|
+
branch = this._branch(session);
|
|
491
|
+
try {
|
|
492
|
+
await branch.session.start();
|
|
493
|
+
} catch (error) {
|
|
494
|
+
throw new DecisionError(
|
|
495
|
+
`Branching ${spec.key} failed: ${(error as Error).message}`,
|
|
496
|
+
'transport',
|
|
497
|
+
'not-dispatched',
|
|
498
|
+
true,
|
|
499
|
+
undefined,
|
|
500
|
+
{ cause: error },
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const branchMs = Math.round(performance.now() - t1);
|
|
505
|
+
const t2 = performance.now();
|
|
506
|
+
let result: SessionResult;
|
|
507
|
+
try {
|
|
508
|
+
let refusal: 'timeout' | 'closed' | 'aborted' | 'admission' | undefined;
|
|
509
|
+
const live = branch;
|
|
510
|
+
const onAbort = () => {
|
|
511
|
+
void live.session.interruptNative?.({ timeoutMs: 2_000 });
|
|
512
|
+
};
|
|
513
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
514
|
+
result = await branch.session
|
|
515
|
+
.send(request.input, {
|
|
516
|
+
timeout: Math.max(1, deadline - Date.now()),
|
|
517
|
+
onAdmission: async (attempt) => {
|
|
518
|
+
admissionId = attempt.admissionId;
|
|
519
|
+
const expired = () => {
|
|
520
|
+
refusal = this._closed ? 'closed' : signal?.aborted ? 'aborted' : 'timeout';
|
|
521
|
+
return Error('Decision deadline passed, aborted or host closed before dispatch');
|
|
522
|
+
};
|
|
523
|
+
if (Date.now() >= deadline || this._closed || signal?.aborted) throw expired();
|
|
524
|
+
refusal = 'admission';
|
|
525
|
+
await request.onAdmission?.({
|
|
526
|
+
admissionId: attempt.admissionId!,
|
|
527
|
+
key: spec.key,
|
|
528
|
+
runtime: 'claude',
|
|
529
|
+
});
|
|
530
|
+
refusal = undefined;
|
|
531
|
+
if (Date.now() >= deadline || this._closed || signal?.aborted) throw expired();
|
|
532
|
+
},
|
|
533
|
+
})
|
|
534
|
+
.finally(() => signal?.removeEventListener('abort', onAbort))
|
|
535
|
+
.catch((error) => {
|
|
536
|
+
const attempt = error instanceof SessionTurnError ? error.attempt : undefined;
|
|
537
|
+
if (
|
|
538
|
+
attempt?.dispatch === 'not-dispatched' &&
|
|
539
|
+
attempt.localFailure === 'registration' &&
|
|
540
|
+
refusal
|
|
541
|
+
)
|
|
542
|
+
throw new DecisionError(
|
|
543
|
+
refusal === 'admission'
|
|
544
|
+
? 'Decision admission refused before dispatch'
|
|
545
|
+
: 'Decision deadline passed, aborted or host closed before dispatch',
|
|
546
|
+
refusal,
|
|
547
|
+
'not-dispatched',
|
|
548
|
+
true,
|
|
549
|
+
admissionId,
|
|
550
|
+
{ cause: error },
|
|
551
|
+
);
|
|
552
|
+
throw error;
|
|
553
|
+
});
|
|
554
|
+
} catch (error) {
|
|
555
|
+
if (error instanceof DecisionError) throw error;
|
|
556
|
+
const attempt = error instanceof SessionTurnError ? error.attempt : undefined;
|
|
557
|
+
if (!attempt || attempt.dispatch === 'not-dispatched')
|
|
558
|
+
throw new DecisionError(
|
|
559
|
+
`Decision not dispatched: ${(error as Error).message}`,
|
|
560
|
+
'transport',
|
|
561
|
+
'not-dispatched',
|
|
562
|
+
true,
|
|
563
|
+
admissionId,
|
|
564
|
+
{ cause: error },
|
|
565
|
+
);
|
|
566
|
+
let settled = attempt.nativeOutcome !== 'unknown';
|
|
567
|
+
if (!settled && branch.session.interruptNative)
|
|
568
|
+
settled = (await branch.session.interruptNative({ timeoutMs: 2_000 })) === 'acknowledged';
|
|
569
|
+
const exited = await this._dispose(branch);
|
|
570
|
+
throw new DecisionError(
|
|
571
|
+
`Claude decision ${attempt.localFailure ?? 'failed'}: ${(error as Error).message}`,
|
|
572
|
+
attempt.localFailure === 'timeout' ? 'timeout' : 'transport',
|
|
573
|
+
'attempted',
|
|
574
|
+
settled || exited,
|
|
575
|
+
admissionId,
|
|
576
|
+
{ cause: error },
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const turnMs = Math.round(performance.now() - t2);
|
|
580
|
+
if (result.events.some((e) => e.kind === 'tool_use' || e.kind === 'tool_result')) {
|
|
581
|
+
this._emit({ type: 'violation', key: spec.key, detail: 'tool activity' });
|
|
582
|
+
throw new DecisionError(
|
|
583
|
+
'Decision violated the text-only policy (tool activity)',
|
|
584
|
+
'violation',
|
|
585
|
+
'attempted',
|
|
586
|
+
true,
|
|
587
|
+
admissionId,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (signal?.aborted && result.nativeOutcome !== 'completed')
|
|
591
|
+
throw new DecisionError(
|
|
592
|
+
'Decision aborted; turn interrupted',
|
|
593
|
+
'aborted',
|
|
594
|
+
'attempted',
|
|
595
|
+
result.nativeOutcome !== 'unknown',
|
|
596
|
+
admissionId,
|
|
597
|
+
);
|
|
598
|
+
if (result.nativeOutcome !== 'completed') {
|
|
599
|
+
const limited =
|
|
600
|
+
result.terminal?.apiErrorStatus === 429 ||
|
|
601
|
+
/rate.?limit|usage.?limit/i.test(result.terminal?.reason ?? result.content);
|
|
602
|
+
throw new DecisionError(
|
|
603
|
+
`Claude decision failed (${result.terminal?.subtype ?? 'unknown'})`,
|
|
604
|
+
limited ? 'rate-limited' : 'native-failed',
|
|
605
|
+
'attempted',
|
|
606
|
+
true,
|
|
607
|
+
admissionId,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
session.model ??= observedModel(result);
|
|
611
|
+
const cacheRead = result.tokens?.cacheRead;
|
|
612
|
+
this._emit({
|
|
613
|
+
type: 'decision',
|
|
614
|
+
key: spec.key,
|
|
615
|
+
prime,
|
|
616
|
+
ms: Date.now() - started,
|
|
617
|
+
...(cacheRead !== undefined ? { cacheRead } : {}),
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
key: spec.key,
|
|
621
|
+
admissionId: result.admissionId ?? admissionId!,
|
|
622
|
+
content: result.content,
|
|
623
|
+
...(result.tokens ? { tokens: result.tokens } : {}),
|
|
624
|
+
...(session.model ? { model: session.model } : {}),
|
|
625
|
+
...(result.externalSessionId ? { threadId: result.externalSessionId } : {}),
|
|
626
|
+
prime,
|
|
627
|
+
timing: { waitMs, primeMs, branchMs, turnMs },
|
|
628
|
+
};
|
|
629
|
+
} finally {
|
|
630
|
+
session.busy = false;
|
|
631
|
+
session.lastUsed = Date.now();
|
|
632
|
+
// The decision settled on its native terminal; process exit and the next spare are off the caller's path.
|
|
633
|
+
const done = branch;
|
|
634
|
+
session.cleanup = (async () => {
|
|
635
|
+
await this._dispose(done);
|
|
636
|
+
if (this._config.spare && !this._closed && this._sessions.get(spec.key) === session) {
|
|
637
|
+
const spare = this._branch(session);
|
|
638
|
+
session.spare = spare;
|
|
639
|
+
await spare.session.start().catch(() => {
|
|
640
|
+
if (session.spare === spare) session.spare = undefined;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
})();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function observedModel(result: SessionResult): string | undefined {
|
|
649
|
+
for (const event of result.events) {
|
|
650
|
+
const raw = event.raw as Record<string, unknown> | undefined;
|
|
651
|
+
const message = raw?.message as Record<string, unknown> | undefined;
|
|
652
|
+
if (typeof message?.model === 'string') return message.model;
|
|
653
|
+
}
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
package/src/claude-usage.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { SessionTokens } from
|
|
1
|
+
import type { SessionTokens } from './harness-session';
|
|
2
2
|
|
|
3
3
|
/** Preserve the native payload as well as the counters consumers aggregate. */
|
|
4
4
|
export function parseClaudeUsage(value: unknown): SessionTokens | undefined {
|
|
5
|
-
if (!value || typeof value !==
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
6
6
|
const usage = value as Record<string, unknown>;
|
|
7
7
|
const tokens: SessionTokens = {
|
|
8
8
|
input: count(usage.input_tokens) ?? 0,
|
|
@@ -25,6 +25,5 @@ export function parseClaudeUsage(value: unknown): SessionTokens | undefined {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function count(value: unknown): number | undefined {
|
|
28
|
-
return typeof value ===
|
|
29
|
-
? value : undefined;
|
|
28
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
30
29
|
}
|