@inixiative/agent-session 0.5.2 → 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 +45 -13
- package/package.json +3 -3
- package/src/claude-agent-sdk-session.ts +136 -3
- package/src/claude-code-session.ts +275 -14
- package/src/claude-primed.ts +153 -35
- package/src/codex-session.ts +339 -607
- package/src/harness-session.ts +52 -2
- package/src/index.ts +6 -4
- package/src/pool.ts +1 -1
- package/src/primed.ts +1 -1
- package/src/tool-gate.ts +61 -0
- package/src/transport.ts +11 -38
- package/src/transports.ts +1 -4
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(() => {
|