@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.
@@ -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
- busy: boolean;
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 this._queue.run(spec.key, async () => {
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
- busy: false,
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
- busy: false,
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.busy && session.lastUsed < cutoff) await this._drop(session, 'idle');
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 session = this._sessions.get(spec.key),
459
- prime: 'warm' | 'cold' = 'warm',
535
+ let prime: 'warm' | 'cold' = 'warm',
460
536
  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');
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 (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;
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.busy = false;
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
- if (this._config.spare && !this._closed && this._sessions.get(spec.key) === session) {
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(() => {