@inixiative/agent-session 0.2.0 → 0.3.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 +82 -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 +1507 -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
package/src/pool.ts
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// SubscriptionPool — accounts as instances of a transport
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// An instance is one way of reaching a model with one login: a transport kind
|
|
6
|
+
// plus a profile (CODEX_HOME / CLAUDE_CONFIG_DIR). The pool routes work across
|
|
7
|
+
// instances deterministically using observed limits and local health, leases
|
|
8
|
+
// capacity, and hands threads between instances only where their continuation
|
|
9
|
+
// identity says the native history is shared.
|
|
10
|
+
//
|
|
11
|
+
// The session is the work; an instance supplies capacity. Pool membership
|
|
12
|
+
// grants nothing by itself: callers (Kingdom, Foundry) supply the instances and
|
|
13
|
+
// the organization/preference policy they are allowed to use.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
import type { HarnessSession } from './harness-session';
|
|
19
|
+
import { type LimitSnapshot, mergeLimits } from './limits';
|
|
20
|
+
import { probeClaudeLimits, probeCodexLimits } from './limits-probe';
|
|
21
|
+
import {
|
|
22
|
+
type ExclusionReason,
|
|
23
|
+
type RankedCandidate,
|
|
24
|
+
type RoutingMode,
|
|
25
|
+
rankCandidates,
|
|
26
|
+
type SubscriptionCandidate,
|
|
27
|
+
} from './routing';
|
|
28
|
+
import { TRANSPORTS, type TransportKind } from './transport';
|
|
29
|
+
import { createSession } from './transports';
|
|
30
|
+
|
|
31
|
+
export type PooledTransport = 'claude-cli' | 'codex-mcp' | 'codex-app-server' | 'claude-agent-sdk';
|
|
32
|
+
|
|
33
|
+
export interface InstanceConfig {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly transport: PooledTransport;
|
|
36
|
+
/** CODEX_HOME / CLAUDE_CONFIG_DIR for this login. Undefined: the runtime's default login. */
|
|
37
|
+
readonly profileDirectory?: string;
|
|
38
|
+
/** Extra environment for sessions on this instance. */
|
|
39
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
40
|
+
/**
|
|
41
|
+
* Instances with equal keys can continue each other's threads. Default: the
|
|
42
|
+
* native history home (`codex:home:<dir>` / `claude:home:<dir>`), so distinct
|
|
43
|
+
* logins only share continuity when the caller has arranged shared history.
|
|
44
|
+
*/
|
|
45
|
+
readonly continuationKey?: string;
|
|
46
|
+
readonly organizationIds?: readonly string[];
|
|
47
|
+
/** model → efforts this instance may serve. Undefined: unrestricted. */
|
|
48
|
+
readonly models?: Readonly<Record<string, readonly string[]>>;
|
|
49
|
+
/** Concurrent leases. Default 1. */
|
|
50
|
+
readonly concurrencyLimit?: number;
|
|
51
|
+
readonly enabled?: boolean;
|
|
52
|
+
readonly authentication?: 'subscription' | 'api-key';
|
|
53
|
+
/** Session config merged into every session opened on this instance (bin, spawn, SDK query…). */
|
|
54
|
+
readonly session?: Readonly<Record<string, unknown>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PoolRequest {
|
|
58
|
+
readonly runtime: 'claude' | 'codex';
|
|
59
|
+
readonly model: string;
|
|
60
|
+
readonly effort?: string;
|
|
61
|
+
readonly organizationId?: string;
|
|
62
|
+
readonly preferredInstanceId?: string;
|
|
63
|
+
readonly mode?: RoutingMode;
|
|
64
|
+
/** Restrict to these transports (e.g. only resumable ones). */
|
|
65
|
+
readonly transports?: readonly TransportKind[];
|
|
66
|
+
readonly excludeInstanceIds?: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Common session options; transport-specific fields ride along untyped and are passed through. */
|
|
70
|
+
export interface PooledSessionConfig {
|
|
71
|
+
readonly cwd?: string;
|
|
72
|
+
readonly baseContext?: string;
|
|
73
|
+
readonly timeout?: number;
|
|
74
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
75
|
+
readonly [option: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Lease {
|
|
79
|
+
readonly leaseId: string;
|
|
80
|
+
readonly instanceId: string;
|
|
81
|
+
readonly transport: PooledTransport;
|
|
82
|
+
readonly continuationKey: string;
|
|
83
|
+
readonly released: boolean;
|
|
84
|
+
release(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type PoolEvent =
|
|
88
|
+
| {
|
|
89
|
+
readonly type: 'allocated';
|
|
90
|
+
readonly leaseId: string;
|
|
91
|
+
readonly instanceId: string;
|
|
92
|
+
readonly transport: PooledTransport;
|
|
93
|
+
readonly mode: RoutingMode;
|
|
94
|
+
readonly utilizationPercent: number;
|
|
95
|
+
readonly rank: number;
|
|
96
|
+
}
|
|
97
|
+
| { readonly type: 'released'; readonly leaseId: string; readonly instanceId: string }
|
|
98
|
+
| { readonly type: 'limits'; readonly instanceId: string; readonly limits: LimitSnapshot }
|
|
99
|
+
| {
|
|
100
|
+
readonly type: 'failure';
|
|
101
|
+
readonly instanceId: string;
|
|
102
|
+
readonly kind: FailureKind;
|
|
103
|
+
readonly unavailableUntil: number;
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
readonly type: 'exhausted';
|
|
107
|
+
readonly runtime: string;
|
|
108
|
+
readonly model: string;
|
|
109
|
+
readonly excluded: Readonly<Record<string, ExclusionReason>>;
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
readonly type: 'handoff';
|
|
113
|
+
readonly from: string;
|
|
114
|
+
readonly to: string;
|
|
115
|
+
readonly continuationKey: string;
|
|
116
|
+
readonly externalSessionId: string;
|
|
117
|
+
}
|
|
118
|
+
| { readonly type: 'handoff-refused'; readonly from: string; readonly reason: ContinuityRefusal };
|
|
119
|
+
|
|
120
|
+
export type FailureKind = 'rate-limited' | 'auth' | 'transport' | 'probe';
|
|
121
|
+
export type ContinuityRefusal =
|
|
122
|
+
| 'unknown-instance'
|
|
123
|
+
| 'resume-unsupported'
|
|
124
|
+
| 'unresolved-native-work'
|
|
125
|
+
| 'no-shared-history'
|
|
126
|
+
| 'targets-unavailable';
|
|
127
|
+
|
|
128
|
+
export class PoolExhaustedError extends Error {
|
|
129
|
+
constructor(
|
|
130
|
+
message: string,
|
|
131
|
+
readonly excluded: Readonly<Record<string, ExclusionReason>>,
|
|
132
|
+
) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = 'PoolExhaustedError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export class ContinuityError extends Error {
|
|
138
|
+
constructor(
|
|
139
|
+
message: string,
|
|
140
|
+
readonly reason: ContinuityRefusal,
|
|
141
|
+
readonly excluded?: Readonly<Record<string, ExclusionReason>>,
|
|
142
|
+
) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = 'ContinuityError';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface InstanceStatus {
|
|
149
|
+
readonly id: string;
|
|
150
|
+
readonly transport: PooledTransport;
|
|
151
|
+
readonly runtime: 'claude' | 'codex';
|
|
152
|
+
readonly continuationKey: string;
|
|
153
|
+
readonly enabled: boolean;
|
|
154
|
+
readonly active: number;
|
|
155
|
+
readonly concurrencyLimit: number;
|
|
156
|
+
readonly limits?: LimitSnapshot;
|
|
157
|
+
readonly unavailableUntil?: number;
|
|
158
|
+
readonly consecutiveFailures: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface InstanceState {
|
|
162
|
+
readonly config: InstanceConfig;
|
|
163
|
+
readonly runtime: 'claude' | 'codex';
|
|
164
|
+
readonly continuationKey: string;
|
|
165
|
+
readonly env: Record<string, string | undefined>;
|
|
166
|
+
readonly leases: Set<string>;
|
|
167
|
+
limits?: LimitSnapshot;
|
|
168
|
+
unavailableUntil?: number;
|
|
169
|
+
failures: number;
|
|
170
|
+
/** Last poll that failed or returned nothing; not repeated within the probe cooldown. */
|
|
171
|
+
emptyProbeAt?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface SubscriptionPoolOptions {
|
|
175
|
+
readonly instances: readonly InstanceConfig[];
|
|
176
|
+
/** Older limit observations exclude an instance until refreshed. Default 15 min. */
|
|
177
|
+
readonly maximumObservationAgeMs?: number;
|
|
178
|
+
/** First cooldown after a failure; doubles per consecutive failure up to 10 min. Default 60 s. */
|
|
179
|
+
readonly failureCooldownMs?: number;
|
|
180
|
+
/** Utilization reserved per active lease when ranking. Default 0. */
|
|
181
|
+
readonly reservationPercent?: number;
|
|
182
|
+
/** A poll that failed or returned no limits is not repeated for this long. Default 60 s. */
|
|
183
|
+
readonly probeCooldownMs?: number;
|
|
184
|
+
/** Limit poll for one instance. Default: probeCodexLimits / probeClaudeLimits with the instance profile. */
|
|
185
|
+
readonly probe?: (
|
|
186
|
+
instance: InstanceStatus & { readonly env: Readonly<Record<string, string | undefined>> },
|
|
187
|
+
) => Promise<LimitSnapshot | undefined>;
|
|
188
|
+
readonly now?: () => number;
|
|
189
|
+
readonly onEvent?: (event: PoolEvent) => void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const PROFILE_VARIABLE = { codex: 'CODEX_HOME', claude: 'CLAUDE_CONFIG_DIR' } as const;
|
|
193
|
+
|
|
194
|
+
export class SubscriptionPool {
|
|
195
|
+
private readonly _instances = new Map<string, InstanceState>();
|
|
196
|
+
private readonly _options: SubscriptionPoolOptions;
|
|
197
|
+
private readonly _maxAge: number;
|
|
198
|
+
private readonly _cooldown: number;
|
|
199
|
+
private readonly _listeners = new Set<(event: PoolEvent) => void>();
|
|
200
|
+
|
|
201
|
+
constructor(options: SubscriptionPoolOptions) {
|
|
202
|
+
this._options = options;
|
|
203
|
+
this._maxAge = options.maximumObservationAgeMs ?? 15 * 60_000;
|
|
204
|
+
this._cooldown = options.failureCooldownMs ?? 60_000;
|
|
205
|
+
if (options.onEvent) this._listeners.add(options.onEvent);
|
|
206
|
+
for (const config of options.instances) {
|
|
207
|
+
if (!config.id || this._instances.has(config.id))
|
|
208
|
+
throw Error(`Duplicate or empty instance id "${config.id}"`);
|
|
209
|
+
const descriptor = TRANSPORTS[config.transport];
|
|
210
|
+
if (descriptor?.status !== 'implemented' || descriptor.runtime === 'external')
|
|
211
|
+
throw Error(`Instance ${config.id}: transport "${config.transport}" cannot be pooled`);
|
|
212
|
+
const runtime = descriptor.runtime;
|
|
213
|
+
const variable = PROFILE_VARIABLE[runtime];
|
|
214
|
+
// The profile variable is always pinned: a directory selects it, undefined selects the default login.
|
|
215
|
+
// Caller env can never move a leased session to a different login.
|
|
216
|
+
const env: Record<string, string | undefined> = {
|
|
217
|
+
...config.env,
|
|
218
|
+
[variable]: config.profileDirectory ?? config.env?.[variable],
|
|
219
|
+
};
|
|
220
|
+
const home = resolve(
|
|
221
|
+
env[variable] ?? join(homedir(), runtime === 'codex' ? '.codex' : '.claude'),
|
|
222
|
+
);
|
|
223
|
+
const limit = config.concurrencyLimit ?? 1;
|
|
224
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
225
|
+
throw Error(`Instance ${config.id}: concurrencyLimit must be a positive integer`);
|
|
226
|
+
this._instances.set(config.id, {
|
|
227
|
+
config,
|
|
228
|
+
runtime,
|
|
229
|
+
env,
|
|
230
|
+
leases: new Set(),
|
|
231
|
+
failures: 0,
|
|
232
|
+
continuationKey: config.continuationKey ?? `${runtime}:home:${home}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
onEvent(listener: (event: PoolEvent) => void): () => void {
|
|
238
|
+
this._listeners.add(listener);
|
|
239
|
+
return () => {
|
|
240
|
+
this._listeners.delete(listener);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
instances(): InstanceStatus[] {
|
|
245
|
+
return [...this._instances.values()].map((state) => this._status(state));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Record limits observed elsewhere (a session's rate_limit event, a primed host). */
|
|
249
|
+
observeLimits(instanceId: string, limits: LimitSnapshot): void {
|
|
250
|
+
const state = this._state(instanceId);
|
|
251
|
+
state.limits = mergeLimits(state.limits, limits);
|
|
252
|
+
this._emit({ type: 'limits', instanceId, limits: state.limits });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Poll limits for the given (default: all enabled) instances. A failed poll is a probe failure, not a limit. */
|
|
256
|
+
async refreshLimits(ids?: readonly string[]): Promise<void> {
|
|
257
|
+
const targets = (ids ?? [...this._instances.keys()])
|
|
258
|
+
.map((id) => this._state(id))
|
|
259
|
+
.filter((s) => s.config.enabled !== false);
|
|
260
|
+
await Promise.all(
|
|
261
|
+
targets.map(async (state) => {
|
|
262
|
+
try {
|
|
263
|
+
const env = state.env;
|
|
264
|
+
const limits = this._options.probe
|
|
265
|
+
? await this._options.probe({ ...this._status(state), env })
|
|
266
|
+
: state.runtime === 'codex'
|
|
267
|
+
? await probeCodexLimits({
|
|
268
|
+
env,
|
|
269
|
+
bin: state.config.session?.bin as string | undefined,
|
|
270
|
+
})
|
|
271
|
+
: await probeClaudeLimits({
|
|
272
|
+
env,
|
|
273
|
+
bin: state.config.session?.bin as string | undefined,
|
|
274
|
+
});
|
|
275
|
+
if (limits) {
|
|
276
|
+
state.emptyProbeAt = undefined;
|
|
277
|
+
this.observeLimits(state.config.id, limits);
|
|
278
|
+
} else state.emptyProbeAt = this._now();
|
|
279
|
+
} catch {
|
|
280
|
+
state.emptyProbeAt = this._now();
|
|
281
|
+
this.reportFailure(state.config.id, 'probe');
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Deterministic ranking of eligible instances, with the reason each other instance is excluded. */
|
|
288
|
+
rank(request: PoolRequest): {
|
|
289
|
+
candidates: RankedCandidate[];
|
|
290
|
+
excluded: Record<string, ExclusionReason>;
|
|
291
|
+
} {
|
|
292
|
+
const now = this._now();
|
|
293
|
+
const effort = request.effort ?? 'default';
|
|
294
|
+
const states = [...this._instances.values()].filter((s) => this._eligibleShape(s, request));
|
|
295
|
+
const candidates: SubscriptionCandidate[] = states.map((state) => {
|
|
296
|
+
const reserved = state.leases.size * (this._options.reservationPercent ?? 0);
|
|
297
|
+
const limits = state.limits;
|
|
298
|
+
return {
|
|
299
|
+
id: state.config.id,
|
|
300
|
+
runtime: state.runtime,
|
|
301
|
+
organizationIds: state.config.organizationIds ?? [],
|
|
302
|
+
authentication: state.config.authentication ?? 'subscription',
|
|
303
|
+
enabled: state.config.enabled !== false,
|
|
304
|
+
models: state.config.models ?? { [request.model]: [effort] },
|
|
305
|
+
activeRuns: state.leases.size,
|
|
306
|
+
concurrencyLimit: state.config.concurrencyLimit ?? 1,
|
|
307
|
+
windows: (limits?.windows ?? []).map((w) => ({
|
|
308
|
+
usedPercent: w.usedPercent,
|
|
309
|
+
reservedPercent: reserved,
|
|
310
|
+
observedAt: limits!.observedAt,
|
|
311
|
+
resetsAt: w.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
312
|
+
})),
|
|
313
|
+
blocked: this._blocked(state, now),
|
|
314
|
+
...(state.unavailableUntil !== undefined
|
|
315
|
+
? { unavailableUntil: state.unavailableUntil }
|
|
316
|
+
: {}),
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
return rankCandidates(
|
|
320
|
+
{
|
|
321
|
+
runtime: request.runtime,
|
|
322
|
+
model: request.model,
|
|
323
|
+
effort,
|
|
324
|
+
now,
|
|
325
|
+
maximumObservationAgeMs: this._maxAge,
|
|
326
|
+
mode: request.mode,
|
|
327
|
+
organizationId: request.organizationId,
|
|
328
|
+
preferredId: request.preferredInstanceId,
|
|
329
|
+
},
|
|
330
|
+
candidates,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Lease the best instance now, or throw PoolExhaustedError with every exclusion reason. */
|
|
335
|
+
acquire(request: PoolRequest): Lease {
|
|
336
|
+
const { candidates, excluded } = this.rank(request);
|
|
337
|
+
const best = candidates[0];
|
|
338
|
+
if (!best) {
|
|
339
|
+
this._emit({ type: 'exhausted', runtime: request.runtime, model: request.model, excluded });
|
|
340
|
+
throw new PoolExhaustedError(
|
|
341
|
+
`No eligible ${request.runtime} instance for ${request.model}: ${
|
|
342
|
+
Object.entries(excluded)
|
|
343
|
+
.map(([id, why]) => `${id}=${why}`)
|
|
344
|
+
.join(', ') || 'none configured'
|
|
345
|
+
}`,
|
|
346
|
+
excluded,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return this._lease(
|
|
350
|
+
this._state(best.accountId),
|
|
351
|
+
request.mode ?? 'quartile-balanced',
|
|
352
|
+
best.utilizationPercent,
|
|
353
|
+
0,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Refresh stale limits, lease an instance and open a session on it. The lease ends with the session. */
|
|
358
|
+
async open(
|
|
359
|
+
request: PoolRequest,
|
|
360
|
+
config: PooledSessionConfig = {},
|
|
361
|
+
): Promise<{ session: HarnessSession; lease: Lease }> {
|
|
362
|
+
await this._refreshStale(request);
|
|
363
|
+
const lease = this.acquire(request);
|
|
364
|
+
try {
|
|
365
|
+
return {
|
|
366
|
+
session: this._session(lease, {
|
|
367
|
+
model: request.model,
|
|
368
|
+
...(request.effort ? { effort: request.effort } : {}),
|
|
369
|
+
...config,
|
|
370
|
+
}),
|
|
371
|
+
lease,
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
lease.release();
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Continue a thread on another instance (AS-003). Allowed only when both
|
|
381
|
+
* transports resume natively, the instances share a continuation key and the
|
|
382
|
+
* old session has no native work of unknown outcome. The old session is killed.
|
|
383
|
+
*/
|
|
384
|
+
async continueOn(
|
|
385
|
+
binding: {
|
|
386
|
+
readonly instanceId: string;
|
|
387
|
+
readonly externalSessionId: string;
|
|
388
|
+
readonly session?: HarnessSession;
|
|
389
|
+
readonly lease?: Lease;
|
|
390
|
+
},
|
|
391
|
+
request: PoolRequest,
|
|
392
|
+
config: PooledSessionConfig = {},
|
|
393
|
+
): Promise<{ session: HarnessSession; lease: Lease }> {
|
|
394
|
+
const refuse = (
|
|
395
|
+
reason: ContinuityRefusal,
|
|
396
|
+
message: string,
|
|
397
|
+
excluded?: Record<string, ExclusionReason>,
|
|
398
|
+
): never => {
|
|
399
|
+
this._emit({ type: 'handoff-refused', from: binding.instanceId, reason });
|
|
400
|
+
throw new ContinuityError(message, reason, excluded);
|
|
401
|
+
};
|
|
402
|
+
const unresolved = () =>
|
|
403
|
+
binding.session?.attempts?.some(
|
|
404
|
+
(a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown',
|
|
405
|
+
);
|
|
406
|
+
const from = this._instances.get(binding.instanceId);
|
|
407
|
+
if (!from) return refuse('unknown-instance', `Unknown instance ${binding.instanceId}`);
|
|
408
|
+
if (TRANSPORTS[from.config.transport].capabilities.resume !== 'native')
|
|
409
|
+
return refuse(
|
|
410
|
+
'resume-unsupported',
|
|
411
|
+
`${from.config.transport} cannot resume a thread in another process`,
|
|
412
|
+
);
|
|
413
|
+
if (unresolved())
|
|
414
|
+
return refuse(
|
|
415
|
+
'unresolved-native-work',
|
|
416
|
+
'The session has native work of unknown outcome; continuing elsewhere could repeat or fork it',
|
|
417
|
+
);
|
|
418
|
+
const shared = [...this._instances.values()].filter(
|
|
419
|
+
(s) =>
|
|
420
|
+
s !== from &&
|
|
421
|
+
s.continuationKey === from.continuationKey &&
|
|
422
|
+
s.runtime === from.runtime &&
|
|
423
|
+
TRANSPORTS[s.config.transport].capabilities.resume === 'native',
|
|
424
|
+
);
|
|
425
|
+
if (!shared.length)
|
|
426
|
+
return refuse(
|
|
427
|
+
'no-shared-history',
|
|
428
|
+
`No other instance shares continuation key ${from.continuationKey}; its native history is not visible elsewhere`,
|
|
429
|
+
);
|
|
430
|
+
const allowed = new Set(shared.map((s) => s.config.id));
|
|
431
|
+
await this._refreshStale(request, allowed);
|
|
432
|
+
const { candidates, excluded } = this.rank({
|
|
433
|
+
...request,
|
|
434
|
+
excludeInstanceIds: [
|
|
435
|
+
...(request.excludeInstanceIds ?? []),
|
|
436
|
+
...[...this._instances.keys()].filter((id) => !allowed.has(id)),
|
|
437
|
+
],
|
|
438
|
+
});
|
|
439
|
+
const best = candidates[0];
|
|
440
|
+
if (!best)
|
|
441
|
+
return refuse(
|
|
442
|
+
'targets-unavailable',
|
|
443
|
+
'Instances sharing this history are unavailable',
|
|
444
|
+
excluded,
|
|
445
|
+
);
|
|
446
|
+
// Re-check after the refresh await, then stop the old session in the same synchronous step: no send can slip in between.
|
|
447
|
+
if (unresolved())
|
|
448
|
+
return refuse(
|
|
449
|
+
'unresolved-native-work',
|
|
450
|
+
'The session has native work of unknown outcome; continuing elsewhere could repeat or fork it',
|
|
451
|
+
);
|
|
452
|
+
binding.session?.kill();
|
|
453
|
+
binding.lease?.release();
|
|
454
|
+
const lease = this._lease(
|
|
455
|
+
this._state(best.accountId),
|
|
456
|
+
request.mode ?? 'quartile-balanced',
|
|
457
|
+
best.utilizationPercent,
|
|
458
|
+
0,
|
|
459
|
+
);
|
|
460
|
+
try {
|
|
461
|
+
const session = this._session(
|
|
462
|
+
lease,
|
|
463
|
+
{ model: request.model, ...(request.effort ? { effort: request.effort } : {}), ...config },
|
|
464
|
+
{ externalSessionId: binding.externalSessionId },
|
|
465
|
+
);
|
|
466
|
+
this._emit({
|
|
467
|
+
type: 'handoff',
|
|
468
|
+
from: from.config.id,
|
|
469
|
+
to: best.accountId,
|
|
470
|
+
continuationKey: from.continuationKey,
|
|
471
|
+
externalSessionId: binding.externalSessionId,
|
|
472
|
+
});
|
|
473
|
+
return { session, lease };
|
|
474
|
+
} catch (error) {
|
|
475
|
+
lease.release();
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Mark an instance unavailable for a cooldown that doubles per consecutive failure (max 10 min). */
|
|
481
|
+
reportFailure(instanceId: string, kind: FailureKind): void {
|
|
482
|
+
const state = this._state(instanceId);
|
|
483
|
+
state.failures++;
|
|
484
|
+
state.unavailableUntil =
|
|
485
|
+
this._now() + Math.min(this._cooldown * 2 ** (state.failures - 1), 10 * 60_000);
|
|
486
|
+
if (kind === 'rate-limited')
|
|
487
|
+
state.limits = mergeLimits(state.limits, {
|
|
488
|
+
runtime: state.runtime,
|
|
489
|
+
source: 'stream',
|
|
490
|
+
observedAt: this._now(),
|
|
491
|
+
windows: [],
|
|
492
|
+
blocked: true,
|
|
493
|
+
reachedType: 'reported',
|
|
494
|
+
});
|
|
495
|
+
this._emit({ type: 'failure', instanceId, kind, unavailableUntil: state.unavailableUntil });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
reportSuccess(instanceId: string): void {
|
|
499
|
+
const state = this._state(instanceId);
|
|
500
|
+
state.failures = 0;
|
|
501
|
+
state.unavailableUntil = undefined;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
private _now() {
|
|
507
|
+
return this._options.now?.() ?? Date.now();
|
|
508
|
+
}
|
|
509
|
+
private _emit(event: PoolEvent) {
|
|
510
|
+
for (const listener of this._listeners) {
|
|
511
|
+
try {
|
|
512
|
+
listener(event);
|
|
513
|
+
} catch {
|
|
514
|
+
/* observers only */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
private _state(id: string): InstanceState {
|
|
519
|
+
const state = this._instances.get(id);
|
|
520
|
+
if (!state) throw Error(`Unknown instance ${id}`);
|
|
521
|
+
return state;
|
|
522
|
+
}
|
|
523
|
+
private _status(state: InstanceState): InstanceStatus {
|
|
524
|
+
return {
|
|
525
|
+
id: state.config.id,
|
|
526
|
+
transport: state.config.transport,
|
|
527
|
+
runtime: state.runtime,
|
|
528
|
+
continuationKey: state.continuationKey,
|
|
529
|
+
enabled: state.config.enabled !== false,
|
|
530
|
+
active: state.leases.size,
|
|
531
|
+
concurrencyLimit: state.config.concurrencyLimit ?? 1,
|
|
532
|
+
consecutiveFailures: state.failures,
|
|
533
|
+
...(state.limits ? { limits: state.limits } : {}),
|
|
534
|
+
...(state.unavailableUntil !== undefined ? { unavailableUntil: state.unavailableUntil } : {}),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
private _eligibleShape(state: InstanceState, request: PoolRequest): boolean {
|
|
538
|
+
return (
|
|
539
|
+
state.runtime === request.runtime &&
|
|
540
|
+
(!request.transports || request.transports.includes(state.config.transport)) &&
|
|
541
|
+
!request.excludeInstanceIds?.includes(state.config.id)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
/** Blocked unless every window that reached 100% has since reset. */
|
|
545
|
+
private _blocked(state: InstanceState, now: number): boolean {
|
|
546
|
+
if (!state.limits?.blocked) return false;
|
|
547
|
+
const exhausted = state.limits.windows.filter((w) => w.usedPercent >= 100);
|
|
548
|
+
return !exhausted.length || exhausted.some((w) => w.resetsAt === undefined || w.resetsAt > now);
|
|
549
|
+
}
|
|
550
|
+
private async _refreshStale(request: PoolRequest, only?: Set<string>): Promise<void> {
|
|
551
|
+
const now = this._now();
|
|
552
|
+
const cooldown = this._options.probeCooldownMs ?? 60_000;
|
|
553
|
+
const stale = [...this._instances.values()].filter(
|
|
554
|
+
(s) =>
|
|
555
|
+
this._eligibleShape(s, request) &&
|
|
556
|
+
s.config.enabled !== false &&
|
|
557
|
+
(!only || only.has(s.config.id)) &&
|
|
558
|
+
(s.emptyProbeAt === undefined || now - s.emptyProbeAt >= cooldown) &&
|
|
559
|
+
(!s.limits ||
|
|
560
|
+
now - s.limits.observedAt > this._maxAge ||
|
|
561
|
+
s.limits.windows.some((w) => w.resetsAt !== undefined && w.resetsAt <= now) ||
|
|
562
|
+
(this._blocked(s, now) &&
|
|
563
|
+
(s.unavailableUntil === undefined || s.unavailableUntil <= now))),
|
|
564
|
+
);
|
|
565
|
+
if (stale.length) await this.refreshLimits(stale.map((s) => s.config.id));
|
|
566
|
+
}
|
|
567
|
+
private _lease(
|
|
568
|
+
state: InstanceState,
|
|
569
|
+
mode: RoutingMode,
|
|
570
|
+
utilizationPercent: number,
|
|
571
|
+
rank: number,
|
|
572
|
+
): Lease {
|
|
573
|
+
const leaseId = crypto.randomUUID();
|
|
574
|
+
state.leases.add(leaseId);
|
|
575
|
+
let released = false;
|
|
576
|
+
const lease: Lease = {
|
|
577
|
+
leaseId,
|
|
578
|
+
instanceId: state.config.id,
|
|
579
|
+
transport: state.config.transport,
|
|
580
|
+
continuationKey: state.continuationKey,
|
|
581
|
+
get released() {
|
|
582
|
+
return released;
|
|
583
|
+
},
|
|
584
|
+
release: () => {
|
|
585
|
+
if (released) return;
|
|
586
|
+
released = true;
|
|
587
|
+
state.leases.delete(leaseId);
|
|
588
|
+
this._emit({ type: 'released', leaseId, instanceId: state.config.id });
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
this._emit({
|
|
592
|
+
type: 'allocated',
|
|
593
|
+
leaseId,
|
|
594
|
+
instanceId: state.config.id,
|
|
595
|
+
transport: state.config.transport,
|
|
596
|
+
mode,
|
|
597
|
+
utilizationPercent,
|
|
598
|
+
rank,
|
|
599
|
+
});
|
|
600
|
+
return lease;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Create a session bound to its lease: the lease ends when the session ends,
|
|
604
|
+
* is killed (even before start) or fails to start, and a session whose lease
|
|
605
|
+
* ended cannot start again (e.g. an automatic --resume restart) outside the pool.
|
|
606
|
+
*/
|
|
607
|
+
private _session(
|
|
608
|
+
lease: Lease,
|
|
609
|
+
config: PooledSessionConfig,
|
|
610
|
+
forced: Record<string, unknown> = {},
|
|
611
|
+
): HarnessSession {
|
|
612
|
+
const state = this._state(lease.instanceId);
|
|
613
|
+
const merged = {
|
|
614
|
+
...config,
|
|
615
|
+
...state.config.session,
|
|
616
|
+
env: { ...(config.env as Record<string, string | undefined> | undefined), ...state.env },
|
|
617
|
+
...forced,
|
|
618
|
+
};
|
|
619
|
+
const session = createSession(state.config.transport, merged as never);
|
|
620
|
+
session.onEvent((event) => {
|
|
621
|
+
if (event.kind === 'rate_limit' && event.limits)
|
|
622
|
+
this.observeLimits(state.config.id, event.limits);
|
|
623
|
+
else if (event.kind === 'result' && event.nativeOutcome === 'completed')
|
|
624
|
+
this.reportSuccess(state.config.id);
|
|
625
|
+
else if (event.kind === 'result' && event.terminal?.apiErrorStatus === 429)
|
|
626
|
+
this.reportFailure(state.config.id, 'rate-limited');
|
|
627
|
+
else if (event.kind === 'session_end') lease.release();
|
|
628
|
+
});
|
|
629
|
+
const start = session.start.bind(session),
|
|
630
|
+
kill = session.kill.bind(session);
|
|
631
|
+
// Own properties shadow the prototype, so the session's internal restart path goes through them too.
|
|
632
|
+
Object.assign(session, {
|
|
633
|
+
start: async () => {
|
|
634
|
+
if (lease.released) throw Error('Pool lease released; open a new session through the pool');
|
|
635
|
+
try {
|
|
636
|
+
await start();
|
|
637
|
+
} catch (error) {
|
|
638
|
+
lease.release();
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
kill: () => {
|
|
643
|
+
try {
|
|
644
|
+
kill();
|
|
645
|
+
} finally {
|
|
646
|
+
lease.release();
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
return session;
|
|
651
|
+
}
|
|
652
|
+
}
|