@intentic/sandbox-contract 1.345.0 → 1.346.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 +26 -14
- package/dist/contracts/accounts.contract.d.ts +0 -80
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +33 -2
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +910 -29
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +43 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/device.contract.d.ts +2 -1
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +0 -1
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/providers.contract.d.ts +0 -1
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +134 -123
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +4 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +102 -1
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +0 -80
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +43 -0
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +42 -37
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/failure-rows.d.ts +14 -0
- package/dist/events/failure-rows.d.ts.map +1 -0
- package/dist/events/failure-rows.js +109 -0
- package/dist/events/failure-rows.js.map +1 -0
- package/dist/events/sandbox-notice.d.ts +16 -0
- package/dist/events/sandbox-notice.d.ts.map +1 -1
- package/dist/events/sandbox-notice.js +1 -0
- package/dist/events/sandbox-notice.js.map +1 -1
- package/dist/events/system-events.d.ts +68 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/system-events.js +7 -1
- package/dist/events/system-events.js.map +1 -1
- package/dist/index.d.ts +1158 -303
- package/dist/index.d.ts.map +1 -1
- package/dist/models/agent-lanes.d.ts.map +1 -1
- package/dist/models/agent-lanes.js +3 -5
- package/dist/models/agent-lanes.js.map +1 -1
- package/dist/models/agent-standing.d.ts +3 -20
- package/dist/models/agent-standing.d.ts.map +1 -1
- package/dist/models/agent-standing.js +12 -37
- package/dist/models/agent-standing.js.map +1 -1
- package/dist/models/model-pins.d.ts +3 -0
- package/dist/models/model-pins.d.ts.map +1 -1
- package/dist/models/model-pins.js +2 -0
- package/dist/models/model-pins.js.map +1 -1
- package/dist/models/plan-pools.d.ts +23 -5
- package/dist/models/plan-pools.d.ts.map +1 -1
- package/dist/models/plan-pools.js +116 -33
- package/dist/models/plan-pools.js.map +1 -1
- package/dist/protocol/doors/runner-protocol.d.ts +1 -0
- package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
- package/dist/protocol/doors/runner-protocol.js +1 -0
- package/dist/protocol/doors/runner-protocol.js.map +1 -1
- package/dist/protocol/raw/raw-json-routes.d.ts +3 -1
- package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.d.ts +4 -0
- package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.js +1 -0
- package/dist/protocol/raw/raw-routes.js.map +1 -1
- package/dist/schemas/agent-next.d.ts +21 -0
- package/dist/schemas/agent-next.d.ts.map +1 -0
- package/dist/schemas/agent-next.js +21 -0
- package/dist/schemas/agent-next.js.map +1 -0
- package/dist/schemas/agent-search.d.ts +45 -0
- package/dist/schemas/agent-search.d.ts.map +1 -0
- package/dist/schemas/agent-search.js +28 -0
- package/dist/schemas/agent-search.js.map +1 -0
- package/dist/schemas/agent.d.ts +9 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +14 -10
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents-list.d.ts +16 -0
- package/dist/schemas/agents-list.d.ts.map +1 -1
- package/dist/schemas/agents.d.ts +48 -43
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +10 -33
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/compose-overlay.d.ts +9 -0
- package/dist/schemas/compose-overlay.d.ts.map +1 -0
- package/dist/schemas/compose-overlay.js +11 -0
- package/dist/schemas/compose-overlay.js.map +1 -0
- package/dist/schemas/composer-drafts.d.ts +169 -0
- package/dist/schemas/composer-drafts.d.ts.map +1 -0
- package/dist/schemas/composer-drafts.js +60 -0
- package/dist/schemas/composer-drafts.js.map +1 -0
- package/dist/schemas/device-commands.d.ts +63 -0
- package/dist/schemas/device-commands.d.ts.map +1 -0
- package/dist/schemas/device-commands.js +42 -0
- package/dist/schemas/device-commands.js.map +1 -0
- package/dist/schemas/device-folder.d.ts +11 -0
- package/dist/schemas/device-folder.d.ts.map +1 -1
- package/dist/schemas/device-folder.js +1 -0
- package/dist/schemas/device-folder.js.map +1 -1
- package/dist/schemas/devices.d.ts +14 -67
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +7 -45
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/environment.d.ts.map +1 -1
- package/dist/schemas/environment.js.map +1 -1
- package/dist/schemas/hosts.d.ts +27 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +21 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/privacy-egress.d.ts +41 -0
- package/dist/schemas/privacy-egress.d.ts.map +1 -0
- package/dist/schemas/privacy-egress.js +30 -0
- package/dist/schemas/privacy-egress.js.map +1 -0
- package/dist/schemas/privacy.d.ts +1 -0
- package/dist/schemas/privacy.d.ts.map +1 -1
- package/dist/schemas/privacy.js +1 -0
- package/dist/schemas/privacy.js.map +1 -1
- package/dist/schemas/providers/plan-limits.d.ts +2 -130
- package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
- package/dist/schemas/providers/plan-limits.js +5 -23
- package/dist/schemas/providers/plan-limits.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +0 -82
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/providers/provider-oauth.js +1 -6
- package/dist/schemas/providers/provider-oauth.js.map +1 -1
- package/dist/schemas/providers/refusals.d.ts +64 -0
- package/dist/schemas/providers/refusals.d.ts.map +1 -0
- package/dist/schemas/providers/refusals.js +33 -0
- package/dist/schemas/providers/refusals.js.map +1 -0
- package/dist/schemas/sessions.js +1 -1
- package/dist/schemas/sessions.js.map +1 -1
- package/dist/schemas/settings.d.ts +4 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +2 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/system.d.ts +16 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +11 -0
- package/dist/schemas/system.js.map +1 -1
- package/dist/state/definition.d.ts +8 -0
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/runtime-state.d.ts +3 -0
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +1 -0
- package/dist/state/runtime-state.js.map +1 -1
- package/dist/state/sandbox-build.d.ts +20 -0
- package/dist/state/sandbox-build.d.ts.map +1 -0
- package/dist/state/sandbox-build.js +43 -0
- package/dist/state/sandbox-build.js.map +1 -0
- package/dist/state/ways-back.d.ts +4 -0
- package/dist/state/ways-back.d.ts.map +1 -0
- package/dist/state/ways-back.js +8 -0
- package/dist/state/ways-back.js.map +1 -0
- package/dist/text/land-rows.d.ts +14 -0
- package/dist/text/land-rows.d.ts.map +1 -0
- package/dist/text/land-rows.js +86 -0
- package/dist/text/land-rows.js.map +1 -0
- package/dist/text/transcript-fold.d.ts +4 -2
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +21 -187
- package/dist/text/transcript-fold.js.map +1 -1
- package/package.json +6 -6
- package/src/contracts/agents.contract.ts +49 -25
- package/src/events/agent-events.ts +95 -78
- package/src/events/failure-rows.ts +148 -0
- package/src/events/sandbox-notice.ts +11 -2
- package/src/events/system-events.ts +14 -4
- package/src/ids/project-dir.test.ts +2 -2
- package/src/ids/project-dir.ts +4 -4
- package/src/models/agent-lanes.ts +7 -21
- package/src/models/agent-runtimes.ts +2 -2
- package/src/models/agent-standing.ts +31 -96
- package/src/models/model-pins.test.ts +31 -1
- package/src/models/model-pins.ts +17 -0
- package/src/models/plan-pools.test.ts +159 -24
- package/src/models/plan-pools.ts +215 -83
- package/src/protocol/doors/runner-protocol.ts +4 -1
- package/src/protocol/raw/raw-routes.ts +1 -0
- package/src/schemas/agent-next.ts +34 -0
- package/src/schemas/agent-search.ts +51 -0
- package/src/schemas/agent.ts +21 -12
- package/src/schemas/agents.ts +24 -63
- package/src/schemas/compose-overlay.test.ts +35 -0
- package/src/schemas/compose-overlay.ts +19 -0
- package/src/schemas/composer-drafts.ts +86 -0
- package/src/schemas/device-commands.ts +89 -0
- package/src/schemas/device-folder.ts +9 -0
- package/src/schemas/devices.ts +24 -106
- package/src/schemas/environment.ts +8 -9
- package/src/schemas/hosts.ts +56 -0
- package/src/schemas/machine-presence.test.ts +33 -0
- package/src/schemas/privacy-egress.test.ts +83 -0
- package/src/schemas/privacy-egress.ts +86 -0
- package/src/schemas/privacy.ts +4 -0
- package/src/schemas/project-delivery.ts +1 -1
- package/src/schemas/providers/plan-limits.test.ts +16 -0
- package/src/schemas/providers/plan-limits.ts +8 -49
- package/src/schemas/providers/provider-oauth.ts +1 -13
- package/src/schemas/providers/refusals.ts +62 -0
- package/src/schemas/sessions.ts +1 -1
- package/src/schemas/settings.ts +2 -0
- package/src/schemas/system.ts +15 -0
- package/src/state/runtime-state.ts +5 -0
- package/src/state/sandbox-build.test.ts +95 -0
- package/src/state/sandbox-build.ts +96 -0
- package/src/state/ways-back.test.ts +36 -0
- package/src/state/ways-back.ts +19 -0
- package/src/state/workspace-state.ts +1 -1
- package/src/text/land-rows.ts +111 -0
- package/src/text/transcript-fold.test.ts +38 -9
- package/src/text/transcript-fold.ts +30 -232
- package/dist/events/undelivered.d.ts +0 -10
- package/dist/events/undelivered.d.ts.map +0 -1
- package/dist/events/undelivered.js +0 -22
- package/dist/events/undelivered.js.map +0 -1
- package/dist/state/history-state.d.ts +0 -3
- package/dist/state/history-state.d.ts.map +0 -1
- package/dist/state/history-state.js +0 -153
- package/dist/state/history-state.js.map +0 -1
- package/src/events/undelivered.ts +0 -33
- package/src/state/history-state.ts +0 -313
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type LandConflictReason,
|
|
8
8
|
type LandFailure,
|
|
9
9
|
} from "../schemas/agents.js";
|
|
10
|
-
import type
|
|
10
|
+
import { type AgentNext, goesBySelf } from "../schemas/agent-next.js";
|
|
11
11
|
|
|
12
12
|
// (2026-10-16) Moved here from the editor (_editor/web/src/models/agent-status/agentStatus-standing.ts, which re-exports
|
|
13
13
|
// it), so the daemon counts what needs the person by the very rules the board draws with (models/agent-lanes.ts): the
|
|
@@ -47,78 +47,33 @@ export interface AgentStanding {
|
|
|
47
47
|
readonly failureCode?: string | undefined;
|
|
48
48
|
// The words that turn died on (AgentSummary.failure), cleared with the code when it runs again.
|
|
49
49
|
readonly failure?: string | undefined;
|
|
50
|
+
// Whether those words are the sandbox's own sentence (AgentSummary.failureOwn): what a card may lead with.
|
|
51
|
+
readonly failureOwn?: boolean | undefined;
|
|
50
52
|
/** When the spent allowance reopens, in epoch SECONDS (the wire's unit). Absent when nobody published one. */
|
|
51
53
|
readonly limitResetsAt?: number | undefined;
|
|
52
54
|
/** Whether the refused turn is held whole, so a press re-runs it rather than sending a new message after it. */
|
|
53
55
|
readonly limitHeld?: boolean | undefined;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
readonly limitMoving?: string | undefined;
|
|
58
|
-
/** When the booked re-run goes, in epoch SECONDS, while one is booked (limitScheduled): the pass's own instant. */
|
|
59
|
-
readonly limitNextAt?: number | undefined;
|
|
56
|
+
// What carries it on with nobody pressing anything, and when (AgentSummary.next): the daemon's one reading, which
|
|
57
|
+
// every lane, chip and corner below asks, never the hold, the queue or an answer it was derived from.
|
|
58
|
+
readonly next?: AgentNext | undefined;
|
|
60
59
|
// A land that broke (AgentSummary.landFailure), standing until one goes through whatever turns run after it. Absent
|
|
61
60
|
// from a sandbox older than it, where only a turn's own `failure` can say so.
|
|
62
61
|
readonly landFailure?: LandFailure | undefined;
|
|
63
|
-
// What waits for its next turn (AgentSummary.queue), read here only for its hold: messages booked to go by
|
|
64
|
-
// themselves at a time or once another agent's work lands (`scheduled`), which make the conversation one that runs
|
|
65
|
-
// again by itself. Never the words.
|
|
66
|
-
readonly queue?: QueueHold | undefined;
|
|
67
62
|
// The conversation whose work this one was opened on, for a fresh session (AgentSummary.fresh): a person's own
|
|
68
63
|
// review, test or ask, which hangs under that conversation's card and is never reported to it
|
|
69
64
|
// (subagents/child-report.ts), so nothing it stops on is that conversation's news. Absent for every other one.
|
|
70
65
|
readonly fresh?: { readonly of: string } | undefined;
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
// The part of a conversation's queue the board reads: why it holds, and what a scheduled hold waits for. A queue-level
|
|
74
|
-
// `scheduled` with its `until` or `after` is the soonest booked message's; a Stop's or a refusal's hold on the messages
|
|
75
|
-
// with no booking takes that place, so each message's own booking (`items`, from sandboxes that book each message on its
|
|
76
|
-
// own) is read then.
|
|
77
|
-
export interface QueueHold {
|
|
78
|
-
readonly paused?: QueuePause | undefined;
|
|
79
|
-
readonly until?: number | undefined;
|
|
80
|
-
readonly after?: string | undefined;
|
|
81
|
-
readonly items?: readonly { readonly until?: number | undefined; readonly after?: string | undefined }[] | undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// The soonest booking a queue holds, as a scheduled hold: the queue's own while it reads `scheduled`, else the earliest
|
|
85
|
-
// message's time, else the first message waiting on a land; undefined when nothing is booked. One thing, never both.
|
|
86
|
-
export const soonestHold = (queue: QueueHold | undefined): QueueHold | undefined => {
|
|
87
|
-
if (queue?.paused === "scheduled") {
|
|
88
|
-
if (queue.after !== undefined) {
|
|
89
|
-
return { paused: "scheduled", after: queue.after };
|
|
90
|
-
}
|
|
91
|
-
return queue.until === undefined ? { paused: "scheduled" } : { paused: "scheduled", until: queue.until };
|
|
92
|
-
}
|
|
93
|
-
const items = queue?.items ?? [];
|
|
94
|
-
const instants = items.flatMap((item) => (item.until === undefined ? [] : [item.until]));
|
|
95
|
-
if (instants.length > 0) {
|
|
96
|
-
return { paused: "scheduled", until: Math.min(...instants) };
|
|
97
|
-
}
|
|
98
|
-
const after = items.find((item) => item.after !== undefined)?.after;
|
|
99
|
-
return after === undefined ? undefined : { paused: "scheduled", after };
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
// Messages booked to go by themselves (sendLater.ts, or a spent allowance's reopen): the conversation runs again with
|
|
103
|
-
// nobody pressing anything, so it is not finished, and nothing is owed by the reader for it to.
|
|
104
|
-
export const scheduledSend = (agent: Pick<AgentStanding, "queue">): boolean => soonestHold(agent.queue) !== undefined;
|
|
105
|
-
|
|
106
|
-
// How many messages are booked to go by themselves: each by its own booking, or all of them when an older sandbox booked
|
|
107
|
-
// the queue whole. What an archive by hand cancels (the sandbox's archive.ts), which the card's Archive says first.
|
|
108
|
-
export const scheduledCount = (agent: Pick<AgentStanding, "queue">): number => {
|
|
109
|
-
const items = agent.queue?.items ?? [];
|
|
110
|
-
return agent.queue?.paused === "scheduled" ? items.length : items.filter((item) => item.until !== undefined || item.after !== undefined).length;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
68
|
// A spent allowance, not a failure to fix: nothing broken, and it comes back. Read off `failureCode` rather than
|
|
114
69
|
// the provider's own sentence, which varies per provider; the code is the daemon's own classification
|
|
115
70
|
// (error-frames.ts).
|
|
116
71
|
export const limited = (agent: AgentStanding): boolean => agent.status === "error" && agent.failureCode === "rate_limit";
|
|
117
72
|
|
|
118
|
-
// A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
73
|
+
// A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed.
|
|
74
|
+
// The daemon files it as an `error`, so this is read off the code, as `limited` is, and the chip and glyph say held
|
|
75
|
+
// rather than error. Its lane is any ending's: Attention until something carries it on (`next`, a send once memory
|
|
76
|
+
// frees or a booking for later), Active from then.
|
|
122
77
|
export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">): boolean =>
|
|
123
78
|
agent.status === "error" && agent.failureCode === "sandbox-memory-low";
|
|
124
79
|
|
|
@@ -127,16 +82,6 @@ export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">)
|
|
|
127
82
|
export const limitClosed = (agent: AgentStanding, now: number = Date.now()): boolean =>
|
|
128
83
|
limited(agent) && agent.limitResetsAt !== undefined && agent.limitResetsAt * 1_000 > now;
|
|
129
84
|
|
|
130
|
-
// A booked fire needs no press: the held turn goes again at the reset by itself. Only where this conversation's answer
|
|
131
|
-
// for the limit says so (`resend` or `move`); that answer starts at `wait`. Nothing is owed then, so it leaves Attention
|
|
132
|
-
// for Active, as an armed watch does: a card counted in the badge and the tab title for the hours until a reset the
|
|
133
|
-
// reader already answered was a call nobody could answer. The stall still shows, on the card's own chip and clock.
|
|
134
|
-
export const limitScheduled = (agent: AgentStanding): boolean => limited(agent) && agent.limitScheduled === true;
|
|
135
|
-
|
|
136
|
-
// A booked move: the owner's policy is already carrying the held turn to another account, on the resume pass's next
|
|
137
|
-
// beat, seconds away, so the card names where it is going rather than when it goes.
|
|
138
|
-
export const limitMoveBooked = (agent: AgentStanding): boolean => limitScheduled(agent) && agent.limitMoving !== undefined;
|
|
139
|
-
|
|
140
85
|
// A refused land nothing the agent does can clear: every blocker left is a file the user has uncommitted edits on,
|
|
141
86
|
// which only a commit or stash releases (git cannot merge through unstaged work, and the agent's checkout cannot
|
|
142
87
|
// even see it). The distinction the board used to lack: it offered "have the agent resolve it" for every conflict,
|
|
@@ -224,44 +169,35 @@ export const writingNow = (agent: AgentStanding): boolean => agent.status === "r
|
|
|
224
169
|
// "Blocked on you": the agent cannot proceed (or has failed) until the user acts. Not the same as unread.
|
|
225
170
|
// `stopped`/`interrupted` leave a half-written worktree only a new message can carry forward; `stopping` is
|
|
226
171
|
// counted the moment the press lands rather than once the unwind finishes, since it settles in the same lane
|
|
227
|
-
// either way; `failed` is the same kind of dead end one step earlier, before a worktree existed.
|
|
228
|
-
// counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now. Any settled
|
|
229
|
-
// dead end counts until a booking answers it (bookingAnswers).
|
|
172
|
+
// either way; `failed` is the same kind of dead end one step earlier, before a worktree existed.
|
|
230
173
|
const BLOCKING_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopping", "stopped", "failed"]);
|
|
231
174
|
|
|
232
175
|
// A land that broke and still stands: its work is stuck on the branch, whatever the turn after it did, so the card is
|
|
233
176
|
// the reader's to look at. Not while a turn or a land is under way, which ends in another land of its own.
|
|
234
177
|
export const landBroken = (agent: AgentStanding): boolean => agent.landFailure !== undefined && !turnInFlight(agent);
|
|
235
178
|
|
|
236
|
-
// The settled dead ends a booking can answer. Not `stopping`, which is still unwinding and rests in Attention whatever
|
|
237
|
-
// waits behind it.
|
|
238
|
-
const ANSWERABLE_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopped", "failed"]);
|
|
239
|
-
|
|
240
179
|
/**
|
|
241
|
-
* A turn that ended badly, with
|
|
242
|
-
*
|
|
243
|
-
* ending, or the sandbox's on their standing word, so the
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* only the ending: an ask (a plan, a question, a permission, a land only the reader can clear) still calls them.
|
|
180
|
+
* A turn that ended badly, with nothing set to carry it on: a dead end the reader owes a press. Once something will
|
|
181
|
+
* (`next`: a held turn re-run at its reopen or moved, a send once memory frees, a message booked for later), that is the
|
|
182
|
+
* answer to the ending, the person's or the sandbox's on their standing word, so the card is Active and its corner says
|
|
183
|
+
* when it goes. Before 2026-10-20 the lane asked two of those, the card's corner three, the chat's bar one more, and
|
|
184
|
+
* they disagreed. Not `stopping`, still unwinding, which rests in Attention whatever waits behind it. Answers only the
|
|
185
|
+
* ending: an ask (a plan, a question, a permission, a land only the reader can clear) still calls the reader.
|
|
248
186
|
*/
|
|
249
|
-
|
|
187
|
+
const deadEnd = (agent: AgentStanding): boolean => agent.status === "stopping" || (BLOCKING_ENDINGS.has(agent.status) && !goesBySelf(agent));
|
|
250
188
|
|
|
251
189
|
export const blocked = (agent: AgentStanding): boolean =>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
heldByPrompt(agent) ||
|
|
264
|
-
(BLOCKING_ENDINGS.has(agent.status) && !bookingAnswers(agent));
|
|
190
|
+
agent.attention.plan ||
|
|
191
|
+
agent.attention.question ||
|
|
192
|
+
agent.attention.permission ||
|
|
193
|
+
agent.attention.capability ||
|
|
194
|
+
agent.attention.conflict ||
|
|
195
|
+
// A need outlives its turn (docs/architecture/needs.md): a conversation still running carries on beside it and
|
|
196
|
+
// stays in Active, one that stopped is waiting on nothing but the answer.
|
|
197
|
+
(agent.attention.need === true && agent.status !== "running") ||
|
|
198
|
+
landBroken(agent) ||
|
|
199
|
+
heldByPrompt(agent) ||
|
|
200
|
+
deadEnd(agent);
|
|
265
201
|
|
|
266
202
|
// The half of `blocked` that is literally waiting on an answer (plan, question, permission, capability), narrower
|
|
267
203
|
// than `blocked`: dead ends (a failed turn, an unlandable conflict) want looking at but owe the user nothing, so
|
|
@@ -296,9 +232,8 @@ export const heardByParent = (child: AgentStanding & { readonly updatedAt: numbe
|
|
|
296
232
|
BLOCKING_ENDINGS.has(child.status) &&
|
|
297
233
|
// Still unwinding: its ending has not reached anyone yet.
|
|
298
234
|
child.status !== "stopping" &&
|
|
299
|
-
//
|
|
300
|
-
!
|
|
301
|
-
!bookingAnswers(child) &&
|
|
235
|
+
// Carried on by itself: not over, whatever its parent made of the pause.
|
|
236
|
+
!goesBySelf(child) &&
|
|
302
237
|
!child.attention.question &&
|
|
303
238
|
!child.attention.conflict &&
|
|
304
239
|
!landBroken(child) &&
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ModelSource, modelPinKey, parsePinned, readyChain } from "./model-pins.js";
|
|
1
|
+
import { type ModelSource, modelPinKey, openingRung, parsePinned, readyChain, rungModel } from "./model-pins.js";
|
|
2
|
+
import type { FleetCapacity } from "./plan-pools.js";
|
|
2
3
|
import type { ModelPin } from "../schemas/agent.js";
|
|
3
4
|
|
|
4
5
|
// Which models a job spends, and in what order: the owner's own list decides, nothing is derived for an empty one, and
|
|
@@ -84,3 +85,32 @@ test("honours a pin on a configured endpoint: the whole id, not the half before
|
|
|
84
85
|
// parsePinned splits on the first colon; an id with its own colon needs the slash form or parsing breaks.
|
|
85
86
|
expect(modelPinKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
// The rung a chain opens on, read by the daemon's run roles and the editor's job button alike.
|
|
90
|
+
describe("openingRung", () => {
|
|
91
|
+
const chain = [`codex:gpt-6.1-sol`, `claude:claude-opus-5`, `kimi:kimi-k3`].map(pin);
|
|
92
|
+
const spent: FleetCapacity = { kind: `spent`, accounts: 1, reopensAt: 5_000, pool: undefined };
|
|
93
|
+
const serving: FleetCapacity = { kind: `serving`, serving: 1, accounts: 1 };
|
|
94
|
+
const capacities = (spentOn: readonly string[]) => (rung: ModelPin): FleetCapacity | undefined => (spentOn.includes(rung.provider) ? spent : serving);
|
|
95
|
+
|
|
96
|
+
test("opens on the first rung no account is spent on, stepping over a spent head", () => {
|
|
97
|
+
expect(openingRung(chain, capacities([]))).toBe(chain[0]);
|
|
98
|
+
expect(openingRung(chain, capacities([`codex`]))).toBe(chain[1]);
|
|
99
|
+
expect(openingRung(chain, capacities([`codex`, `claude`]))).toBe(chain[2]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("an unread capacity steps nothing over, and the caller's own reason does", () => {
|
|
103
|
+
expect(openingRung(chain, () => undefined)).toBe(chain[0]);
|
|
104
|
+
expect(openingRung(chain, capacities([]), (rung) => rung.provider === `codex`)).toBe(chain[1]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("opens on the head when every rung is stepped over, and on nothing for an empty chain", () => {
|
|
108
|
+
expect(openingRung(chain, capacities([`codex`, `claude`, `kimi`]))).toBe(chain[0]);
|
|
109
|
+
expect(openingRung([], capacities([]))).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("an agent with no model id asks about its provider's accounts as a whole", () => {
|
|
113
|
+
expect(rungModel({ provider: `cursor`, model: `` })).toBeUndefined();
|
|
114
|
+
expect(rungModel({ provider: `codex`, model: `gpt-6.1-sol` })).toEqual({ id: `gpt-6.1-sol` });
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/models/model-pins.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentProvider, ModelPin } from "../schemas/agent.js";
|
|
2
|
+
import type { FleetCapacity, ModelRef } from "./plan-pools.js";
|
|
2
3
|
|
|
3
4
|
// The order to try models in for a job's setup (model-setups.ts), shared by daemon and browser so both agree what a job
|
|
4
5
|
// will spend before it runs. An empty list resolves to nothing: the caller decides what that means (a helper is off; a
|
|
@@ -50,3 +51,19 @@ export const readyChain = (sources: readonly ModelSource[], pinned: readonly Mod
|
|
|
50
51
|
}
|
|
51
52
|
return chain;
|
|
52
53
|
};
|
|
54
|
+
|
|
55
|
+
// The model a rung asks capacity about (plan-pools.ts fleetCapacity): an ACP agent's empty id asks about the provider's
|
|
56
|
+
// accounts as a whole.
|
|
57
|
+
export const rungModel = (choice: ModelChoice): ModelRef | undefined => (choice.model === `` ? undefined : { id: choice.model });
|
|
58
|
+
|
|
59
|
+
// The rung a ready chain opens on: the first whose capacity no connected account is spent on and that `steppedOver`
|
|
60
|
+
// (what else the caller knows, the daemon's failing streaks) lets through, else the head, since every reading that
|
|
61
|
+
// stepped one over is a snapshot that may already be stale. The daemon's run roles open on it (run-role-model.ts) and
|
|
62
|
+
// the editor names it on the job's button (roleModel.ts), each over the same capacity verdict, so the button never
|
|
63
|
+
// names a rung the run would step over. 2026-10-20: the editor took the head by readiness alone, and named a spent
|
|
64
|
+
// account's model while the run opened on the next.
|
|
65
|
+
export const openingRung = <T extends ModelChoice>(
|
|
66
|
+
chain: readonly T[],
|
|
67
|
+
capacityOf: (rung: T) => FleetCapacity | undefined,
|
|
68
|
+
steppedOver: (rung: T) => boolean = () => false,
|
|
69
|
+
): T | undefined => chain.find((rung) => capacityOf(rung)?.kind !== `spent` && !steppedOver(rung)) ?? chain[0];
|
|
@@ -4,11 +4,12 @@ import {
|
|
|
4
4
|
currentWindows,
|
|
5
5
|
gatesModel,
|
|
6
6
|
gatingWindows,
|
|
7
|
-
|
|
7
|
+
judgedReading,
|
|
8
|
+
fleetCapacity,
|
|
8
9
|
readingHeadroom,
|
|
9
10
|
preferredAccount,
|
|
10
11
|
refusalVerdict,
|
|
11
|
-
|
|
12
|
+
refusedPoolReset,
|
|
12
13
|
roomiestAccount,
|
|
13
14
|
scopedWindow,
|
|
14
15
|
uncoveredModels,
|
|
@@ -166,7 +167,7 @@ test("spent is the contract's one line: 99.5% still has room, 100% is spent unti
|
|
|
166
167
|
expect(readingHeadroom(usage(window({ kind: "five_hour", utilization: 100 })))).toEqual({ kind: "spent" });
|
|
167
168
|
expect(readingHeadroom(undefined)).toEqual({ kind: "unknown" });
|
|
168
169
|
expect(readingHeadroom(CLAUDE, { id: "claude-haiku-4-5" })).toEqual({ kind: "ready", room: 70 });
|
|
169
|
-
expect(readingHeadroom(CLAUDE, { id: "claude-opus-4-6" })).toEqual({ kind: "spent" });
|
|
170
|
+
expect(readingHeadroom(CLAUDE, { id: "claude-opus-4-6" })).toEqual({ kind: "spent", pool: "Opus" });
|
|
170
171
|
});
|
|
171
172
|
|
|
172
173
|
// Anthropic reports whole percents: an account read at 100 kept serving turns for hours, so that reading is the line,
|
|
@@ -182,7 +183,7 @@ test("a pool read at 100 in whole percents is at the line with no room, until a
|
|
|
182
183
|
const facts = { account: "a", usage: { ...atLine, measuredAt: 2_000 } };
|
|
183
184
|
const refused = refusal({ kind: "limit", account: "a" });
|
|
184
185
|
expect(refusalVerdict(refused, [facts])).toBe("standing");
|
|
185
|
-
expect(serviceStates([facts], refused).get("a")).toEqual({ kind: "spent", reopensAt: 900 });
|
|
186
|
+
expect(serviceStates([facts], [refused]).get("a")).toEqual({ kind: "spent", reopensAt: 900 });
|
|
186
187
|
});
|
|
187
188
|
|
|
188
189
|
test("with no model named, an account is spent only when no model can run on it", () => {
|
|
@@ -233,15 +234,15 @@ test("a revoked sign-in outranks a lost seat, which outranks a bench, whatever t
|
|
|
233
234
|
|
|
234
235
|
test("a standing refusal blocks by its kind, and a limit refusal pins only the pool its model spends", () => {
|
|
235
236
|
const facts = { account: "a", usage: CLAUDE };
|
|
236
|
-
expect(serviceState(facts, refusal({ kind: "auth" }))).toEqual({ kind: "blocked", fix: "reconnect", reason: "no." });
|
|
237
|
-
expect(serviceState(facts, refusal({ kind: "entitlement" }))).toEqual({ kind: "blocked", fix: "admin", reason: "no." });
|
|
237
|
+
expect(serviceState(facts, [refusal({ kind: "auth" })])).toEqual({ kind: "blocked", fix: "reconnect", reason: "no." });
|
|
238
|
+
expect(serviceState(facts, [refusal({ kind: "entitlement" })])).toEqual({ kind: "blocked", fix: "admin", reason: "no." });
|
|
238
239
|
// Refused on Haiku: the 5-hour/weekly binding pool (seven_day, 30%) reads full, so Haiku is spent too.
|
|
239
|
-
expect(serviceState(facts, refusal({ kind: "limit", model: "claude-haiku-4-5" }), { id: "claude-haiku-4-5" })).toEqual({ kind: "spent" });
|
|
240
|
+
expect(serviceState(facts, [refusal({ kind: "limit", model: "claude-haiku-4-5" })], { id: "claude-haiku-4-5" })).toEqual({ kind: "spent" });
|
|
240
241
|
// Refused on Opus: its own slice was already full; Haiku keeps the room its pools show.
|
|
241
|
-
expect(serviceState(facts, refusal({ kind: "limit", model: "claude-opus-4-6" }), { id: "claude-haiku-4-5" })).toEqual({ kind: "ready", room: 70 });
|
|
242
|
+
expect(serviceState(facts, [refusal({ kind: "limit", model: "claude-opus-4-6" })], { id: "claude-haiku-4-5" })).toEqual({ kind: "ready", room: 70 });
|
|
242
243
|
// No reading at all: the refusal is the evidence, unless it was about another model.
|
|
243
|
-
expect(serviceState({ account: "a" }, refusal({ kind: "limit" }))).toEqual({ kind: "spent" });
|
|
244
|
-
expect(serviceState({ account: "a" }, refusal({ kind: "limit", model: "opus" }), { id: "haiku" })).toEqual({ kind: "unknown" });
|
|
244
|
+
expect(serviceState({ account: "a" }, [refusal({ kind: "limit" })])).toEqual({ kind: "spent" });
|
|
245
|
+
expect(serviceState({ account: "a" }, [refusal({ kind: "limit", model: "opus" })], { id: "haiku" })).toEqual({ kind: "unknown" });
|
|
245
246
|
});
|
|
246
247
|
|
|
247
248
|
test("a refusal stands until a later reading contradicts it, and never past its account's disconnection", () => {
|
|
@@ -286,7 +287,7 @@ test("a nameless refusal over several accounts is answered by any of them readin
|
|
|
286
287
|
];
|
|
287
288
|
const codex = refusal({ kind: "limit", at: filed, model: "gpt-6.1-sol", resetsAt: Date.parse("2026-10-20T13:53:00Z") / 1000, unmetered: true });
|
|
288
289
|
expect(refusalVerdict(codex, accounts, now)).toBe("answered");
|
|
289
|
-
expect(Object.fromEntries(serviceStates(accounts, codex, undefined, now))).toEqual({
|
|
290
|
+
expect(Object.fromEntries(serviceStates(accounts, [codex], undefined, now))).toEqual({
|
|
290
291
|
"free-1": { kind: "ready", room: 100 },
|
|
291
292
|
"free-2": { kind: "ready", room: 100 },
|
|
292
293
|
team: { kind: "ready", room: 100 },
|
|
@@ -311,14 +312,92 @@ test("a spent allowance with the provider's own reset stands until that instant,
|
|
|
311
312
|
// Over several, the translator benches the one it refused and routes on, so another's room still answers it.
|
|
312
313
|
expect(refusalVerdict(refusal(until), [{ account: "a", usage: room }, { account: "b", usage: room }], 9_999)).toBe("answered");
|
|
313
314
|
// Spent until the instant the provider named, not the pool's own reset.
|
|
314
|
-
expect(serviceStates([{ account: "a", usage: room }], refusal({ ...until, account: "a" }), undefined, 9_999).get("a")).toEqual({ kind: "spent", reopensAt: 10 });
|
|
315
|
+
expect(serviceStates([{ account: "a", usage: room }], [refusal({ ...until, account: "a" })], undefined, 9_999).get("a")).toEqual({ kind: "spent", reopensAt: 10 });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// 2026-10-20: a spent allowance the provider gave no instant for stood past the reset of the pool it was refused in, the
|
|
319
|
+
// "back at" every surface showed, until the next reading landed (up to the daemon's 15-minute idle sweep).
|
|
320
|
+
test("a spent allowance with no instant named is stamped with its pool's reset, and ends there everywhere at once", () => {
|
|
321
|
+
const reset = 10;
|
|
322
|
+
const filling = at(500, window({ kind: "five_hour", utilization: 96, resetsAt: reset }), window({ kind: "seven_day", utilization: 40, resetsAt: 900 }));
|
|
323
|
+
const accounts = [{ account: "a", usage: filling }];
|
|
324
|
+
const spent = refusal({ kind: "limit", account: "a", model: "gpt-6.1-sol" });
|
|
325
|
+
expect(refusedPoolReset(spent, accounts)).toBe(reset);
|
|
326
|
+
const filed = { ...spent, poolResetsAt: reset };
|
|
327
|
+
expect(refusalVerdict(filed, accounts, reset * 1000 - 1)).toBe("standing");
|
|
328
|
+
expect(serviceStates(accounts, [filed], undefined, reset * 1000 - 1).get("a")).toEqual({ kind: "spent", reopensAt: reset });
|
|
329
|
+
expect(refusalVerdict(filed, accounts, reset * 1000)).toBe("answered");
|
|
330
|
+
// Even once the reading has lapsed that pool, so the weekly one binds: the stamp, not the reading, says when.
|
|
331
|
+
const lapsed = at(500, window({ kind: "five_hour", utilization: 0, lapsedAt: reset }), window({ kind: "seven_day", utilization: 40, resetsAt: 900 }));
|
|
332
|
+
expect(refusedPoolReset(spent, [{ account: "a", usage: lapsed }])).toBe(900);
|
|
333
|
+
expect(refusalVerdict(filed, [{ account: "a", usage: lapsed }], reset * 1000)).toBe("answered");
|
|
334
|
+
expect(serviceStates([{ account: "a", usage: lapsed }], [filed], undefined, reset * 1000).get("a")).toEqual({ kind: "ready", room: 60 });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 2026-10-21: Claude refused at 5h 98% and weekly 97% (rounded) with no time named, so the refusal was stamped with the
|
|
338
|
+
// five-hour reset, the fullest pool then. Once that window reopened the weekly pool read 100, and the passed stamp ended
|
|
339
|
+
// the refusal anyway: a rounded 100 is no proof of empty, so the account read ready with no room and every turn let
|
|
340
|
+
// through was refused again. A stamp ends the refusal where nothing read since says otherwise, never over a reading.
|
|
341
|
+
test("a stamped reset ends a refusal only where no reading since it says the pool is still at the line", () => {
|
|
342
|
+
const fiveHourReset = 1_800_000_000;
|
|
343
|
+
const weeklyReset = 1_800_259_200;
|
|
344
|
+
const refusedIn = at(
|
|
345
|
+
1_799_989_000_000,
|
|
346
|
+
window({ kind: "five_hour", utilization: 98, rounded: true, resetsAt: fiveHourReset }),
|
|
347
|
+
window({ kind: "seven_day", utilization: 97, rounded: true, resetsAt: weeklyReset }),
|
|
348
|
+
);
|
|
349
|
+
const spent = refusal({ kind: "limit", account: "a", at: 1_799_990_000_000 });
|
|
350
|
+
expect(refusedPoolReset(spent, [{ account: "a", usage: refusedIn }])).toBe(fiveHourReset);
|
|
351
|
+
const stamped = { ...spent, poolResetsAt: fiveHourReset };
|
|
352
|
+
const now = (fiveHourReset + 700) * 1000;
|
|
353
|
+
const weeklyFull = at(
|
|
354
|
+
(fiveHourReset + 600) * 1000,
|
|
355
|
+
window({ kind: "five_hour", utilization: 4, rounded: true, resetsAt: fiveHourReset + 18_000 }),
|
|
356
|
+
window({ kind: "seven_day", utilization: 100, rounded: true, resetsAt: weeklyReset }),
|
|
357
|
+
);
|
|
358
|
+
const unstampedVerdict = serviceStates([{ account: "a", usage: weeklyFull }], [spent], undefined, now).get("a");
|
|
359
|
+
expect(unstampedVerdict).toEqual({ kind: "spent", reopensAt: weeklyReset });
|
|
360
|
+
expect(serviceStates([{ account: "a", usage: weeklyFull }], [stamped], undefined, now).get("a")).toEqual(unstampedVerdict);
|
|
361
|
+
expect(fleetCapacity([{ account: "a", usage: weeklyFull }], [stamped], undefined, now)).toMatchObject({ kind: "spent", reopensAt: weeklyReset });
|
|
362
|
+
// Nothing read since the stamp: it ends there. Read with room since: over.
|
|
363
|
+
expect(serviceStates([{ account: "a", usage: refusedIn }], [stamped], undefined, now).get("a")).toEqual({ kind: "ready", room: 2 });
|
|
364
|
+
const roomSince = at((fiveHourReset + 600) * 1000, window({ kind: "five_hour", utilization: 4, resetsAt: fiveHourReset + 18_000 }), window({ kind: "seven_day", utilization: 60, resetsAt: weeklyReset }));
|
|
365
|
+
expect(refusalVerdict(stamped, [{ account: "a", usage: roomSince }], now)).toBe("answered");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("the stamp is the refused account's pool, or the soonest of any where the refusal named none", () => {
|
|
369
|
+
const soon = at(500, window({ kind: "five_hour", utilization: 96, resetsAt: 10 }));
|
|
370
|
+
const later = at(500, window({ kind: "five_hour", utilization: 96, resetsAt: 50 }));
|
|
371
|
+
const accounts = [{ account: "b", usage: later }, { account: "a", usage: soon }];
|
|
372
|
+
expect(refusedPoolReset(refusal({ kind: "limit" }), accounts)).toBe(10);
|
|
373
|
+
expect(refusedPoolReset(refusal({ kind: "limit", account: "b" }), accounts)).toBe(50);
|
|
374
|
+
// The pool the model spends, not the account's fullest.
|
|
375
|
+
const opus = at(500, window({ kind: "five_hour", utilization: 20, resetsAt: 10 }), window({ kind: "model:Opus", label: "Opus", utilization: 99, resetsAt: 70, gates: { models: ["Opus"] } }));
|
|
376
|
+
expect(refusedPoolReset(refusal({ kind: "limit", model: "claude-opus-4-6" }), [{ account: "a", usage: opus }])).toBe(70);
|
|
377
|
+
expect(refusedPoolReset(refusal({ kind: "limit", model: "claude-haiku-4-5" }), [{ account: "a", usage: opus }])).toBe(10);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("nothing is stamped where a pool's reset cannot speak for the refusal", () => {
|
|
381
|
+
const reopening = at(500, window({ kind: "five_hour", utilization: 96, resetsAt: 10 }));
|
|
382
|
+
// A reading older than the window it was refused in: that reset was over before the refusal came.
|
|
383
|
+
expect(refusedPoolReset(refusal({ kind: "limit", at: 20_000 }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
384
|
+
expect(refusedPoolReset(refusal({ kind: "limit" }), [{ account: "a", usage: at(500, window({ kind: "monthly", utilization: 96 })) }])).toBeUndefined();
|
|
385
|
+
expect(refusedPoolReset(refusal({ kind: "limit" }), [{ account: "a" }])).toBeUndefined();
|
|
386
|
+
expect(refusedPoolReset(refusal({ kind: "limit", account: "gone" }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
387
|
+
// The provider's own instant, an allowance no reading measures, a person's mark, and any refusal but a spent allowance.
|
|
388
|
+
expect(refusedPoolReset(refusal({ kind: "limit", resetsAt: 5 }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
389
|
+
expect(refusedPoolReset(refusal({ kind: "limit", unmetered: true }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
390
|
+
expect(refusedPoolReset(refusal({ kind: "limit", byHand: true }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
391
|
+
expect(refusedPoolReset(refusal({ kind: "entitlement" }), [{ account: "a", usage: reopening }])).toBeUndefined();
|
|
392
|
+
// And a refusal filed unstamped keeps standing until a reading answers it, as before.
|
|
393
|
+
expect(refusalVerdict(refusal({ kind: "limit", account: "a" }), [{ account: "a", usage: reopening }], 9e12)).toBe("standing");
|
|
315
394
|
});
|
|
316
395
|
|
|
317
396
|
test("a provider's accounts are judged together: a standing refusal lands only on the account it names", () => {
|
|
318
397
|
const room = at(500, window({ kind: "five_hour", utilization: 40 }));
|
|
319
|
-
const states = serviceStates([{ account: "a", usage: room }, { account: "b", usage: room }], refusal({ kind: "entitlement", account: "a" }));
|
|
398
|
+
const states = serviceStates([{ account: "a", usage: room }, { account: "b", usage: room }], [refusal({ kind: "entitlement", account: "a" })]);
|
|
320
399
|
expect(Object.fromEntries(states)).toEqual({ a: { kind: "blocked", fix: "admin", reason: "no." }, b: { kind: "ready", room: 60 } });
|
|
321
|
-
const answered = serviceStates([{ account: "a", usage: at(2_000, window({ kind: "five_hour", utilization: 40 })) }], refusal({ kind: "limit", account: "a" }));
|
|
400
|
+
const answered = serviceStates([{ account: "a", usage: at(2_000, window({ kind: "five_hour", utilization: 40 })) }], [refusal({ kind: "limit", account: "a" })]);
|
|
322
401
|
expect(answered.get("a")).toEqual({ kind: "ready", room: 60 });
|
|
323
402
|
});
|
|
324
403
|
|
|
@@ -339,7 +418,7 @@ test("each account answers to the newest refusal on file that still stands over
|
|
|
339
418
|
b: { kind: "blocked", fix: "reconnect", reason: "no." },
|
|
340
419
|
});
|
|
341
420
|
// Only the provider's last one: the first account is back at the line, no room but not proven spent.
|
|
342
|
-
expect(serviceStates(accounts, elsewhere, undefined, 9_999).get("a")).toEqual({ kind: "ready", room: 0 });
|
|
421
|
+
expect(serviceStates(accounts, [elsewhere], undefined, 9_999).get("a")).toEqual({ kind: "ready", room: 0 });
|
|
343
422
|
// Past the instant the provider named, its refusal is over and the reading speaks alone.
|
|
344
423
|
expect(serviceStates(accounts, [elsewhere, own], undefined, 20_000).get("a")).toEqual({ kind: "ready", room: 0 });
|
|
345
424
|
// A newer refusal on the same account outranks the older one.
|
|
@@ -421,19 +500,75 @@ test("a pool the provider stopped reporting is dropped once its window closes, n
|
|
|
421
500
|
test("a standing refusal pins the pool its model spends, reopening when the provider said, for every surface alike", () => {
|
|
422
501
|
const reading = at(2_000, window({ kind: "five_hour", utilization: 0, resetsAt: 20_000 }), window({ kind: "seven_day", utilization: 0, resetsAt: 600_000 }));
|
|
423
502
|
const said = refusal({ kind: "limit", model: "gpt-6.1-sol", resetsAt: 9_000 });
|
|
424
|
-
expect(
|
|
503
|
+
expect(judgedReading(reading, [said])?.windows.map((entry) => [entry.kind, entry.utilization, entry.resetsAt])).toEqual([
|
|
425
504
|
["five_hour", 100, 9_000],
|
|
426
505
|
["seven_day", 0, 600_000],
|
|
427
506
|
]);
|
|
428
|
-
expect(
|
|
507
|
+
expect(serviceState({ account: "a", usage: reading }, [said], undefined, 0)).toEqual({ kind: "spent", reopensAt: 9_000 });
|
|
429
508
|
// Where it named no instant, the pinned pool keeps its own reset, and that is when it reopens.
|
|
430
|
-
expect(
|
|
431
|
-
// No
|
|
432
|
-
expect(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
expect(serviceState({ account: "a"
|
|
509
|
+
expect(serviceState({ account: "a", usage: reading }, [{ ...said, resetsAt: undefined }], undefined, 0)).toEqual({ kind: "spent", reopensAt: 20_000 });
|
|
510
|
+
// No reading at all (a plan that publishes none): the refusal is a pool of its own, for its own model and no other's.
|
|
511
|
+
expect(judgedReading(undefined, [said])?.windows).toEqual([
|
|
512
|
+
{ kind: "refused:gpt-6.1-sol", label: "GPT 6.1 Sol", utilization: 100, gates: { models: ["gpt-6.1-sol"] }, resetsAt: 9_000 },
|
|
513
|
+
]);
|
|
514
|
+
expect(serviceState({ account: "a" }, [said], undefined, 0)).toEqual({ kind: "spent", reopensAt: 9_000, pool: "GPT 6.1 Sol" });
|
|
515
|
+
expect(serviceState({ account: "a" }, [said], { id: "gpt-6-luna" }, 0)).toEqual({ kind: "unknown" });
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// 2026-10-09/10-20: a routed model's bench (model-cooldowns.json) and the provider's last refusal were two copies of one
|
|
519
|
+
// refusal, and the picker greyed by one while the rail judged by the other. One ledger: every refusal standing over an
|
|
520
|
+
// account is laid on its reading, so two models refused on two pools both read spent.
|
|
521
|
+
test("every refusal standing over an account is laid on it, not only the provider's last", () => {
|
|
522
|
+
const reading = at(
|
|
523
|
+
500,
|
|
524
|
+
window({ kind: "five_hour", utilization: 10, resetsAt: 7_000 }),
|
|
525
|
+
window({ kind: "model:Opus", label: "Opus", utilization: 40, resetsAt: 8_000, gates: { models: ["opus"] } }),
|
|
526
|
+
);
|
|
527
|
+
const opus = refusal({ kind: "limit", account: "a", model: "claude-opus-5-5", at: 1_000 });
|
|
528
|
+
const sonnet = refusal({ kind: "limit", account: "a", model: "claude-sonnet-5", at: 2_000, resetsAt: 7_000 });
|
|
529
|
+
const states = (model: string) => serviceStates([{ account: "a", usage: reading }], [opus, sonnet], { id: model }, 5_000).get("a");
|
|
530
|
+
expect(states("claude-opus-5-5")).toEqual({ kind: "spent", reopensAt: 8_000, pool: "Opus" });
|
|
531
|
+
expect(states("claude-sonnet-5")).toEqual({ kind: "spent", reopensAt: 7_000 });
|
|
532
|
+
// A spent allowance refused after a refused sign-in says the sign-in worked: the newest word on the account decides.
|
|
533
|
+
const revoked = refusal({ kind: "auth", account: "a", at: 1_500 });
|
|
534
|
+
expect(serviceStates([{ account: "a", usage: reading }], [revoked, sonnet], { id: "claude-sonnet-5" }, 5_000).get("a")).toEqual({ kind: "spent", reopensAt: 7_000 });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// 2026-10-17: a Kimi credential benched for thirty minutes, its five-hour window two hours from reopening, was re-run at
|
|
538
|
+
// thirty minutes and refused again. A bench is a floor under the account's reopening, never the reopening itself.
|
|
539
|
+
test("a translator bench lifts only once the pools it holds have reopened too", () => {
|
|
540
|
+
const full = at(500, window({ kind: "five_hour", utilization: 100, resetsAt: 9_000 }));
|
|
541
|
+
expect(serviceState({ account: "a", usage: full, cooling: { until: 2_000, reason: "429" } }, [], undefined, 1_000_000)).toEqual({
|
|
542
|
+
kind: "blocked",
|
|
543
|
+
fix: "wait",
|
|
544
|
+
reason: "429",
|
|
545
|
+
until: 9_000,
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// The answer every gate, listing and picker reads (2026-10-13: Sol subagents spawned onto a Codex whose every account
|
|
550
|
+
// was refused, while the picker greyed the row; 2026-10-19: Auto read the readings alone).
|
|
551
|
+
test("a provider's capacity for a model is spent only when no account can serve it, and says when one is back", () => {
|
|
552
|
+
const room = at(5_000, window({ kind: "five_hour", utilization: 30, resetsAt: 9_000 }));
|
|
553
|
+
const accounts = [
|
|
554
|
+
{ account: "a", usage: room },
|
|
555
|
+
{ account: "b", usage: room },
|
|
556
|
+
];
|
|
557
|
+
const a = refusal({ kind: "limit", account: "a", model: "sol", resetsAt: 8_000, at: 4_000 });
|
|
558
|
+
const b = refusal({ kind: "limit", account: "b", model: "sol", resetsAt: 7_000, at: 4_000 });
|
|
559
|
+
expect(fleetCapacity(accounts, [a, b], { id: "sol" }, 6_000_000)).toEqual({ kind: "spent", accounts: 2, reopensAt: 7_000, pool: undefined });
|
|
560
|
+
expect(fleetCapacity(accounts, [a], { id: "sol" }, 6_000_000)).toEqual({ kind: "serving", serving: 1, accounts: 2, roomMeasuredAt: 5_000 });
|
|
561
|
+
// An account nothing has measured may still answer: it keeps the model servable.
|
|
562
|
+
expect(fleetCapacity([...accounts, { account: "c" }], [a, b], { id: "sol" }, 6_000_000)).toEqual({ kind: "serving", serving: 1, accounts: 3, roomMeasuredAt: undefined });
|
|
563
|
+
// No account at all says nothing either way.
|
|
564
|
+
expect(fleetCapacity([], [a], { id: "sol" })).toEqual({ kind: "unknown" });
|
|
565
|
+
// A plan that publishes no reading (Cursor's) is spent for a model every account refused, and only that model.
|
|
566
|
+
const cursor = [{ account: "x" }, { account: "y" }];
|
|
567
|
+
const refused = [refusal({ kind: "limit", account: "x", model: "composer-2.5" }), refusal({ kind: "limit", account: "y", model: "composer-2.5" })];
|
|
568
|
+
expect(fleetCapacity(cursor, refused, { id: "composer-2.5" }, 2_000).kind).toBe("spent");
|
|
569
|
+
expect(fleetCapacity(cursor, refused, { id: "gpt-6" }, 2_000).kind).toBe("serving");
|
|
570
|
+
// An account refused outright is blocked for every model.
|
|
571
|
+
expect(fleetCapacity([{ account: "x" }], [refusal({ kind: "entitlement", account: "x" })], { id: "gpt-6" }, 2_000).kind).toBe("spent");
|
|
437
572
|
});
|
|
438
573
|
|
|
439
574
|
test("an answer on one model covers another only where every pool the other spends gates it too", () => {
|