@inixiative/agent-session 0.3.1 → 0.5.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/package.json +6 -3
- package/src/index.ts +12 -1
- package/src/pool.ts +20 -22
- package/src/routing.ts +159 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.0"
|
|
39
|
+
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@biomejs/biome": "2.5.2",
|
|
39
|
-
"@inixiative/config": "^0.5.
|
|
42
|
+
"@inixiative/config": "^0.5.63",
|
|
40
43
|
"@types/bun": "1.4.2",
|
|
41
44
|
"lefthook": "2.1.9",
|
|
42
45
|
"typescript": "6.0.3"
|
package/src/index.ts
CHANGED
|
@@ -86,16 +86,27 @@ export {
|
|
|
86
86
|
} from './primed';
|
|
87
87
|
export {
|
|
88
88
|
assessCandidate,
|
|
89
|
+
type BillingMode,
|
|
89
90
|
type CandidateRequest,
|
|
91
|
+
DEFAULT_STRATEGY,
|
|
90
92
|
type ExclusionReason,
|
|
93
|
+
type OrderBy,
|
|
94
|
+
type OrderingSpec,
|
|
95
|
+
type OrderingTerm,
|
|
91
96
|
type RankedCandidate,
|
|
92
97
|
type RepositoryRoute,
|
|
93
|
-
type
|
|
98
|
+
type ResolvedOrdering,
|
|
99
|
+
ROUTING_STRATEGIES,
|
|
100
|
+
type RoutingField,
|
|
94
101
|
type RoutingRequest,
|
|
102
|
+
type RoutingStrategy,
|
|
95
103
|
rankCandidates,
|
|
96
104
|
rankSubscriptionAccounts,
|
|
97
105
|
repositoryIdentity,
|
|
106
|
+
resolveOrdering,
|
|
107
|
+
type SortDir,
|
|
98
108
|
type SubscriptionCandidate,
|
|
109
|
+
type UnknownUtilizationPolicy,
|
|
99
110
|
} from './routing';
|
|
100
111
|
// Transports: how a session reaches its model, with declared capabilities.
|
|
101
112
|
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,84 @@
|
|
|
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
|
+
import { orderRecords } from '@inixiative/json-rules';
|
|
24
|
+
|
|
25
|
+
export type { OrderBy, SortDir };
|
|
26
|
+
|
|
27
|
+
export type RoutingField = 'priority' | 'utilization' | 'quartile' | 'headroom' | 'preferred';
|
|
28
|
+
|
|
29
|
+
export interface OrderingTerm {
|
|
30
|
+
readonly field: RoutingField;
|
|
31
|
+
readonly dir?: SortDir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RoutingStrategy =
|
|
35
|
+
| 'quartile-balanced'
|
|
36
|
+
| 'owner-first'
|
|
37
|
+
| 'priority-balanced'
|
|
38
|
+
| 'priority-strict'
|
|
39
|
+
| 'least-used';
|
|
40
|
+
|
|
41
|
+
export type OrderingSpec = RoutingStrategy | readonly OrderingTerm[];
|
|
42
|
+
|
|
43
|
+
/** Every term carries a direction. Assignable to json-rules' `OrderBy`. */
|
|
44
|
+
export type ResolvedOrdering = { field: RoutingField; dir: SortDir }[];
|
|
45
|
+
|
|
46
|
+
/** The direction that puts the better candidate first when a term omits one. */
|
|
47
|
+
const NATURAL_DIRECTION: Record<RoutingField, SortDir> = {
|
|
48
|
+
priority: 'asc',
|
|
49
|
+
utilization: 'asc',
|
|
50
|
+
quartile: 'asc',
|
|
51
|
+
headroom: 'desc',
|
|
52
|
+
preferred: 'desc',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const ROUTING_STRATEGIES: Record<RoutingStrategy, readonly OrderingTerm[]> = {
|
|
56
|
+
'quartile-balanced': [{ field: 'quartile' }, { field: 'preferred' }, { field: 'utilization' }],
|
|
57
|
+
'owner-first': [{ field: 'preferred' }, { field: 'utilization' }],
|
|
58
|
+
// Load decides between tiers that differ by a quartile; priority decides within one.
|
|
59
|
+
'priority-balanced': [
|
|
60
|
+
{ field: 'quartile' },
|
|
61
|
+
{ field: 'priority' },
|
|
62
|
+
{ field: 'preferred' },
|
|
63
|
+
{ field: 'utilization' },
|
|
64
|
+
],
|
|
65
|
+
// Priority dominates: a lower tier serves only once every higher one is excluded.
|
|
66
|
+
'priority-strict': [{ field: 'priority' }, { field: 'utilization' }],
|
|
67
|
+
'least-used': [{ field: 'utilization' }],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_STRATEGY: RoutingStrategy = 'quartile-balanced';
|
|
71
|
+
|
|
72
|
+
const ROUTING_FIELDS = Object.keys(NATURAL_DIRECTION) as RoutingField[];
|
|
73
|
+
|
|
74
|
+
/** Resolve a strategy name or explicit term list into terms, each with a direction. */
|
|
75
|
+
export const resolveOrdering = (spec: OrderingSpec = DEFAULT_STRATEGY): ResolvedOrdering => {
|
|
76
|
+
if (typeof spec === 'string') {
|
|
77
|
+
const preset = ROUTING_STRATEGIES[spec];
|
|
78
|
+
if (!preset) throw Error(`Unknown routing strategy "${spec}"`);
|
|
79
|
+
return resolveOrdering(preset);
|
|
80
|
+
}
|
|
81
|
+
if (!spec.length) throw Error('Ordering requires at least one term');
|
|
82
|
+
return spec.map((term) => {
|
|
83
|
+
if (!ROUTING_FIELDS.includes(term.field)) throw Error(`Unknown ordering field "${term.field}"`);
|
|
84
|
+
if (term.dir !== undefined && term.dir !== 'asc' && term.dir !== 'desc')
|
|
85
|
+
throw Error(`Unknown sort direction "${term.dir}"`);
|
|
86
|
+
return { field: term.field, dir: term.dir ?? NATURAL_DIRECTION[term.field] };
|
|
87
|
+
});
|
|
88
|
+
};
|
|
23
89
|
|
|
24
90
|
export interface RepositoryRoute {
|
|
25
91
|
repository: string;
|
|
@@ -37,6 +103,8 @@ export interface SubscriptionCandidate {
|
|
|
37
103
|
models: Readonly<Record<string, readonly string[]>>;
|
|
38
104
|
activeRuns: number;
|
|
39
105
|
concurrencyLimit: number;
|
|
106
|
+
/** Lower serves first. Absent is 0, so prioritized and unprioritized instances order together. */
|
|
107
|
+
priority?: number;
|
|
40
108
|
/** All provider enforcement windows, plus locally retained reservations. `resetsAt: Infinity` means none reported. */
|
|
41
109
|
windows: readonly {
|
|
42
110
|
usedPercent: number | null;
|
|
@@ -50,6 +118,26 @@ export interface SubscriptionCandidate {
|
|
|
50
118
|
unavailableUntil?: number;
|
|
51
119
|
}
|
|
52
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What to do with an instance routing has no usable capacity reading for.
|
|
123
|
+
*
|
|
124
|
+
* `exclude` (default) suits provider-enforced subscription windows, where an
|
|
125
|
+
* unread login may already be exhausted and sending work there fails the turn.
|
|
126
|
+
* `rank-last` suits capacity whose spend is bounded elsewhere (Kingdom gates on
|
|
127
|
+
* allocation policies), where a never-observed instance must stay usable.
|
|
128
|
+
*/
|
|
129
|
+
export type UnknownUtilizationPolicy = 'exclude' | 'rank-last';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How an instance is paid for. Routing serves only `subscription` unless the
|
|
133
|
+
* caller widens it: spending real money is the caller's decision, never a
|
|
134
|
+
* routing default. Kingdom widens to `api-key`, where a run's spend is bounded
|
|
135
|
+
* by its allocation policies.
|
|
136
|
+
*/
|
|
137
|
+
export type BillingMode = 'subscription' | 'api-key';
|
|
138
|
+
|
|
139
|
+
const DEFAULT_BILLING: readonly BillingMode[] = ['subscription'];
|
|
140
|
+
|
|
53
141
|
export interface RoutingRequest {
|
|
54
142
|
repository: string;
|
|
55
143
|
organizationId?: string;
|
|
@@ -58,7 +146,10 @@ export interface RoutingRequest {
|
|
|
58
146
|
effort: string;
|
|
59
147
|
now: number;
|
|
60
148
|
maximumObservationAgeMs: number;
|
|
61
|
-
|
|
149
|
+
ordering?: OrderingSpec;
|
|
150
|
+
pinned?: boolean;
|
|
151
|
+
unknownUtilization?: UnknownUtilizationPolicy;
|
|
152
|
+
allowedBilling?: readonly BillingMode[];
|
|
62
153
|
}
|
|
63
154
|
|
|
64
155
|
/** Repository resolution already done by the caller (or not needed). */
|
|
@@ -70,19 +161,27 @@ export interface CandidateRequest {
|
|
|
70
161
|
effort: string;
|
|
71
162
|
now: number;
|
|
72
163
|
maximumObservationAgeMs: number;
|
|
73
|
-
|
|
164
|
+
ordering?: OrderingSpec;
|
|
165
|
+
/** Only the preferred instance may serve; others are excluded as `not-pinned`. */
|
|
166
|
+
pinned?: boolean;
|
|
167
|
+
unknownUtilization?: UnknownUtilizationPolicy;
|
|
168
|
+
/** Billing modes this request may use. Default: subscription only. */
|
|
169
|
+
allowedBilling?: readonly BillingMode[];
|
|
74
170
|
}
|
|
75
171
|
|
|
76
172
|
export interface RankedCandidate {
|
|
77
173
|
accountId: string;
|
|
78
|
-
|
|
79
|
-
|
|
174
|
+
/** Null when no usable observation exists and the policy is `rank-last`. */
|
|
175
|
+
utilizationPercent: number | null;
|
|
176
|
+
quartile: number | null;
|
|
177
|
+
priority: number;
|
|
178
|
+
headroom: number;
|
|
80
179
|
preferred: boolean;
|
|
81
180
|
}
|
|
82
181
|
|
|
83
182
|
export type ExclusionReason =
|
|
84
183
|
| 'disabled'
|
|
85
|
-
| '
|
|
184
|
+
| 'billing'
|
|
86
185
|
| 'organization'
|
|
87
186
|
| 'runtime'
|
|
88
187
|
| 'model'
|
|
@@ -133,17 +232,19 @@ export function repositoryIdentity(remote: string): string {
|
|
|
133
232
|
export function assessCandidate(
|
|
134
233
|
request: CandidateRequest,
|
|
135
234
|
account: SubscriptionCandidate,
|
|
136
|
-
): { excluded: ExclusionReason } | { utilizationPercent: number } {
|
|
235
|
+
): { excluded: ExclusionReason } | { utilizationPercent: number | null } {
|
|
137
236
|
if (
|
|
138
237
|
!account.id ||
|
|
139
238
|
!Number.isSafeInteger(account.activeRuns) ||
|
|
140
239
|
account.activeRuns < 0 ||
|
|
141
240
|
!Number.isSafeInteger(account.concurrencyLimit) ||
|
|
142
|
-
account.concurrencyLimit < 1
|
|
241
|
+
account.concurrencyLimit < 1 ||
|
|
242
|
+
(account.priority !== undefined && !Number.isFinite(account.priority))
|
|
143
243
|
)
|
|
144
244
|
return { excluded: 'invalid' };
|
|
145
245
|
if (!account.enabled) return { excluded: 'disabled' };
|
|
146
|
-
if (
|
|
246
|
+
if (!(request.allowedBilling ?? DEFAULT_BILLING).includes(account.authentication))
|
|
247
|
+
return { excluded: 'billing' };
|
|
147
248
|
if (
|
|
148
249
|
request.organizationId !== undefined &&
|
|
149
250
|
!account.organizationIds.includes(request.organizationId)
|
|
@@ -151,13 +252,16 @@ export function assessCandidate(
|
|
|
151
252
|
return { excluded: 'organization' };
|
|
152
253
|
if (account.runtime !== request.runtime) return { excluded: 'runtime' };
|
|
153
254
|
if (!account.models[request.model]?.includes(request.effort)) return { excluded: 'model' };
|
|
154
|
-
if (request.
|
|
155
|
-
return { excluded: 'not-pinned' };
|
|
255
|
+
if (request.pinned && account.id !== request.preferredId) return { excluded: 'not-pinned' };
|
|
156
256
|
if (account.unavailableUntil !== undefined && account.unavailableUntil > request.now)
|
|
157
257
|
return { excluded: 'unavailable' };
|
|
158
258
|
if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
|
|
159
259
|
if (account.blocked) return { excluded: 'blocked' };
|
|
160
|
-
|
|
260
|
+
const unreadable = request.unknownUtilization === 'rank-last' ? null : undefined;
|
|
261
|
+
if (account.windows.length === 0) {
|
|
262
|
+
if (unreadable === undefined) return { excluded: 'no-observation' };
|
|
263
|
+
return { utilizationPercent: null };
|
|
264
|
+
}
|
|
161
265
|
const fractions: number[] = [];
|
|
162
266
|
for (const window of account.windows) {
|
|
163
267
|
if (
|
|
@@ -168,7 +272,7 @@ export function assessCandidate(
|
|
|
168
272
|
!Number.isFinite(window.reservedPercent) ||
|
|
169
273
|
window.reservedPercent < 0
|
|
170
274
|
)
|
|
171
|
-
return { excluded: 'invalid' };
|
|
275
|
+
return unreadable === undefined ? { excluded: 'invalid' } : { utilizationPercent: null };
|
|
172
276
|
// resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
|
|
173
277
|
if (
|
|
174
278
|
!Number.isFinite(window.observedAt) ||
|
|
@@ -177,28 +281,46 @@ export function assessCandidate(
|
|
|
177
281
|
(window.resetsAt !== Number.POSITIVE_INFINITY &&
|
|
178
282
|
(!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
|
|
179
283
|
)
|
|
180
|
-
return
|
|
284
|
+
return unreadable === undefined
|
|
285
|
+
? { excluded: 'stale-observation' }
|
|
286
|
+
: { utilizationPercent: null };
|
|
181
287
|
fractions.push(window.usedPercent + window.reservedPercent);
|
|
182
288
|
}
|
|
183
289
|
if (fractions.some((p) => p >= 100)) return { excluded: 'exhausted' };
|
|
184
290
|
return { utilizationPercent: Math.max(...fractions) };
|
|
185
291
|
}
|
|
186
292
|
|
|
293
|
+
/** Term fields as own properties, so json-rules orders them by name. */
|
|
294
|
+
const sortKey = (candidate: RankedCandidate, index: number) => ({
|
|
295
|
+
index,
|
|
296
|
+
priority: candidate.priority,
|
|
297
|
+
utilization: candidate.utilizationPercent,
|
|
298
|
+
quartile: candidate.quartile,
|
|
299
|
+
headroom: candidate.headroom,
|
|
300
|
+
preferred: Number(candidate.preferred),
|
|
301
|
+
id: candidate.accountId,
|
|
302
|
+
});
|
|
303
|
+
|
|
187
304
|
/** Rank eligible candidates deterministically (ties broken by id). */
|
|
188
305
|
export function rankCandidates(
|
|
189
306
|
request: CandidateRequest,
|
|
190
307
|
accounts: readonly SubscriptionCandidate[],
|
|
191
|
-
): {
|
|
308
|
+
): {
|
|
309
|
+
candidates: RankedCandidate[];
|
|
310
|
+
excluded: Record<string, ExclusionReason>;
|
|
311
|
+
ordering: ResolvedOrdering;
|
|
312
|
+
} {
|
|
192
313
|
if (
|
|
193
314
|
!Number.isFinite(request.now) ||
|
|
194
315
|
!Number.isFinite(request.maximumObservationAgeMs) ||
|
|
195
316
|
request.maximumObservationAgeMs <= 0
|
|
196
317
|
)
|
|
197
318
|
throw Error('Invalid observation policy');
|
|
198
|
-
if (request.
|
|
319
|
+
if (request.pinned && !request.preferredId)
|
|
199
320
|
throw Error('Pinned routing requires a preferred instance');
|
|
200
321
|
if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
|
|
201
322
|
throw Error('Duplicate account identity');
|
|
323
|
+
const ordering = resolveOrdering(request.ordering);
|
|
202
324
|
const excluded: Record<string, ExclusionReason> = {};
|
|
203
325
|
const ranked = accounts.flatMap((account) => {
|
|
204
326
|
const assessed = assessCandidate(request, account);
|
|
@@ -211,22 +333,15 @@ export function rankCandidates(
|
|
|
211
333
|
{
|
|
212
334
|
accountId: account.id,
|
|
213
335
|
utilizationPercent,
|
|
214
|
-
quartile: Math.floor(utilizationPercent / 25),
|
|
336
|
+
quartile: utilizationPercent === null ? null : Math.floor(utilizationPercent / 25),
|
|
337
|
+
priority: account.priority ?? 0,
|
|
338
|
+
headroom: account.concurrencyLimit - account.activeRuns,
|
|
215
339
|
preferred: account.id === request.preferredId,
|
|
216
340
|
},
|
|
217
341
|
];
|
|
218
342
|
});
|
|
219
|
-
ranked.
|
|
220
|
-
|
|
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 };
|
|
343
|
+
const ordered = orderRecords(ranked.map(sortKey), [...ordering, { field: 'id', dir: 'asc' }]);
|
|
344
|
+
return { candidates: ordered.map((key) => ranked[key.index]!), excluded, ordering };
|
|
230
345
|
}
|
|
231
346
|
|
|
232
347
|
/** The draft's repository-scoped entry point: resolve ownership, then rank. */
|
|
@@ -249,7 +364,7 @@ export function rankSubscriptionAccounts(
|
|
|
249
364
|
throw Error('Incomplete repository routing rule');
|
|
250
365
|
if (request.organizationId && request.organizationId !== route.organizationId)
|
|
251
366
|
throw Error('Explicit organization conflicts with repository ownership');
|
|
252
|
-
const { candidates } = rankCandidates(
|
|
367
|
+
const { candidates, ordering } = rankCandidates(
|
|
253
368
|
{
|
|
254
369
|
organizationId: route.organizationId,
|
|
255
370
|
preferredId: route.preferredAccountId,
|
|
@@ -258,7 +373,12 @@ export function rankSubscriptionAccounts(
|
|
|
258
373
|
effort: request.effort,
|
|
259
374
|
now: request.now,
|
|
260
375
|
maximumObservationAgeMs: request.maximumObservationAgeMs,
|
|
261
|
-
|
|
376
|
+
ordering: request.ordering,
|
|
377
|
+
...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
|
|
378
|
+
...(request.unknownUtilization !== undefined
|
|
379
|
+
? { unknownUtilization: request.unknownUtilization }
|
|
380
|
+
: {}),
|
|
381
|
+
...(request.allowedBilling !== undefined ? { allowedBilling: request.allowedBilling } : {}),
|
|
262
382
|
},
|
|
263
383
|
accounts,
|
|
264
384
|
);
|
|
@@ -267,7 +387,7 @@ export function rankSubscriptionAccounts(
|
|
|
267
387
|
organizationId: route.organizationId,
|
|
268
388
|
model: request.model,
|
|
269
389
|
effort: request.effort,
|
|
270
|
-
|
|
390
|
+
ordering,
|
|
271
391
|
candidates,
|
|
272
392
|
};
|
|
273
393
|
}
|