@inixiative/agent-session 0.3.0 → 0.4.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 +5 -0
- package/package.json +6 -3
- package/src/codex-primed.ts +31 -6
- package/src/index.ts +11 -1
- package/src/pool.ts +20 -22
- package/src/routing.ts +151 -37
package/README.md
CHANGED
|
@@ -96,6 +96,11 @@ const result = await decisions.decide(
|
|
|
96
96
|
);
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
Run Codex decisions on a private `CODEX_HOME` that holds only the login (link
|
|
100
|
+
`auth.json` from the account's home; a copy forks its refresh token): Codex has no
|
|
101
|
+
setting that drops the user's own `AGENTS.md`, and a decision thread that reports
|
|
102
|
+
any instruction source is refused.
|
|
103
|
+
|
|
99
104
|
Codex hosts every key as a thread of one `app-server` process per account and
|
|
100
105
|
forks the primed thread per cycle; Claude forks a persisted primed session with
|
|
101
106
|
a pre-spawned spare. Measured on a ChatGPT login: warm Codex decisions 2.45 s
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"prepublishOnly": "bun run check",
|
|
19
19
|
"measure:primed": "bun scripts/measure-primed.ts",
|
|
20
20
|
"lint": "biome check .",
|
|
21
|
-
"prepare": "lefthook install"
|
|
21
|
+
"prepare": "if git rev-parse --git-dir >/dev/null 2>&1; then lefthook install; fi"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
24
|
"claude-code",
|
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
"eval"
|
|
35
35
|
],
|
|
36
36
|
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@inixiative/json-rules": "^3.4.1"
|
|
39
|
+
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@biomejs/biome": "2.5.2",
|
|
39
|
-
"@inixiative/config": "^0.5.
|
|
42
|
+
"@inixiative/config": "^0.5.62",
|
|
40
43
|
"@types/bun": "1.4.2",
|
|
41
44
|
"lefthook": "2.1.9",
|
|
42
45
|
"typescript": "6.0.3"
|
package/src/codex-primed.ts
CHANGED
|
@@ -75,15 +75,19 @@ export const CODEX_DECISION_DISABLED_FEATURES: readonly string[] = [
|
|
|
75
75
|
];
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Per-thread configuration for decisions: no notify hook, no
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
78
|
+
* Per-thread configuration for decisions: no notify hook, no project docs or skills,
|
|
79
|
+
* and none of the instruction surfaces that describe tools or the environment.
|
|
80
|
+
* Applied on thread/start and thread/fork (verified equivalent to launch-level `-c`
|
|
81
|
+
* overrides), so the launch argv carries only approval, web-search and feature flags
|
|
82
|
+
* that credential launchers accept.
|
|
83
|
+
*
|
|
84
|
+
* The user's own CODEX_HOME (its AGENTS.md, memories, MCP servers) is not a thread
|
|
85
|
+
* setting: run decisions on a private CODEX_HOME holding only the login. A thread
|
|
86
|
+
* that reports an instruction source is refused, and an MCP server that starts is a
|
|
87
|
+
* violation.
|
|
83
88
|
*/
|
|
84
89
|
export const CODEX_DECISION_THREAD_CONFIG: Readonly<Record<string, unknown>> = Object.freeze({
|
|
85
90
|
notify: [],
|
|
86
|
-
'mcp_servers.node_repl.enabled': false,
|
|
87
91
|
project_doc_max_bytes: 0,
|
|
88
92
|
'skills.enabled': false,
|
|
89
93
|
include_permissions_instructions: false,
|
|
@@ -213,6 +217,19 @@ interface Branch {
|
|
|
213
217
|
wake(): void;
|
|
214
218
|
}
|
|
215
219
|
|
|
220
|
+
/** Instruction files Codex loaded into a thread (the user's AGENTS.md, project docs); a decision allows none. */
|
|
221
|
+
const instructionSources = (response: Record<string, unknown> | undefined): string | undefined => {
|
|
222
|
+
const sources = response?.instructionSources;
|
|
223
|
+
return Array.isArray(sources) && sources.length ? sources.map(String).join(', ') : undefined;
|
|
224
|
+
};
|
|
225
|
+
const instructionLeak = (sources: string) =>
|
|
226
|
+
new DecisionError(
|
|
227
|
+
`Codex loaded instructions into a decision thread (${sources}); run decisions on a private CODEX_HOME`,
|
|
228
|
+
'violation',
|
|
229
|
+
'not-dispatched',
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
|
|
216
233
|
const object = (value: unknown): Record<string, unknown> | undefined =>
|
|
217
234
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
218
235
|
? (value as Record<string, unknown>)
|
|
@@ -1045,6 +1062,11 @@ export class CodexPrimedSessions implements PrimedSessions {
|
|
|
1045
1062
|
'not-dispatched',
|
|
1046
1063
|
true,
|
|
1047
1064
|
);
|
|
1065
|
+
const leaked = instructionSources(started);
|
|
1066
|
+
if (leaked) {
|
|
1067
|
+
void conn.request('thread/delete', { threadId: baseId }, 10_000).catch(() => undefined);
|
|
1068
|
+
throw instructionLeak(leaked);
|
|
1069
|
+
}
|
|
1048
1070
|
const branch = this._branch(conn, baseId, spec.key);
|
|
1049
1071
|
let ok = false;
|
|
1050
1072
|
try {
|
|
@@ -1287,6 +1309,8 @@ export class CodexPrimedSessions implements PrimedSessions {
|
|
|
1287
1309
|
);
|
|
1288
1310
|
const id = object(response?.thread)?.id;
|
|
1289
1311
|
if (typeof id !== 'string' || !id) throw Error('Codex returned no decision thread');
|
|
1312
|
+
const leaked = instructionSources(response);
|
|
1313
|
+
if (leaked) throw instructionLeak(leaked);
|
|
1290
1314
|
model ??= typeof response?.model === 'string' ? response.model : undefined;
|
|
1291
1315
|
return { threadId: id, branch: this._branch(conn, id, spec.key) };
|
|
1292
1316
|
};
|
|
@@ -1294,6 +1318,7 @@ export class CodexPrimedSessions implements PrimedSessions {
|
|
|
1294
1318
|
if (Date.now() >= deadline || signal?.aborted) throw expired();
|
|
1295
1319
|
const t1 = performance.now();
|
|
1296
1320
|
const opened = await openBranch().catch((error) => {
|
|
1321
|
+
if (error instanceof DecisionError) throw error;
|
|
1297
1322
|
// The runtime refusing the fork means the primed thread is gone: retire it and prime again off-path.
|
|
1298
1323
|
// A timeout proves nothing about the primed thread.
|
|
1299
1324
|
if (session && error instanceof JsonRpcError) this._retire(session, 'reprime');
|
package/src/index.ts
CHANGED
|
@@ -87,15 +87,25 @@ export {
|
|
|
87
87
|
export {
|
|
88
88
|
assessCandidate,
|
|
89
89
|
type CandidateRequest,
|
|
90
|
+
DEFAULT_STRATEGY,
|
|
90
91
|
type ExclusionReason,
|
|
92
|
+
type OrderBy,
|
|
93
|
+
type OrderingSpec,
|
|
94
|
+
type OrderingTerm,
|
|
91
95
|
type RankedCandidate,
|
|
92
96
|
type RepositoryRoute,
|
|
93
|
-
type
|
|
97
|
+
type ResolvedOrdering,
|
|
98
|
+
ROUTING_STRATEGIES,
|
|
99
|
+
type RoutingField,
|
|
94
100
|
type RoutingRequest,
|
|
101
|
+
type RoutingStrategy,
|
|
95
102
|
rankCandidates,
|
|
96
103
|
rankSubscriptionAccounts,
|
|
97
104
|
repositoryIdentity,
|
|
105
|
+
resolveOrdering,
|
|
106
|
+
type SortDir,
|
|
98
107
|
type SubscriptionCandidate,
|
|
108
|
+
type UnknownUtilizationPolicy,
|
|
99
109
|
} from './routing';
|
|
100
110
|
// Transports: how a session reaches its model, with declared capabilities.
|
|
101
111
|
export {
|
package/src/pool.ts
CHANGED
|
@@ -20,8 +20,9 @@ import { type LimitSnapshot, mergeLimits } from './limits';
|
|
|
20
20
|
import { probeClaudeLimits, probeCodexLimits } from './limits-probe';
|
|
21
21
|
import {
|
|
22
22
|
type ExclusionReason,
|
|
23
|
+
type OrderingSpec,
|
|
23
24
|
type RankedCandidate,
|
|
24
|
-
type
|
|
25
|
+
type ResolvedOrdering,
|
|
25
26
|
rankCandidates,
|
|
26
27
|
type SubscriptionCandidate,
|
|
27
28
|
} from './routing';
|
|
@@ -48,6 +49,8 @@ export interface InstanceConfig {
|
|
|
48
49
|
readonly models?: Readonly<Record<string, readonly string[]>>;
|
|
49
50
|
/** Concurrent leases. Default 1. */
|
|
50
51
|
readonly concurrencyLimit?: number;
|
|
52
|
+
/** Lower serves first under a priority ordering. Default 0. */
|
|
53
|
+
readonly priority?: number;
|
|
51
54
|
readonly enabled?: boolean;
|
|
52
55
|
readonly authentication?: 'subscription' | 'api-key';
|
|
53
56
|
/** Session config merged into every session opened on this instance (bin, spawn, SDK query…). */
|
|
@@ -60,7 +63,9 @@ export interface PoolRequest {
|
|
|
60
63
|
readonly effort?: string;
|
|
61
64
|
readonly organizationId?: string;
|
|
62
65
|
readonly preferredInstanceId?: string;
|
|
63
|
-
readonly
|
|
66
|
+
readonly ordering?: OrderingSpec;
|
|
67
|
+
/** Only the preferred instance may serve. Requires preferredInstanceId. */
|
|
68
|
+
readonly pinned?: boolean;
|
|
64
69
|
/** Restrict to these transports (e.g. only resumable ones). */
|
|
65
70
|
readonly transports?: readonly TransportKind[];
|
|
66
71
|
readonly excludeInstanceIds?: readonly string[];
|
|
@@ -90,8 +95,8 @@ export type PoolEvent =
|
|
|
90
95
|
readonly leaseId: string;
|
|
91
96
|
readonly instanceId: string;
|
|
92
97
|
readonly transport: PooledTransport;
|
|
93
|
-
readonly
|
|
94
|
-
readonly utilizationPercent: number;
|
|
98
|
+
readonly ordering: ResolvedOrdering;
|
|
99
|
+
readonly utilizationPercent: number | null;
|
|
95
100
|
readonly rank: number;
|
|
96
101
|
}
|
|
97
102
|
| { readonly type: 'released'; readonly leaseId: string; readonly instanceId: string }
|
|
@@ -288,6 +293,7 @@ export class SubscriptionPool {
|
|
|
288
293
|
rank(request: PoolRequest): {
|
|
289
294
|
candidates: RankedCandidate[];
|
|
290
295
|
excluded: Record<string, ExclusionReason>;
|
|
296
|
+
ordering: ResolvedOrdering;
|
|
291
297
|
} {
|
|
292
298
|
const now = this._now();
|
|
293
299
|
const effort = request.effort ?? 'default';
|
|
@@ -304,6 +310,7 @@ export class SubscriptionPool {
|
|
|
304
310
|
models: state.config.models ?? { [request.model]: [effort] },
|
|
305
311
|
activeRuns: state.leases.size,
|
|
306
312
|
concurrencyLimit: state.config.concurrencyLimit ?? 1,
|
|
313
|
+
priority: state.config.priority ?? 0,
|
|
307
314
|
windows: (limits?.windows ?? []).map((w) => ({
|
|
308
315
|
usedPercent: w.usedPercent,
|
|
309
316
|
reservedPercent: reserved,
|
|
@@ -323,7 +330,8 @@ export class SubscriptionPool {
|
|
|
323
330
|
effort,
|
|
324
331
|
now,
|
|
325
332
|
maximumObservationAgeMs: this._maxAge,
|
|
326
|
-
|
|
333
|
+
ordering: request.ordering,
|
|
334
|
+
...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
|
|
327
335
|
organizationId: request.organizationId,
|
|
328
336
|
preferredId: request.preferredInstanceId,
|
|
329
337
|
},
|
|
@@ -333,7 +341,7 @@ export class SubscriptionPool {
|
|
|
333
341
|
|
|
334
342
|
/** Lease the best instance now, or throw PoolExhaustedError with every exclusion reason. */
|
|
335
343
|
acquire(request: PoolRequest): Lease {
|
|
336
|
-
const { candidates, excluded } = this.rank(request);
|
|
344
|
+
const { candidates, excluded, ordering } = this.rank(request);
|
|
337
345
|
const best = candidates[0];
|
|
338
346
|
if (!best) {
|
|
339
347
|
this._emit({ type: 'exhausted', runtime: request.runtime, model: request.model, excluded });
|
|
@@ -346,12 +354,7 @@ export class SubscriptionPool {
|
|
|
346
354
|
excluded,
|
|
347
355
|
);
|
|
348
356
|
}
|
|
349
|
-
return this._lease(
|
|
350
|
-
this._state(best.accountId),
|
|
351
|
-
request.mode ?? 'quartile-balanced',
|
|
352
|
-
best.utilizationPercent,
|
|
353
|
-
0,
|
|
354
|
-
);
|
|
357
|
+
return this._lease(this._state(best.accountId), ordering, best.utilizationPercent, 0);
|
|
355
358
|
}
|
|
356
359
|
|
|
357
360
|
/** Refresh stale limits, lease an instance and open a session on it. The lease ends with the session. */
|
|
@@ -429,7 +432,7 @@ export class SubscriptionPool {
|
|
|
429
432
|
);
|
|
430
433
|
const allowed = new Set(shared.map((s) => s.config.id));
|
|
431
434
|
await this._refreshStale(request, allowed);
|
|
432
|
-
const { candidates, excluded } = this.rank({
|
|
435
|
+
const { candidates, excluded, ordering } = this.rank({
|
|
433
436
|
...request,
|
|
434
437
|
excludeInstanceIds: [
|
|
435
438
|
...(request.excludeInstanceIds ?? []),
|
|
@@ -451,12 +454,7 @@ export class SubscriptionPool {
|
|
|
451
454
|
);
|
|
452
455
|
binding.session?.kill();
|
|
453
456
|
binding.lease?.release();
|
|
454
|
-
const lease = this._lease(
|
|
455
|
-
this._state(best.accountId),
|
|
456
|
-
request.mode ?? 'quartile-balanced',
|
|
457
|
-
best.utilizationPercent,
|
|
458
|
-
0,
|
|
459
|
-
);
|
|
457
|
+
const lease = this._lease(this._state(best.accountId), ordering, best.utilizationPercent, 0);
|
|
460
458
|
try {
|
|
461
459
|
const session = this._session(
|
|
462
460
|
lease,
|
|
@@ -566,8 +564,8 @@ export class SubscriptionPool {
|
|
|
566
564
|
}
|
|
567
565
|
private _lease(
|
|
568
566
|
state: InstanceState,
|
|
569
|
-
|
|
570
|
-
utilizationPercent: number,
|
|
567
|
+
ordering: ResolvedOrdering,
|
|
568
|
+
utilizationPercent: number | null,
|
|
571
569
|
rank: number,
|
|
572
570
|
): Lease {
|
|
573
571
|
const leaseId = crypto.randomUUID();
|
|
@@ -593,7 +591,7 @@ export class SubscriptionPool {
|
|
|
593
591
|
leaseId,
|
|
594
592
|
instanceId: state.config.id,
|
|
595
593
|
transport: state.config.transport,
|
|
596
|
-
|
|
594
|
+
ordering,
|
|
597
595
|
utilizationPercent,
|
|
598
596
|
rank,
|
|
599
597
|
});
|
package/src/routing.ts
CHANGED
|
@@ -8,18 +8,83 @@
|
|
|
8
8
|
// does not mint authority, read credentials or call a model, and a routing
|
|
9
9
|
// preference never widens a grant (organization, runtime, model, billing).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// spillover by utilization.
|
|
16
|
-
// pinned the preferred instance only; otherwise none.
|
|
11
|
+
// Ordering is an ordered list of terms over the fields projected onto each
|
|
12
|
+
// eligible candidate, in `@inixiative/json-rules`' `OrderBy` vocabulary.
|
|
13
|
+
// Named strategies are presets over that list; a caller may pass its own terms
|
|
14
|
+
// instead. `id` is always the final term, so ranking is total and deterministic.
|
|
17
15
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// Eligibility is separate from ordering: `pinned` restricts which instances may
|
|
17
|
+
// serve at all, and unknown, stale, future or reset-crossing limit observations
|
|
18
|
+
// mean routing has no capacity reading for an instance. By default that excludes
|
|
19
|
+
// it — routing never guesses capacity.
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
import type { OrderBy, SortDir } from '@inixiative/json-rules';
|
|
23
|
+
|
|
24
|
+
export type { OrderBy, SortDir };
|
|
25
|
+
|
|
26
|
+
export type RoutingField = 'priority' | 'utilization' | 'quartile' | 'headroom' | 'preferred';
|
|
27
|
+
|
|
28
|
+
export interface OrderingTerm {
|
|
29
|
+
readonly field: RoutingField;
|
|
30
|
+
readonly dir?: SortDir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RoutingStrategy =
|
|
34
|
+
| 'quartile-balanced'
|
|
35
|
+
| 'owner-first'
|
|
36
|
+
| 'priority-balanced'
|
|
37
|
+
| 'priority-strict'
|
|
38
|
+
| 'least-used';
|
|
39
|
+
|
|
40
|
+
export type OrderingSpec = RoutingStrategy | readonly OrderingTerm[];
|
|
41
|
+
|
|
42
|
+
/** Every term carries a direction. Assignable to json-rules' `OrderBy`. */
|
|
43
|
+
export type ResolvedOrdering = { field: RoutingField; dir: SortDir }[];
|
|
44
|
+
|
|
45
|
+
/** The direction that puts the better candidate first when a term omits one. */
|
|
46
|
+
const NATURAL_DIRECTION: Record<RoutingField, SortDir> = {
|
|
47
|
+
priority: 'asc',
|
|
48
|
+
utilization: 'asc',
|
|
49
|
+
quartile: 'asc',
|
|
50
|
+
headroom: 'desc',
|
|
51
|
+
preferred: 'desc',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const ROUTING_STRATEGIES: Record<RoutingStrategy, readonly OrderingTerm[]> = {
|
|
55
|
+
'quartile-balanced': [{ field: 'quartile' }, { field: 'preferred' }, { field: 'utilization' }],
|
|
56
|
+
'owner-first': [{ field: 'preferred' }, { field: 'utilization' }],
|
|
57
|
+
// Load decides between tiers that differ by a quartile; priority decides within one.
|
|
58
|
+
'priority-balanced': [
|
|
59
|
+
{ field: 'quartile' },
|
|
60
|
+
{ field: 'priority' },
|
|
61
|
+
{ field: 'preferred' },
|
|
62
|
+
{ field: 'utilization' },
|
|
63
|
+
],
|
|
64
|
+
// Priority dominates: a lower tier serves only once every higher one is excluded.
|
|
65
|
+
'priority-strict': [{ field: 'priority' }, { field: 'utilization' }],
|
|
66
|
+
'least-used': [{ field: 'utilization' }],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_STRATEGY: RoutingStrategy = 'quartile-balanced';
|
|
70
|
+
|
|
71
|
+
const ROUTING_FIELDS = Object.keys(NATURAL_DIRECTION) as RoutingField[];
|
|
72
|
+
|
|
73
|
+
/** Resolve a strategy name or explicit term list into terms, each with a direction. */
|
|
74
|
+
export const resolveOrdering = (spec: OrderingSpec = DEFAULT_STRATEGY): ResolvedOrdering => {
|
|
75
|
+
if (typeof spec === 'string') {
|
|
76
|
+
const preset = ROUTING_STRATEGIES[spec];
|
|
77
|
+
if (!preset) throw Error(`Unknown routing strategy "${spec}"`);
|
|
78
|
+
return resolveOrdering(preset);
|
|
79
|
+
}
|
|
80
|
+
if (!spec.length) throw Error('Ordering requires at least one term');
|
|
81
|
+
return spec.map((term) => {
|
|
82
|
+
if (!ROUTING_FIELDS.includes(term.field)) throw Error(`Unknown ordering field "${term.field}"`);
|
|
83
|
+
if (term.dir !== undefined && term.dir !== 'asc' && term.dir !== 'desc')
|
|
84
|
+
throw Error(`Unknown sort direction "${term.dir}"`);
|
|
85
|
+
return { field: term.field, dir: term.dir ?? NATURAL_DIRECTION[term.field] };
|
|
86
|
+
});
|
|
87
|
+
};
|
|
23
88
|
|
|
24
89
|
export interface RepositoryRoute {
|
|
25
90
|
repository: string;
|
|
@@ -37,6 +102,8 @@ export interface SubscriptionCandidate {
|
|
|
37
102
|
models: Readonly<Record<string, readonly string[]>>;
|
|
38
103
|
activeRuns: number;
|
|
39
104
|
concurrencyLimit: number;
|
|
105
|
+
/** Lower serves first. Absent is 0, so prioritized and unprioritized instances order together. */
|
|
106
|
+
priority?: number;
|
|
40
107
|
/** All provider enforcement windows, plus locally retained reservations. `resetsAt: Infinity` means none reported. */
|
|
41
108
|
windows: readonly {
|
|
42
109
|
usedPercent: number | null;
|
|
@@ -50,6 +117,16 @@ export interface SubscriptionCandidate {
|
|
|
50
117
|
unavailableUntil?: number;
|
|
51
118
|
}
|
|
52
119
|
|
|
120
|
+
/**
|
|
121
|
+
* What to do with an instance routing has no usable capacity reading for.
|
|
122
|
+
*
|
|
123
|
+
* `exclude` (default) suits provider-enforced subscription windows, where an
|
|
124
|
+
* unread login may already be exhausted and sending work there fails the turn.
|
|
125
|
+
* `rank-last` suits capacity whose spend is bounded elsewhere (Kingdom gates on
|
|
126
|
+
* allocation policies), where a never-observed instance must stay usable.
|
|
127
|
+
*/
|
|
128
|
+
export type UnknownUtilizationPolicy = 'exclude' | 'rank-last';
|
|
129
|
+
|
|
53
130
|
export interface RoutingRequest {
|
|
54
131
|
repository: string;
|
|
55
132
|
organizationId?: string;
|
|
@@ -58,7 +135,9 @@ export interface RoutingRequest {
|
|
|
58
135
|
effort: string;
|
|
59
136
|
now: number;
|
|
60
137
|
maximumObservationAgeMs: number;
|
|
61
|
-
|
|
138
|
+
ordering?: OrderingSpec;
|
|
139
|
+
pinned?: boolean;
|
|
140
|
+
unknownUtilization?: UnknownUtilizationPolicy;
|
|
62
141
|
}
|
|
63
142
|
|
|
64
143
|
/** Repository resolution already done by the caller (or not needed). */
|
|
@@ -70,13 +149,19 @@ export interface CandidateRequest {
|
|
|
70
149
|
effort: string;
|
|
71
150
|
now: number;
|
|
72
151
|
maximumObservationAgeMs: number;
|
|
73
|
-
|
|
152
|
+
ordering?: OrderingSpec;
|
|
153
|
+
/** Only the preferred instance may serve; others are excluded as `not-pinned`. */
|
|
154
|
+
pinned?: boolean;
|
|
155
|
+
unknownUtilization?: UnknownUtilizationPolicy;
|
|
74
156
|
}
|
|
75
157
|
|
|
76
158
|
export interface RankedCandidate {
|
|
77
159
|
accountId: string;
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
/** Null when no usable observation exists and the policy is `rank-last`. */
|
|
161
|
+
utilizationPercent: number | null;
|
|
162
|
+
quartile: number | null;
|
|
163
|
+
priority: number;
|
|
164
|
+
headroom: number;
|
|
80
165
|
preferred: boolean;
|
|
81
166
|
}
|
|
82
167
|
|
|
@@ -133,13 +218,14 @@ export function repositoryIdentity(remote: string): string {
|
|
|
133
218
|
export function assessCandidate(
|
|
134
219
|
request: CandidateRequest,
|
|
135
220
|
account: SubscriptionCandidate,
|
|
136
|
-
): { excluded: ExclusionReason } | { utilizationPercent: number } {
|
|
221
|
+
): { excluded: ExclusionReason } | { utilizationPercent: number | null } {
|
|
137
222
|
if (
|
|
138
223
|
!account.id ||
|
|
139
224
|
!Number.isSafeInteger(account.activeRuns) ||
|
|
140
225
|
account.activeRuns < 0 ||
|
|
141
226
|
!Number.isSafeInteger(account.concurrencyLimit) ||
|
|
142
|
-
account.concurrencyLimit < 1
|
|
227
|
+
account.concurrencyLimit < 1 ||
|
|
228
|
+
(account.priority !== undefined && !Number.isFinite(account.priority))
|
|
143
229
|
)
|
|
144
230
|
return { excluded: 'invalid' };
|
|
145
231
|
if (!account.enabled) return { excluded: 'disabled' };
|
|
@@ -151,13 +237,16 @@ export function assessCandidate(
|
|
|
151
237
|
return { excluded: 'organization' };
|
|
152
238
|
if (account.runtime !== request.runtime) return { excluded: 'runtime' };
|
|
153
239
|
if (!account.models[request.model]?.includes(request.effort)) return { excluded: 'model' };
|
|
154
|
-
if (request.
|
|
155
|
-
return { excluded: 'not-pinned' };
|
|
240
|
+
if (request.pinned && account.id !== request.preferredId) return { excluded: 'not-pinned' };
|
|
156
241
|
if (account.unavailableUntil !== undefined && account.unavailableUntil > request.now)
|
|
157
242
|
return { excluded: 'unavailable' };
|
|
158
243
|
if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
|
|
159
244
|
if (account.blocked) return { excluded: 'blocked' };
|
|
160
|
-
|
|
245
|
+
const unreadable = request.unknownUtilization === 'rank-last' ? null : undefined;
|
|
246
|
+
if (account.windows.length === 0) {
|
|
247
|
+
if (unreadable === undefined) return { excluded: 'no-observation' };
|
|
248
|
+
return { utilizationPercent: null };
|
|
249
|
+
}
|
|
161
250
|
const fractions: number[] = [];
|
|
162
251
|
for (const window of account.windows) {
|
|
163
252
|
if (
|
|
@@ -168,7 +257,7 @@ export function assessCandidate(
|
|
|
168
257
|
!Number.isFinite(window.reservedPercent) ||
|
|
169
258
|
window.reservedPercent < 0
|
|
170
259
|
)
|
|
171
|
-
return { excluded: 'invalid' };
|
|
260
|
+
return unreadable === undefined ? { excluded: 'invalid' } : { utilizationPercent: null };
|
|
172
261
|
// resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
|
|
173
262
|
if (
|
|
174
263
|
!Number.isFinite(window.observedAt) ||
|
|
@@ -177,28 +266,50 @@ export function assessCandidate(
|
|
|
177
266
|
(window.resetsAt !== Number.POSITIVE_INFINITY &&
|
|
178
267
|
(!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
|
|
179
268
|
)
|
|
180
|
-
return
|
|
269
|
+
return unreadable === undefined
|
|
270
|
+
? { excluded: 'stale-observation' }
|
|
271
|
+
: { utilizationPercent: null };
|
|
181
272
|
fractions.push(window.usedPercent + window.reservedPercent);
|
|
182
273
|
}
|
|
183
274
|
if (fractions.some((p) => p >= 100)) return { excluded: 'exhausted' };
|
|
184
275
|
return { utilizationPercent: Math.max(...fractions) };
|
|
185
276
|
}
|
|
186
277
|
|
|
278
|
+
const FIELD_VALUE: Record<RoutingField, (candidate: RankedCandidate) => number | null> = {
|
|
279
|
+
priority: (c) => c.priority,
|
|
280
|
+
utilization: (c) => c.utilizationPercent,
|
|
281
|
+
quartile: (c) => c.quartile,
|
|
282
|
+
headroom: (c) => c.headroom,
|
|
283
|
+
preferred: (c) => Number(c.preferred),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** Nulls last in either direction: an unknown reading never outranks a known one. */
|
|
287
|
+
const compare = (a: number | null, b: number | null, dir: SortDir): number => {
|
|
288
|
+
if (a === null) return b === null ? 0 : 1;
|
|
289
|
+
if (b === null) return -1;
|
|
290
|
+
return dir === 'desc' ? b - a : a - b;
|
|
291
|
+
};
|
|
292
|
+
|
|
187
293
|
/** Rank eligible candidates deterministically (ties broken by id). */
|
|
188
294
|
export function rankCandidates(
|
|
189
295
|
request: CandidateRequest,
|
|
190
296
|
accounts: readonly SubscriptionCandidate[],
|
|
191
|
-
): {
|
|
297
|
+
): {
|
|
298
|
+
candidates: RankedCandidate[];
|
|
299
|
+
excluded: Record<string, ExclusionReason>;
|
|
300
|
+
ordering: ResolvedOrdering;
|
|
301
|
+
} {
|
|
192
302
|
if (
|
|
193
303
|
!Number.isFinite(request.now) ||
|
|
194
304
|
!Number.isFinite(request.maximumObservationAgeMs) ||
|
|
195
305
|
request.maximumObservationAgeMs <= 0
|
|
196
306
|
)
|
|
197
307
|
throw Error('Invalid observation policy');
|
|
198
|
-
if (request.
|
|
308
|
+
if (request.pinned && !request.preferredId)
|
|
199
309
|
throw Error('Pinned routing requires a preferred instance');
|
|
200
310
|
if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
|
|
201
311
|
throw Error('Duplicate account identity');
|
|
312
|
+
const ordering = resolveOrdering(request.ordering);
|
|
202
313
|
const excluded: Record<string, ExclusionReason> = {};
|
|
203
314
|
const ranked = accounts.flatMap((account) => {
|
|
204
315
|
const assessed = assessCandidate(request, account);
|
|
@@ -211,22 +322,21 @@ export function rankCandidates(
|
|
|
211
322
|
{
|
|
212
323
|
accountId: account.id,
|
|
213
324
|
utilizationPercent,
|
|
214
|
-
quartile: Math.floor(utilizationPercent / 25),
|
|
325
|
+
quartile: utilizationPercent === null ? null : Math.floor(utilizationPercent / 25),
|
|
326
|
+
priority: account.priority ?? 0,
|
|
327
|
+
headroom: account.concurrencyLimit - account.activeRuns,
|
|
215
328
|
preferred: account.id === request.preferredId,
|
|
216
329
|
},
|
|
217
330
|
];
|
|
218
331
|
});
|
|
219
|
-
ranked.sort((a, b) =>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
a.accountId.localeCompare(b.accountId),
|
|
228
|
-
);
|
|
229
|
-
return { candidates: ranked, excluded };
|
|
332
|
+
ranked.sort((a, b) => {
|
|
333
|
+
for (const { field, dir } of ordering) {
|
|
334
|
+
const order = compare(FIELD_VALUE[field](a), FIELD_VALUE[field](b), dir);
|
|
335
|
+
if (order !== 0) return order;
|
|
336
|
+
}
|
|
337
|
+
return a.accountId.localeCompare(b.accountId);
|
|
338
|
+
});
|
|
339
|
+
return { candidates: ranked, excluded, ordering };
|
|
230
340
|
}
|
|
231
341
|
|
|
232
342
|
/** The draft's repository-scoped entry point: resolve ownership, then rank. */
|
|
@@ -249,7 +359,7 @@ export function rankSubscriptionAccounts(
|
|
|
249
359
|
throw Error('Incomplete repository routing rule');
|
|
250
360
|
if (request.organizationId && request.organizationId !== route.organizationId)
|
|
251
361
|
throw Error('Explicit organization conflicts with repository ownership');
|
|
252
|
-
const { candidates } = rankCandidates(
|
|
362
|
+
const { candidates, ordering } = rankCandidates(
|
|
253
363
|
{
|
|
254
364
|
organizationId: route.organizationId,
|
|
255
365
|
preferredId: route.preferredAccountId,
|
|
@@ -258,7 +368,11 @@ export function rankSubscriptionAccounts(
|
|
|
258
368
|
effort: request.effort,
|
|
259
369
|
now: request.now,
|
|
260
370
|
maximumObservationAgeMs: request.maximumObservationAgeMs,
|
|
261
|
-
|
|
371
|
+
ordering: request.ordering,
|
|
372
|
+
...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
|
|
373
|
+
...(request.unknownUtilization !== undefined
|
|
374
|
+
? { unknownUtilization: request.unknownUtilization }
|
|
375
|
+
: {}),
|
|
262
376
|
},
|
|
263
377
|
accounts,
|
|
264
378
|
);
|
|
@@ -267,7 +381,7 @@ export function rankSubscriptionAccounts(
|
|
|
267
381
|
organizationId: route.organizationId,
|
|
268
382
|
model: request.model,
|
|
269
383
|
effort: request.effort,
|
|
270
|
-
|
|
384
|
+
ordering,
|
|
271
385
|
candidates,
|
|
272
386
|
};
|
|
273
387
|
}
|