@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
package/src/primed.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Primed decision sessions — one live, warm session per middleware role
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Each key (Foundry: `${threadId}:aux:${role}`) owns one long-lived session,
|
|
6
|
+
// primed once with its stable context: role instructions plus owned content.
|
|
7
|
+
// That primed state is the cached prefix. Every decision runs on a fresh branch
|
|
8
|
+
// of the primed state and is then discarded, so one cycle never sees another's
|
|
9
|
+
// turns. A key is re-primed only when its prime hash changes, after eviction,
|
|
10
|
+
// or after its process is lost; those are the only cold paths.
|
|
11
|
+
//
|
|
12
|
+
// Decisions are text-only: the launch policy removes tool surfaces, and any
|
|
13
|
+
// tool, command or approval activity is a violation that fails the decision.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import type { SessionTokens } from './harness-session';
|
|
18
|
+
import type { LimitSnapshot } from './limits';
|
|
19
|
+
|
|
20
|
+
export interface PrimeSpec {
|
|
21
|
+
/** Session identity. One live primed session per key. */
|
|
22
|
+
readonly key: string;
|
|
23
|
+
/** Role instructions (developer/system role). */
|
|
24
|
+
readonly instructions: string;
|
|
25
|
+
/** Stable owned context (layer content), primed as data in the user role. May be empty. */
|
|
26
|
+
readonly context?: string;
|
|
27
|
+
/** Content hash of instructions and context. Re-primes when it changes. Default: sha256 of both. */
|
|
28
|
+
readonly hash?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DecisionAdmission {
|
|
32
|
+
readonly admissionId: string;
|
|
33
|
+
readonly key: string;
|
|
34
|
+
readonly runtime: 'codex' | 'claude';
|
|
35
|
+
/** Native identity of the branch the decision will run on, when known before the write. */
|
|
36
|
+
readonly threadId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DecisionRequest {
|
|
40
|
+
/** The per-cycle input, run on top of the primed state. */
|
|
41
|
+
readonly input: string;
|
|
42
|
+
readonly timeoutMs?: number;
|
|
43
|
+
/** Required registration before the native write; a rejection means nothing was dispatched. */
|
|
44
|
+
readonly onAdmission?: (admission: DecisionAdmission) => void | Promise<void>;
|
|
45
|
+
/** JSON Schema constraining the final message (Codex only). */
|
|
46
|
+
readonly outputSchema?: Readonly<Record<string, unknown>>;
|
|
47
|
+
/** Cancel: refused before dispatch, or the running turn is interrupted and settled. */
|
|
48
|
+
readonly signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DecisionResult {
|
|
52
|
+
readonly key: string;
|
|
53
|
+
readonly admissionId: string;
|
|
54
|
+
readonly content: string;
|
|
55
|
+
readonly tokens?: SessionTokens;
|
|
56
|
+
/** Model the runtime reported serving the session. */
|
|
57
|
+
readonly model?: string;
|
|
58
|
+
readonly threadId?: string;
|
|
59
|
+
readonly turnId?: string;
|
|
60
|
+
/** `warm`: forked from the key's primed state; `cold`: ran inline (no primed session for this context yet). */
|
|
61
|
+
readonly prime: 'warm' | 'cold';
|
|
62
|
+
/** A second branch was started for this slow decision; the result is the first to finish. */
|
|
63
|
+
readonly hedged?: boolean;
|
|
64
|
+
/** The runtime fell back from websockets to HTTPS during this decision's turn. */
|
|
65
|
+
readonly transportFallback?: boolean;
|
|
66
|
+
readonly timing: {
|
|
67
|
+
readonly waitMs: number;
|
|
68
|
+
readonly primeMs: number;
|
|
69
|
+
readonly branchMs: number;
|
|
70
|
+
readonly turnMs: number;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type DecisionFailure =
|
|
75
|
+
| 'closed'
|
|
76
|
+
| 'busy'
|
|
77
|
+
| 'admission'
|
|
78
|
+
| 'rate-limited'
|
|
79
|
+
| 'auth'
|
|
80
|
+
| 'timeout'
|
|
81
|
+
| 'aborted'
|
|
82
|
+
| 'violation'
|
|
83
|
+
| 'transport'
|
|
84
|
+
| 'native-failed'
|
|
85
|
+
| 'prime-failed';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `dispatch: "not-dispatched"`: no model input was written for this decision.
|
|
89
|
+
* `settled: false`: a native turn may still be running; its process is being recycled.
|
|
90
|
+
*/
|
|
91
|
+
export class DecisionError extends Error {
|
|
92
|
+
constructor(
|
|
93
|
+
message: string,
|
|
94
|
+
readonly reason: DecisionFailure,
|
|
95
|
+
readonly dispatch: 'not-dispatched' | 'attempted',
|
|
96
|
+
readonly settled: boolean,
|
|
97
|
+
readonly admissionId?: string,
|
|
98
|
+
options?: ErrorOptions,
|
|
99
|
+
) {
|
|
100
|
+
super(message, options);
|
|
101
|
+
this.name = 'DecisionError';
|
|
102
|
+
}
|
|
103
|
+
/** A second branch was started for this decision before it failed; both were stopped. */
|
|
104
|
+
hedged?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type PrimedEvent =
|
|
108
|
+
| { readonly type: 'process-started'; readonly generation: number; readonly ms: number }
|
|
109
|
+
| { readonly type: 'process-recycled'; readonly generation: number; readonly reason: string }
|
|
110
|
+
| { readonly type: 'primed'; readonly key: string; readonly hash: string; readonly ms: number }
|
|
111
|
+
| {
|
|
112
|
+
readonly type: 'evicted';
|
|
113
|
+
readonly key: string;
|
|
114
|
+
readonly reason: 'idle' | 'capacity' | 'reprime' | 'requested' | 'process-lost' | 'close';
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
readonly type: 'decision';
|
|
118
|
+
readonly key: string;
|
|
119
|
+
readonly prime: 'warm' | 'cold';
|
|
120
|
+
readonly ms: number;
|
|
121
|
+
readonly cacheRead?: number;
|
|
122
|
+
readonly input?: number;
|
|
123
|
+
}
|
|
124
|
+
| { readonly type: 'limits'; readonly limits: LimitSnapshot }
|
|
125
|
+
| { readonly type: 'violation'; readonly key: string; readonly detail: string }
|
|
126
|
+
| { readonly type: 'hedged'; readonly key: string; readonly afterMs: number }
|
|
127
|
+
| { readonly type: 'transport-fallback'; readonly key: string };
|
|
128
|
+
|
|
129
|
+
export interface PrimedSnapshot {
|
|
130
|
+
readonly runtime: 'codex' | 'claude';
|
|
131
|
+
readonly closed: boolean;
|
|
132
|
+
readonly generation: number;
|
|
133
|
+
readonly processAlive: boolean;
|
|
134
|
+
readonly sessions: number;
|
|
135
|
+
readonly active: number;
|
|
136
|
+
readonly waiting: number;
|
|
137
|
+
readonly limits?: LimitSnapshot;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface PrimedSessions {
|
|
141
|
+
readonly runtime: 'codex' | 'claude';
|
|
142
|
+
decide(spec: PrimeSpec, request: DecisionRequest): Promise<DecisionResult>;
|
|
143
|
+
/** Drop a key's primed session; the next decision re-primes it. */
|
|
144
|
+
evict(key: string): Promise<void>;
|
|
145
|
+
limits(): LimitSnapshot | undefined;
|
|
146
|
+
snapshot(): PrimedSnapshot;
|
|
147
|
+
close(): Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function primeHash(spec: PrimeSpec): string {
|
|
151
|
+
return (
|
|
152
|
+
spec.hash ??
|
|
153
|
+
createHash('sha256')
|
|
154
|
+
.update(spec.instructions)
|
|
155
|
+
.update('\0')
|
|
156
|
+
.update(spec.context ?? '')
|
|
157
|
+
.digest('hex')
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** FIFO counting semaphore with deadline-bounded waits. */
|
|
162
|
+
export class Slots {
|
|
163
|
+
private _active = 0;
|
|
164
|
+
private _waiters: Array<{
|
|
165
|
+
grant(): void;
|
|
166
|
+
fail(error: Error): void;
|
|
167
|
+
timer: ReturnType<typeof setTimeout>;
|
|
168
|
+
}> = [];
|
|
169
|
+
constructor(private readonly _limit: number) {
|
|
170
|
+
if (!Number.isSafeInteger(_limit) || _limit < 1)
|
|
171
|
+
throw Error('Concurrency limit must be a positive integer');
|
|
172
|
+
}
|
|
173
|
+
get active() {
|
|
174
|
+
return this._active;
|
|
175
|
+
}
|
|
176
|
+
get waiting() {
|
|
177
|
+
return this._waiters.length;
|
|
178
|
+
}
|
|
179
|
+
acquire(deadline: number, signal?: AbortSignal): Promise<() => void> {
|
|
180
|
+
if (signal?.aborted)
|
|
181
|
+
return Promise.reject(
|
|
182
|
+
new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true),
|
|
183
|
+
);
|
|
184
|
+
const release = () => {
|
|
185
|
+
let released = false;
|
|
186
|
+
return () => {
|
|
187
|
+
if (released) return;
|
|
188
|
+
released = true;
|
|
189
|
+
const next = this._waiters.shift();
|
|
190
|
+
if (next) {
|
|
191
|
+
clearTimeout(next.timer);
|
|
192
|
+
next.grant();
|
|
193
|
+
} else this._active--;
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
if (this._active < this._limit) {
|
|
197
|
+
this._active++;
|
|
198
|
+
return Promise.resolve(release());
|
|
199
|
+
}
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const leave = (error: DecisionError) => {
|
|
202
|
+
const index = this._waiters.indexOf(waiter);
|
|
203
|
+
signal?.removeEventListener('abort', onAbort);
|
|
204
|
+
if (index >= 0) {
|
|
205
|
+
this._waiters.splice(index, 1);
|
|
206
|
+
clearTimeout(waiter.timer);
|
|
207
|
+
reject(error);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const onAbort = () =>
|
|
211
|
+
leave(
|
|
212
|
+
new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true),
|
|
213
|
+
);
|
|
214
|
+
const waiter = {
|
|
215
|
+
grant: () => {
|
|
216
|
+
signal?.removeEventListener('abort', onAbort);
|
|
217
|
+
resolve(release());
|
|
218
|
+
},
|
|
219
|
+
fail: (error: Error) => {
|
|
220
|
+
signal?.removeEventListener('abort', onAbort);
|
|
221
|
+
reject(error);
|
|
222
|
+
},
|
|
223
|
+
timer: setTimeout(
|
|
224
|
+
() =>
|
|
225
|
+
leave(
|
|
226
|
+
new DecisionError(
|
|
227
|
+
'Decision expired waiting for a session slot',
|
|
228
|
+
'busy',
|
|
229
|
+
'not-dispatched',
|
|
230
|
+
true,
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
Math.max(0, deadline - Date.now()),
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
237
|
+
this._waiters.push(waiter);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** A slot only if one is free now (never queues). */
|
|
241
|
+
tryAcquire(): (() => void) | undefined {
|
|
242
|
+
if (this._active >= this._limit || this._waiters.length) return undefined;
|
|
243
|
+
this._active++;
|
|
244
|
+
let released = false;
|
|
245
|
+
return () => {
|
|
246
|
+
if (released) return;
|
|
247
|
+
released = true;
|
|
248
|
+
const next = this._waiters.shift();
|
|
249
|
+
if (next) {
|
|
250
|
+
clearTimeout(next.timer);
|
|
251
|
+
next.grant();
|
|
252
|
+
} else this._active--;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Reject every waiter (close). Held slots are released by their owners. */
|
|
257
|
+
drain(error: Error): void {
|
|
258
|
+
for (const waiter of this._waiters.splice(0)) {
|
|
259
|
+
clearTimeout(waiter.timer);
|
|
260
|
+
waiter.fail(error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Per-key serialization: a key's decisions run one at a time, in order. */
|
|
266
|
+
export class KeyedQueue {
|
|
267
|
+
private _tails = new Map<string, Promise<void>>();
|
|
268
|
+
run<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
269
|
+
const prior = this._tails.get(key) ?? Promise.resolve();
|
|
270
|
+
const result = prior.then(task, task);
|
|
271
|
+
const tail = result.then(
|
|
272
|
+
() => undefined,
|
|
273
|
+
() => undefined,
|
|
274
|
+
);
|
|
275
|
+
this._tails.set(key, tail);
|
|
276
|
+
void tail.then(() => {
|
|
277
|
+
if (this._tails.get(key) === tail) this._tails.delete(key);
|
|
278
|
+
});
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
get size() {
|
|
282
|
+
return this._tails.size;
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/retained-evidence.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const owned = new WeakSet<object>();
|
|
5
5
|
|
|
6
6
|
export function retainEvidence<T>(value: T): T {
|
|
7
|
-
if (value === null || typeof value !==
|
|
7
|
+
if (value === null || typeof value !== 'object' || owned.has(value)) return value;
|
|
8
8
|
const copy = structuredClone(value);
|
|
9
9
|
const pending: object[] = [copy];
|
|
10
10
|
while (pending.length) {
|
|
@@ -12,7 +12,7 @@ export function retainEvidence<T>(value: T): T {
|
|
|
12
12
|
if (owned.has(item)) continue;
|
|
13
13
|
owned.add(item);
|
|
14
14
|
for (const nested of Object.values(item)) {
|
|
15
|
-
if (nested !== null && typeof nested ===
|
|
15
|
+
if (nested !== null && typeof nested === 'object') pending.push(nested);
|
|
16
16
|
}
|
|
17
17
|
Object.freeze(item);
|
|
18
18
|
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Deterministic subscription routing
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Ported from Foundry's draft account-routing policy (foundry-jev-routing
|
|
6
|
+
// a60c664, `docs/signet-routing-strategy.md`). Pure functions: invoked only
|
|
7
|
+
// after the caller resolves which instances it is authorized to use. Routing
|
|
8
|
+
// does not mint authority, read credentials or call a model, and a routing
|
|
9
|
+
// preference never widens a grant (organization, runtime, model, billing).
|
|
10
|
+
//
|
|
11
|
+
// Strategies:
|
|
12
|
+
// quartile-balanced (default) lowest usage quartile first; the preferred
|
|
13
|
+
// (owning) instance wins within its quartile.
|
|
14
|
+
// owner-first the preferred instance while eligible, then
|
|
15
|
+
// spillover by utilization.
|
|
16
|
+
// pinned the preferred instance only; otherwise none.
|
|
17
|
+
//
|
|
18
|
+
// Unknown, stale, future or reset-crossing limit observations exclude an
|
|
19
|
+
// instance: routing never guesses capacity.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export type RoutingMode = 'quartile-balanced' | 'owner-first' | 'pinned';
|
|
23
|
+
|
|
24
|
+
export interface RepositoryRoute {
|
|
25
|
+
repository: string;
|
|
26
|
+
organizationId: string;
|
|
27
|
+
preferredAccountId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SubscriptionCandidate {
|
|
31
|
+
id: string;
|
|
32
|
+
organizationIds: readonly string[];
|
|
33
|
+
runtime: 'codex' | 'claude';
|
|
34
|
+
authentication: 'subscription' | 'api-key';
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** model → supported efforts. */
|
|
37
|
+
models: Readonly<Record<string, readonly string[]>>;
|
|
38
|
+
activeRuns: number;
|
|
39
|
+
concurrencyLimit: number;
|
|
40
|
+
/** All provider enforcement windows, plus locally retained reservations. `resetsAt: Infinity` means none reported. */
|
|
41
|
+
windows: readonly {
|
|
42
|
+
usedPercent: number | null;
|
|
43
|
+
reservedPercent: number;
|
|
44
|
+
observedAt: number;
|
|
45
|
+
resetsAt: number;
|
|
46
|
+
}[];
|
|
47
|
+
/** The provider reports ordinary usage blocked now. */
|
|
48
|
+
blocked?: boolean;
|
|
49
|
+
/** Excluded until this time after local failures (epoch ms). */
|
|
50
|
+
unavailableUntil?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RoutingRequest {
|
|
54
|
+
repository: string;
|
|
55
|
+
organizationId?: string;
|
|
56
|
+
runtime: 'codex' | 'claude';
|
|
57
|
+
model: string;
|
|
58
|
+
effort: string;
|
|
59
|
+
now: number;
|
|
60
|
+
maximumObservationAgeMs: number;
|
|
61
|
+
mode?: RoutingMode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Repository resolution already done by the caller (or not needed). */
|
|
65
|
+
export interface CandidateRequest {
|
|
66
|
+
organizationId?: string;
|
|
67
|
+
preferredId?: string;
|
|
68
|
+
runtime: 'codex' | 'claude';
|
|
69
|
+
model: string;
|
|
70
|
+
effort: string;
|
|
71
|
+
now: number;
|
|
72
|
+
maximumObservationAgeMs: number;
|
|
73
|
+
mode?: RoutingMode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RankedCandidate {
|
|
77
|
+
accountId: string;
|
|
78
|
+
utilizationPercent: number;
|
|
79
|
+
quartile: number;
|
|
80
|
+
preferred: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ExclusionReason =
|
|
84
|
+
| 'disabled'
|
|
85
|
+
| 'not-subscription'
|
|
86
|
+
| 'organization'
|
|
87
|
+
| 'runtime'
|
|
88
|
+
| 'model'
|
|
89
|
+
| 'occupied'
|
|
90
|
+
| 'invalid'
|
|
91
|
+
| 'no-observation'
|
|
92
|
+
| 'stale-observation'
|
|
93
|
+
| 'exhausted'
|
|
94
|
+
| 'blocked'
|
|
95
|
+
| 'unavailable'
|
|
96
|
+
| 'not-pinned';
|
|
97
|
+
|
|
98
|
+
/** Only repository identities, never local paths or URL credentials. GitHub's
|
|
99
|
+
* scp-style git user is transport syntax, not a credential or organization grant. */
|
|
100
|
+
export function repositoryIdentity(remote: string): string {
|
|
101
|
+
const scp = /^git@([a-zA-Z0-9.-]+):([^?#\s]+)$/.exec(remote);
|
|
102
|
+
let url: URL;
|
|
103
|
+
try {
|
|
104
|
+
url = new URL(scp ? `ssh://git@${scp[1]}/${scp[2]}` : remote);
|
|
105
|
+
} catch {
|
|
106
|
+
throw Error('Repository must be an HTTPS or SSH remote');
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!['https:', 'ssh:'].includes(url.protocol) ||
|
|
110
|
+
url.password ||
|
|
111
|
+
url.search ||
|
|
112
|
+
url.hash ||
|
|
113
|
+
url.port ||
|
|
114
|
+
(url.username && !(url.protocol === 'ssh:' && url.username === 'git'))
|
|
115
|
+
)
|
|
116
|
+
throw Error('Unsupported repository remote');
|
|
117
|
+
if (/%|\\|\s/.test(url.pathname)) throw Error('Ambiguous repository path');
|
|
118
|
+
const path = url.pathname
|
|
119
|
+
.replace(/^\//, '')
|
|
120
|
+
.replace(/\/$/, '')
|
|
121
|
+
.replace(/\.git$/, '');
|
|
122
|
+
if (
|
|
123
|
+
!path ||
|
|
124
|
+
path.split('/').length < 2 ||
|
|
125
|
+
path.split('/').some((p) => !p || p === '.' || p === '..')
|
|
126
|
+
)
|
|
127
|
+
throw Error('Repository must include its namespace');
|
|
128
|
+
const normalized = url.hostname === 'github.com' ? path.toLowerCase() : path;
|
|
129
|
+
return `${url.hostname.toLowerCase()}/${normalized}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Why one candidate is ineligible, or undefined with its utilization when eligible. */
|
|
133
|
+
export function assessCandidate(
|
|
134
|
+
request: CandidateRequest,
|
|
135
|
+
account: SubscriptionCandidate,
|
|
136
|
+
): { excluded: ExclusionReason } | { utilizationPercent: number } {
|
|
137
|
+
if (
|
|
138
|
+
!account.id ||
|
|
139
|
+
!Number.isSafeInteger(account.activeRuns) ||
|
|
140
|
+
account.activeRuns < 0 ||
|
|
141
|
+
!Number.isSafeInteger(account.concurrencyLimit) ||
|
|
142
|
+
account.concurrencyLimit < 1
|
|
143
|
+
)
|
|
144
|
+
return { excluded: 'invalid' };
|
|
145
|
+
if (!account.enabled) return { excluded: 'disabled' };
|
|
146
|
+
if (account.authentication !== 'subscription') return { excluded: 'not-subscription' };
|
|
147
|
+
if (
|
|
148
|
+
request.organizationId !== undefined &&
|
|
149
|
+
!account.organizationIds.includes(request.organizationId)
|
|
150
|
+
)
|
|
151
|
+
return { excluded: 'organization' };
|
|
152
|
+
if (account.runtime !== request.runtime) return { excluded: 'runtime' };
|
|
153
|
+
if (!account.models[request.model]?.includes(request.effort)) return { excluded: 'model' };
|
|
154
|
+
if (request.mode === 'pinned' && account.id !== request.preferredId)
|
|
155
|
+
return { excluded: 'not-pinned' };
|
|
156
|
+
if (account.unavailableUntil !== undefined && account.unavailableUntil > request.now)
|
|
157
|
+
return { excluded: 'unavailable' };
|
|
158
|
+
if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
|
|
159
|
+
if (account.blocked) return { excluded: 'blocked' };
|
|
160
|
+
if (account.windows.length === 0) return { excluded: 'no-observation' };
|
|
161
|
+
const fractions: number[] = [];
|
|
162
|
+
for (const window of account.windows) {
|
|
163
|
+
if (
|
|
164
|
+
window.usedPercent === null ||
|
|
165
|
+
!Number.isFinite(window.usedPercent) ||
|
|
166
|
+
window.usedPercent < 0 ||
|
|
167
|
+
window.usedPercent > 100 ||
|
|
168
|
+
!Number.isFinite(window.reservedPercent) ||
|
|
169
|
+
window.reservedPercent < 0
|
|
170
|
+
)
|
|
171
|
+
return { excluded: 'invalid' };
|
|
172
|
+
// resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
|
|
173
|
+
if (
|
|
174
|
+
!Number.isFinite(window.observedAt) ||
|
|
175
|
+
window.observedAt > request.now ||
|
|
176
|
+
request.now - window.observedAt > request.maximumObservationAgeMs ||
|
|
177
|
+
(window.resetsAt !== Number.POSITIVE_INFINITY &&
|
|
178
|
+
(!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
|
|
179
|
+
)
|
|
180
|
+
return { excluded: 'stale-observation' };
|
|
181
|
+
fractions.push(window.usedPercent + window.reservedPercent);
|
|
182
|
+
}
|
|
183
|
+
if (fractions.some((p) => p >= 100)) return { excluded: 'exhausted' };
|
|
184
|
+
return { utilizationPercent: Math.max(...fractions) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Rank eligible candidates deterministically (ties broken by id). */
|
|
188
|
+
export function rankCandidates(
|
|
189
|
+
request: CandidateRequest,
|
|
190
|
+
accounts: readonly SubscriptionCandidate[],
|
|
191
|
+
): { candidates: RankedCandidate[]; excluded: Record<string, ExclusionReason> } {
|
|
192
|
+
if (
|
|
193
|
+
!Number.isFinite(request.now) ||
|
|
194
|
+
!Number.isFinite(request.maximumObservationAgeMs) ||
|
|
195
|
+
request.maximumObservationAgeMs <= 0
|
|
196
|
+
)
|
|
197
|
+
throw Error('Invalid observation policy');
|
|
198
|
+
if (request.mode === 'pinned' && !request.preferredId)
|
|
199
|
+
throw Error('Pinned routing requires a preferred instance');
|
|
200
|
+
if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
|
|
201
|
+
throw Error('Duplicate account identity');
|
|
202
|
+
const excluded: Record<string, ExclusionReason> = {};
|
|
203
|
+
const ranked = accounts.flatMap((account) => {
|
|
204
|
+
const assessed = assessCandidate(request, account);
|
|
205
|
+
if ('excluded' in assessed) {
|
|
206
|
+
excluded[account.id] = assessed.excluded;
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const { utilizationPercent } = assessed;
|
|
210
|
+
return [
|
|
211
|
+
{
|
|
212
|
+
accountId: account.id,
|
|
213
|
+
utilizationPercent,
|
|
214
|
+
quartile: Math.floor(utilizationPercent / 25),
|
|
215
|
+
preferred: account.id === request.preferredId,
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
});
|
|
219
|
+
ranked.sort((a, b) =>
|
|
220
|
+
request.mode === 'owner-first' || request.mode === 'pinned'
|
|
221
|
+
? Number(b.preferred) - Number(a.preferred) ||
|
|
222
|
+
a.utilizationPercent - b.utilizationPercent ||
|
|
223
|
+
a.accountId.localeCompare(b.accountId)
|
|
224
|
+
: a.quartile - b.quartile ||
|
|
225
|
+
Number(b.preferred) - Number(a.preferred) ||
|
|
226
|
+
a.utilizationPercent - b.utilizationPercent ||
|
|
227
|
+
a.accountId.localeCompare(b.accountId),
|
|
228
|
+
);
|
|
229
|
+
return { candidates: ranked, excluded };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The draft's repository-scoped entry point: resolve ownership, then rank. */
|
|
233
|
+
export function rankSubscriptionAccounts(
|
|
234
|
+
request: RoutingRequest,
|
|
235
|
+
routes: readonly RepositoryRoute[],
|
|
236
|
+
accounts: readonly SubscriptionCandidate[],
|
|
237
|
+
) {
|
|
238
|
+
if (
|
|
239
|
+
!Number.isFinite(request.now) ||
|
|
240
|
+
!Number.isFinite(request.maximumObservationAgeMs) ||
|
|
241
|
+
request.maximumObservationAgeMs <= 0
|
|
242
|
+
)
|
|
243
|
+
throw Error('Invalid observation policy');
|
|
244
|
+
const repository = repositoryIdentity(request.repository);
|
|
245
|
+
const matches = routes.filter((r) => repositoryIdentity(r.repository) === repository);
|
|
246
|
+
if (matches.length !== 1) throw Error('Repository ownership is missing or ambiguous');
|
|
247
|
+
const route = matches[0]!;
|
|
248
|
+
if (!route.organizationId || !route.preferredAccountId)
|
|
249
|
+
throw Error('Incomplete repository routing rule');
|
|
250
|
+
if (request.organizationId && request.organizationId !== route.organizationId)
|
|
251
|
+
throw Error('Explicit organization conflicts with repository ownership');
|
|
252
|
+
const { candidates } = rankCandidates(
|
|
253
|
+
{
|
|
254
|
+
organizationId: route.organizationId,
|
|
255
|
+
preferredId: route.preferredAccountId,
|
|
256
|
+
runtime: request.runtime,
|
|
257
|
+
model: request.model,
|
|
258
|
+
effort: request.effort,
|
|
259
|
+
now: request.now,
|
|
260
|
+
maximumObservationAgeMs: request.maximumObservationAgeMs,
|
|
261
|
+
mode: request.mode,
|
|
262
|
+
},
|
|
263
|
+
accounts,
|
|
264
|
+
);
|
|
265
|
+
return {
|
|
266
|
+
repository,
|
|
267
|
+
organizationId: route.organizationId,
|
|
268
|
+
model: request.model,
|
|
269
|
+
effort: request.effort,
|
|
270
|
+
mode: request.mode ?? 'quartile-balanced',
|
|
271
|
+
candidates,
|
|
272
|
+
};
|
|
273
|
+
}
|