@coreplane/switchboard 1.254.1 → 1.256.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/dist/assets/.dockerignore +3 -0
- package/dist/assets/Dockerfile +12 -1
- package/dist/assets/config/config.example.yaml +6 -1
- package/dist/assets/deploy/cloudflare/worker.ts +39 -23
- package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
- package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
- package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
- package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
- package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
- package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
- package/dist/assets/deploy/secrets.manifest.json +12 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +52 -0
- package/dist/assets/src/core/budgets.ts +35 -2
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +59 -6
- package/dist/assets/src/core/costs.ts +39 -16
- package/dist/assets/src/core/pipelineStanding.ts +62 -0
- package/dist/assets/src/core/plane/decide.ts +389 -22
- package/dist/assets/src/core/refusal.ts +3 -0
- package/dist/assets/src/core/reviewVerdict.ts +64 -2
- package/dist/assets/src/core/runEvents.ts +31 -12
- package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
- package/dist/assets/src/core/runLedger/types.ts +3 -0
- package/dist/assets/src/core/runRecord.ts +24 -7
- package/dist/assets/src/core/ship/contract.ts +20 -30
- package/dist/assets/src/core/ship/coordinator.ts +519 -126
- package/dist/assets/src/core/trace/workerTrace.ts +3 -0
- package/dist/assets/src/execution/sandboxErrors.ts +77 -6
- package/dist/assets/web/dist/.vite/manifest.json +61 -60
- package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
- package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
- package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
- package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
- package/dist/cli.js +4400 -3079
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
- package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
- package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
- package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
|
@@ -94,6 +94,9 @@ export const ROUND_STAGE = {
|
|
|
94
94
|
request_changes: "fix",
|
|
95
95
|
no_verdict: "hold",
|
|
96
96
|
checks_failed: "fix",
|
|
97
|
+
transient: "hold",
|
|
98
|
+
enqueued: "approved",
|
|
99
|
+
dequeued: "fix",
|
|
97
100
|
aborted: "hold",
|
|
98
101
|
stopped: "hold",
|
|
99
102
|
continued: "continue",
|
|
@@ -111,12 +114,14 @@ export const ENDING_STAGE = {
|
|
|
111
114
|
merged: "merged",
|
|
112
115
|
already_landed: "merged",
|
|
113
116
|
merge_ready: "merge-ready",
|
|
117
|
+
held: "ended",
|
|
114
118
|
merge_refused: "ended",
|
|
115
119
|
round_cap: "ended",
|
|
116
120
|
wall_clock_cap: "ended",
|
|
117
121
|
review_pending: "ended",
|
|
118
122
|
stopped: "ended",
|
|
119
123
|
aborted: "ended",
|
|
124
|
+
transient: "ended",
|
|
120
125
|
continued: "hold",
|
|
121
126
|
no_verdict: "ended",
|
|
122
127
|
interrupted: "ended",
|
|
@@ -125,6 +130,63 @@ export const ENDING_STAGE = {
|
|
|
125
130
|
blocked: "ended",
|
|
126
131
|
} as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
|
|
127
132
|
|
|
133
|
+
/** The user's words for a unit's ending (record 0066): what a card, a plan
|
|
134
|
+
* summary and the web unit page print instead of the internal token —
|
|
135
|
+
* `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
|
|
136
|
+
* never the snake_case kind. Pinned to the union plus `blocked` (the driver's
|
|
137
|
+
* never-started status), so a new ending kind fails the build here. */
|
|
138
|
+
export const ENDING_WORDS = {
|
|
139
|
+
merged: "merged",
|
|
140
|
+
already_landed: "merged",
|
|
141
|
+
merge_ready: "merge-ready",
|
|
142
|
+
held: "held",
|
|
143
|
+
merge_refused: "not merged",
|
|
144
|
+
round_cap: "round cap reached",
|
|
145
|
+
wall_clock_cap: "out of budget",
|
|
146
|
+
review_pending: "review pending",
|
|
147
|
+
stopped: "stopped",
|
|
148
|
+
aborted: "aborted",
|
|
149
|
+
transient: "aborted",
|
|
150
|
+
continued: "continuing",
|
|
151
|
+
no_verdict: "no verdict",
|
|
152
|
+
interrupted: "interrupted",
|
|
153
|
+
refused: "refused",
|
|
154
|
+
idle: "idle",
|
|
155
|
+
blocked: "blocked",
|
|
156
|
+
} as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
|
|
157
|
+
|
|
158
|
+
/** The user's words for a round outcome (record 0066): the card's unit line
|
|
159
|
+
* prints these beside the round header, never `checks_failed` or another
|
|
160
|
+
* internal token. Pinned to the union: a new outcome fails the build here. */
|
|
161
|
+
export const ROUND_OUTCOME_WORDS = {
|
|
162
|
+
started: "started",
|
|
163
|
+
pr_opened: "pull request opened",
|
|
164
|
+
completed: "done",
|
|
165
|
+
approve: "approved",
|
|
166
|
+
request_changes: "changes requested",
|
|
167
|
+
no_verdict: "no verdict",
|
|
168
|
+
checks_failed: "checks failed",
|
|
169
|
+
transient: "retried",
|
|
170
|
+
enqueued: "queued to merge",
|
|
171
|
+
dequeued: "removed from the merge queue",
|
|
172
|
+
aborted: "aborted",
|
|
173
|
+
stopped: "stopped",
|
|
174
|
+
continued: "continuing",
|
|
175
|
+
idle: "idle",
|
|
176
|
+
} as const satisfies Record<ShipRoundOutcome, string>;
|
|
177
|
+
|
|
178
|
+
/** The user's word for any ending or unit-status token — total over strings
|
|
179
|
+
* read off a stored row, so an unknown word prints as itself rather than
|
|
180
|
+
* throwing on an older record. */
|
|
181
|
+
export function endingWordOf(kind: string): string {
|
|
182
|
+
return (ENDING_WORDS as Record<string, string>)[kind] ?? kind;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The user's word for any round-outcome token, total like `endingWordOf`. */
|
|
186
|
+
export function roundOutcomeWordOf(outcome: string): string {
|
|
187
|
+
return (ROUND_OUTCOME_WORDS as Record<string, string>)[outcome] ?? outcome;
|
|
188
|
+
}
|
|
189
|
+
|
|
128
190
|
type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
|
|
129
191
|
|
|
130
192
|
function isShipEvent(e: RunEvent): e is ShipEvent {
|
|
@@ -13,9 +13,59 @@
|
|
|
13
13
|
export type PlaneStage = "admission" | "runner" | "resident";
|
|
14
14
|
|
|
15
15
|
/** A queue condition: what must become true before the row may run. Each is
|
|
16
|
-
* flipped by an event the plane already sees, never polled. The
|
|
17
|
-
*
|
|
18
|
-
|
|
16
|
+
* flipped by an event the plane already sees, never polled. The admission
|
|
17
|
+
* stage's full set (record 0064, "The queue"): `thread_free` — flipped by the
|
|
18
|
+
* seal or closing reclaim of the thread's run; `window_open` — flipped by the
|
|
19
|
+
* window's lift; `deploy_settled` — flipped by the deploy runner's
|
|
20
|
+
* `deploy.landed` post. The union grows one member per stage as the later
|
|
21
|
+
* units land. */
|
|
22
|
+
export type PlaneCondition =
|
|
23
|
+
| { kind: "thread_free"; threadKey: string; met: boolean }
|
|
24
|
+
| { kind: "window_open"; window: string; met: boolean }
|
|
25
|
+
| { kind: "deploy_settled"; met: boolean }
|
|
26
|
+
/** The resident stage's pair (record 0064, "The queue"; the resident unit):
|
|
27
|
+
* `seat` — the thread/op user pool has room; `memory` — the gate's soft
|
|
28
|
+
* side. Each is flipped by the resident's own level report, forwarded by
|
|
29
|
+
* the bot to `POST /plane/level`, never polled. */
|
|
30
|
+
| { kind: "seat"; resident: string; met: boolean }
|
|
31
|
+
| { kind: "memory"; resident: string; met: boolean };
|
|
32
|
+
|
|
33
|
+
/** One resident level as the plane stores it (`plane_levels`): the side of
|
|
34
|
+
* the line the resident last reported, stamped with the report time and the
|
|
35
|
+
* resident's generation. A resident with no row — or whose generation moved
|
|
36
|
+
* without a report — is `unknown` (`residentSideOf`), never assumed below. */
|
|
37
|
+
export interface PlaneLevelRow {
|
|
38
|
+
resident: string;
|
|
39
|
+
name: "seat" | "memory";
|
|
40
|
+
side: "below" | "above";
|
|
41
|
+
reportedAt: number;
|
|
42
|
+
generation: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The side a resident's level reads for an observer that knows the current
|
|
46
|
+
* generation: `unknown` for a resident that never reported or whose report
|
|
47
|
+
* predates the generation given (record 0064; the resident unit's record 0064). */
|
|
48
|
+
export function residentSideOf(
|
|
49
|
+
levels: PlaneLevelRow[],
|
|
50
|
+
resident: string,
|
|
51
|
+
name: "seat" | "memory",
|
|
52
|
+
generation?: string,
|
|
53
|
+
): "below" | "above" | "unknown" {
|
|
54
|
+
const row = levels.find((l) => l.resident === resident && l.name === name);
|
|
55
|
+
if (!row) return "unknown";
|
|
56
|
+
if (generation !== undefined && row.generation !== generation) return "unknown";
|
|
57
|
+
return row.side;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A reservation: an admitted ask's hold on its thread between the answer and
|
|
61
|
+
* the ledger claim that promotes it (the `plane_reservations` row). A second
|
|
62
|
+
* ask meanwhile sees the thread taken and queues. The seal deletes it. */
|
|
63
|
+
export interface PlaneReservation {
|
|
64
|
+
kind: "thread";
|
|
65
|
+
key: string;
|
|
66
|
+
runId: string;
|
|
67
|
+
at: number;
|
|
68
|
+
}
|
|
19
69
|
|
|
20
70
|
/** One queued ask: the `plane_queue` row. `position` counts the waiting rows
|
|
21
71
|
* ahead of it on the same conditions when it queued — the number a person is
|
|
@@ -39,12 +89,28 @@ export interface PlaneQueueRow {
|
|
|
39
89
|
export interface PlaneState {
|
|
40
90
|
queue: PlaneQueueRow[];
|
|
41
91
|
liveThreads: string[];
|
|
92
|
+
/** Threads an admitted ask holds before its claim lands (or after, until the seal). */
|
|
93
|
+
reservations: PlaneReservation[];
|
|
94
|
+
/** Open window kinds; `deploy` is the pending-deploy window (`deploy_settled` is its absence). */
|
|
95
|
+
openWindows: string[];
|
|
96
|
+
/** The residents' last level reports (`plane_levels`), one row per resident and name. */
|
|
97
|
+
levels: PlaneLevelRow[];
|
|
42
98
|
}
|
|
43
99
|
|
|
44
100
|
export function emptyPlaneState(): PlaneState {
|
|
45
|
-
return { queue: [], liveThreads: [] };
|
|
101
|
+
return { queue: [], liveThreads: [], reservations: [], openWindows: [], levels: [] };
|
|
46
102
|
}
|
|
47
103
|
|
|
104
|
+
/** The window kind behind `deploy_settled`: opened while a deploy is pending,
|
|
105
|
+
* lifted by the deploy runner's `deploy.landed` post (record 0064). */
|
|
106
|
+
export const DEPLOY_WINDOW = "deploy";
|
|
107
|
+
|
|
108
|
+
/** The window behind the resident fleet's drain (resident-repos item 69):
|
|
109
|
+
* opened by the registry's `set` post, lifted by its `cleared` or — from the
|
|
110
|
+
* registry's one alarm at `until` — `expired` post. A `restartOf` claim
|
|
111
|
+
* passes it like every window: the drain never refuses a run it waits for. */
|
|
112
|
+
export const RESIDENT_DRAIN_WINDOW = "resident-drain";
|
|
113
|
+
|
|
48
114
|
/** The closed event union. `ask`: may this run start now; `sealed`: a thread's
|
|
49
115
|
* live run ended (the ledger's seal, the event that flips `thread_free`);
|
|
50
116
|
* `withdraw`: the requester gave the wait up (`runs stop` on a queued id, the transport unit). */
|
|
@@ -56,28 +122,69 @@ export interface PlaneAskEvent {
|
|
|
56
122
|
threadKey: string;
|
|
57
123
|
stage: PlaneStage;
|
|
58
124
|
request: Record<string, unknown>;
|
|
125
|
+
/** The resident the ask is for (stage `resident` only): its seat and memory
|
|
126
|
+
* levels become the ask's conditions. */
|
|
127
|
+
resident?: string;
|
|
128
|
+
/** A restart of a run the resident already holds (record 0064): it
|
|
129
|
+
* passes the windows and the memory line — its worktree lives there or
|
|
130
|
+
* nowhere, and the drain never refuses a run it waits for. */
|
|
131
|
+
restartOf?: boolean;
|
|
59
132
|
}
|
|
60
133
|
export type PlaneEvent =
|
|
61
|
-
|
|
134
|
+
| PlaneAskEvent
|
|
135
|
+
| { kind: "sealed"; at: number; threadKey: string }
|
|
136
|
+
| { kind: "withdraw"; at: number; runId: string }
|
|
137
|
+
/** A window's open or lift (`window_open`); kind `deploy` is the pending deploy (`deploy_settled`). */
|
|
138
|
+
| { kind: "window"; at: number; window: string; phase: "opened" | "lifted" }
|
|
139
|
+
/** A resident's level report (record 0064): forwarded by the bot from the levels a
|
|
140
|
+
* resident answer carried, or posted from the registry's outbox. A `below`
|
|
141
|
+
* side walks the queue — the event that admits a waiting resident ask. */
|
|
142
|
+
| {
|
|
143
|
+
kind: "level";
|
|
144
|
+
at: number;
|
|
145
|
+
resident: string;
|
|
146
|
+
name: "seat" | "memory";
|
|
147
|
+
side: "below" | "above";
|
|
148
|
+
generation: string;
|
|
149
|
+
}
|
|
150
|
+
/** A refusal-by-name the bot met at attach or exec (record 0064): an admitted run
|
|
151
|
+
* that meets one re-enters the queue at its old position, waiting on the
|
|
152
|
+
* condition the refusal names, instead of falling cold. */
|
|
153
|
+
| { kind: "observation"; at: number; runId: string; resident: string; refusal: string }
|
|
154
|
+
/** The re-ask cadence (record 0064; `plane.reaskMinutes`): while a queued run
|
|
155
|
+
* waits on a resident that has said nothing within the cadence, one
|
|
156
|
+
* `probe(resident)` effect is emitted — a silent resident is probed, never
|
|
157
|
+
* waited on forever. */
|
|
158
|
+
| { kind: "reask"; at: number; cadenceMs: number };
|
|
62
159
|
|
|
63
160
|
/** The closed effect union: what the bot is asked to do, offered on its
|
|
64
161
|
* heartbeat and reclaim answers and acknowledged by id (`/plane/ack`). The
|
|
65
162
|
* id is derived from the run, so a duplicate offer after a roll is the same
|
|
66
163
|
* effect, acknowledged once. The transport unit adds execution; this one only shapes and stores. */
|
|
67
|
-
export type PlaneEffect =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
164
|
+
export type PlaneEffect =
|
|
165
|
+
| {
|
|
166
|
+
id: string;
|
|
167
|
+
kind: "admit";
|
|
168
|
+
runId: string;
|
|
169
|
+
threadKey: string;
|
|
170
|
+
request: Record<string, unknown>;
|
|
171
|
+
}
|
|
172
|
+
/** Ask the bot to probe the resident's `/status` and forward its levels
|
|
173
|
+
* (record 0064): the id is `probe:<resident>`, so the object holds at most one
|
|
174
|
+
* open probe per resident and a duplicate offer is the same effect. */
|
|
175
|
+
| { id: string; kind: "probe"; resident: string };
|
|
74
176
|
|
|
75
177
|
/** What the object must persist beside the returned state — the decider names
|
|
76
178
|
* the rows, the object owns the SQL, both inside one `transactionSync`. */
|
|
77
179
|
export type PlaneWrite =
|
|
180
|
+
| { table: "plane_levels"; op: "put"; row: PlaneLevelRow }
|
|
78
181
|
| { table: "plane_queue"; op: "put"; row: PlaneQueueRow }
|
|
79
182
|
| { table: "plane_queue"; op: "state"; runId: string; state: PlaneQueueRow["state"] }
|
|
80
|
-
| { table: "plane_effects"; op: "offer"; effect: PlaneEffect; at: number }
|
|
183
|
+
| { table: "plane_effects"; op: "offer"; effect: PlaneEffect; at: number }
|
|
184
|
+
| { table: "plane_reservations"; op: "put"; row: PlaneReservation }
|
|
185
|
+
| { table: "plane_reservations"; op: "del"; key: string }
|
|
186
|
+
| { table: "plane_windows"; op: "put"; window: string; at: number }
|
|
187
|
+
| { table: "plane_windows"; op: "del"; window: string };
|
|
81
188
|
|
|
82
189
|
/** The bot's own outcome for one dispatch, posted to `POST /plane/outcome`
|
|
83
190
|
* under `plane.admission: shadow` (orchestration-plane item 8): `proceeded`, `refused:<code>` or
|
|
@@ -95,6 +202,21 @@ export interface PlaneOutcomePost {
|
|
|
95
202
|
* `deferred` leaves it on the next heartbeat or reclaim answer. */
|
|
96
203
|
export type PlaneAckOutcome = "done" | "skipped" | "deferred";
|
|
97
204
|
|
|
205
|
+
/** The effect bounds (record 0064, "Where it lives"): a run holds at most this
|
|
206
|
+
* many open effects, the object at most the total — an offer past either is
|
|
207
|
+
* refused by the cap's name, never queued silently. */
|
|
208
|
+
export const PLANE_EFFECTS_PER_RUN_CAP = 4;
|
|
209
|
+
export const PLANE_EFFECTS_TOTAL_CAP = 256;
|
|
210
|
+
|
|
211
|
+
/** The named refusal an over-cap offer gets (the object counts, this judges). */
|
|
212
|
+
export function effectCapRefusal(counts: { total: number; forRun: number }, effect: PlaneEffect): string | undefined {
|
|
213
|
+
if (counts.total >= PLANE_EFFECTS_TOTAL_CAP)
|
|
214
|
+
return `plane_effects total cap (${PLANE_EFFECTS_TOTAL_CAP}): effect ${effect.id} refused`;
|
|
215
|
+
if (counts.forRun >= PLANE_EFFECTS_PER_RUN_CAP)
|
|
216
|
+
return `plane_effects per-run cap (${PLANE_EFFECTS_PER_RUN_CAP}): effect ${effect.id} refused`;
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
98
220
|
export interface PlaneDecision {
|
|
99
221
|
state: PlaneState;
|
|
100
222
|
effects: PlaneEffect[];
|
|
@@ -114,9 +236,31 @@ export function decide(state: PlaneState, event: PlaneEvent): PlaneDecision {
|
|
|
114
236
|
return onSealed(state, event);
|
|
115
237
|
case "withdraw":
|
|
116
238
|
return onWithdraw(state, event);
|
|
239
|
+
case "window":
|
|
240
|
+
return onWindow(state, event);
|
|
241
|
+
case "level":
|
|
242
|
+
return onLevel(state, event);
|
|
243
|
+
case "observation":
|
|
244
|
+
return onObservation(state, event);
|
|
245
|
+
case "reask":
|
|
246
|
+
return onReask(state, event);
|
|
117
247
|
}
|
|
118
248
|
}
|
|
119
249
|
|
|
250
|
+
/** The `/plane/admit` answer (record 0064, "The queue"): `admitted` with the
|
|
251
|
+
* reservation the decision wrote, or `queued` with the row's id, its position
|
|
252
|
+
* and the conditions it waits on. */
|
|
253
|
+
export type PlaneAskAnswer =
|
|
254
|
+
| { kind: "admitted"; reservation: string }
|
|
255
|
+
| { kind: "queued"; id: string; position: number; waiting: PlaneCondition[] };
|
|
256
|
+
|
|
257
|
+
export function planeAskAnswerOf(decision: PlaneDecision, runId: string): PlaneAskAnswer {
|
|
258
|
+
const row = decision.state.queue.find((r) => r.runId === runId);
|
|
259
|
+
return row && row.state === "waiting"
|
|
260
|
+
? { kind: "queued", id: runId, position: row.position, waiting: row.conditions }
|
|
261
|
+
: { kind: "admitted", reservation: runId };
|
|
262
|
+
}
|
|
263
|
+
|
|
120
264
|
/** The shadow word for an ask the decider just judged (orchestration-plane item 8): `queued` when the
|
|
121
265
|
* decision holds a waiting row for the run, `proceed` when it holds none —
|
|
122
266
|
* what the object logs beside the bot's own outcome. */
|
|
@@ -125,17 +269,105 @@ export function planeAskWordOf(decision: PlaneDecision, runId: string): "proceed
|
|
|
125
269
|
return row && row.state === "waiting" ? "queued" : "proceed";
|
|
126
270
|
}
|
|
127
271
|
|
|
272
|
+
/** The queue's waiting words (record 0064, "The queue"): what a person is
|
|
273
|
+
* told the row waits on — the queued reply in the thread and the queued id's
|
|
274
|
+
* page say the same thing, so the two surfaces cannot drift. */
|
|
275
|
+
export function waitingWords(waiting: PlaneCondition[]): string {
|
|
276
|
+
if (waiting.length === 0) return "its turn";
|
|
277
|
+
return waiting
|
|
278
|
+
.map((c) =>
|
|
279
|
+
c.kind === "thread_free"
|
|
280
|
+
? "the thread's live run"
|
|
281
|
+
: c.kind === "deploy_settled"
|
|
282
|
+
? "the pending deploy"
|
|
283
|
+
: c.kind === "seat"
|
|
284
|
+
? `a seat on ${c.resident}`
|
|
285
|
+
: c.kind === "memory"
|
|
286
|
+
? `memory on ${c.resident}`
|
|
287
|
+
: `the ${c.window} window`,
|
|
288
|
+
)
|
|
289
|
+
.join(", then ");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The unmet conditions an ask meets right now. The admission stage asks the
|
|
293
|
+
* thread and the windows; the resident stage asks the windows and the
|
|
294
|
+
* resident's own levels — its thread is already this run's (reserved at
|
|
295
|
+
* admission), so it is never a condition there. A `restartOf` ask passes the
|
|
296
|
+
* windows and the memory line (record 0064); an `unknown` level is not a wait — the
|
|
297
|
+
* bot falls cold for it (record 0064) — so only a reported `above` side queues. */
|
|
298
|
+
function unmetConditionsOf(state: PlaneState, event: PlaneAskEvent): PlaneCondition[] {
|
|
299
|
+
const out: PlaneCondition[] = [];
|
|
300
|
+
if (
|
|
301
|
+
event.stage === "admission" &&
|
|
302
|
+
(state.liveThreads.includes(event.threadKey) || state.reservations.some((r) => r.key === event.threadKey))
|
|
303
|
+
)
|
|
304
|
+
out.push({ kind: "thread_free", threadKey: event.threadKey, met: false });
|
|
305
|
+
if (!event.restartOf)
|
|
306
|
+
for (const w of state.openWindows)
|
|
307
|
+
out.push(
|
|
308
|
+
w === DEPLOY_WINDOW ? { kind: "deploy_settled", met: false } : { kind: "window_open", window: w, met: false },
|
|
309
|
+
);
|
|
310
|
+
if (event.stage === "resident" && event.resident !== undefined) {
|
|
311
|
+
if (residentSideOf(state.levels, event.resident, "seat") === "above")
|
|
312
|
+
out.push({ kind: "seat", resident: event.resident, met: false });
|
|
313
|
+
if (!event.restartOf && residentSideOf(state.levels, event.resident, "memory") === "above")
|
|
314
|
+
out.push({ kind: "memory", resident: event.resident, met: false });
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The condition a refusal-by-name waits on (record 0064): the pool's is the seat,
|
|
320
|
+
* the gate's the memory line, the drain's its window; a replaced or
|
|
321
|
+
* unreachable runtime waits on the resident's next seat report (the probe
|
|
322
|
+
* reaches it). An unrecognized refusal maps to nothing — the observation is
|
|
323
|
+
* a no-op and the bot's own fallback stands. */
|
|
324
|
+
export function conditionOfRefusal(refusal: string, resident: string): PlaneCondition | undefined {
|
|
325
|
+
if (refusal.startsWith("user-pool-exhausted")) return { kind: "seat", resident, met: false };
|
|
326
|
+
if (refusal.startsWith("memory-pressure")) return { kind: "memory", resident, met: false };
|
|
327
|
+
if (refusal.startsWith("draining")) return { kind: "window_open", window: RESIDENT_DRAIN_WINDOW, met: false };
|
|
328
|
+
if (refusal.startsWith("runtime-unreachable") || refusal.startsWith("runtime-replaced"))
|
|
329
|
+
return { kind: "seat", resident, met: false };
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Whether two conditions are the same wait: same kind, same subject. */
|
|
334
|
+
function sameCondition(a: PlaneCondition, b: PlaneCondition): boolean {
|
|
335
|
+
if (a.kind !== b.kind) return false;
|
|
336
|
+
if (a.kind === "thread_free" && b.kind === "thread_free") return a.threadKey === b.threadKey;
|
|
337
|
+
if (a.kind === "window_open" && b.kind === "window_open") return a.window === b.window;
|
|
338
|
+
if ((a.kind === "seat" && b.kind === "seat") || (a.kind === "memory" && b.kind === "memory"))
|
|
339
|
+
return a.resident === b.resident;
|
|
340
|
+
return true; // deploy_settled has one subject
|
|
341
|
+
}
|
|
342
|
+
|
|
128
343
|
function onAsk(state: PlaneState, event: PlaneAskEvent): PlaneDecision {
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
|
|
344
|
+
const conditions = unmetConditionsOf(state, event);
|
|
345
|
+
if (conditions.length === 0) {
|
|
346
|
+
// The resident stage's ask holds nothing new on admission: its thread was
|
|
347
|
+
// reserved at the admission stage under this same run, so a reservation
|
|
348
|
+
// here would only shadow it.
|
|
349
|
+
if (event.stage === "resident") return { state, effects: [], writes: [] };
|
|
350
|
+
// Admitted: the thread is reserved in the same transaction (record 0064,
|
|
351
|
+
// "The queue") so a second ask a moment later queues; the ledger claim
|
|
352
|
+
// promotes the reservation and the seal deletes it.
|
|
353
|
+
const reservation: PlaneReservation = { kind: "thread", key: event.threadKey, runId: event.runId, at: event.at };
|
|
354
|
+
return {
|
|
355
|
+
state: { ...state, reservations: [...state.reservations, reservation] },
|
|
356
|
+
effects: [],
|
|
357
|
+
writes: [{ table: "plane_reservations", op: "put", row: reservation }],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// Position (record 0064): the rank among queued runs sharing an unmet condition.
|
|
361
|
+
const waitingAhead = state.queue.filter(
|
|
362
|
+
(r) => r.state === "waiting" && r.conditions.some((c) => conditions.some((n) => sameCondition(c, n))),
|
|
363
|
+
).length;
|
|
132
364
|
const row: PlaneQueueRow = {
|
|
133
365
|
runId: event.runId,
|
|
134
366
|
requester: event.requester,
|
|
135
367
|
threadKey: event.threadKey,
|
|
136
368
|
stage: event.stage,
|
|
137
369
|
request: event.request,
|
|
138
|
-
conditions
|
|
370
|
+
conditions,
|
|
139
371
|
position: waitingAhead + 1,
|
|
140
372
|
queuedAt: event.at,
|
|
141
373
|
state: "waiting",
|
|
@@ -149,9 +381,124 @@ function onAsk(state: PlaneState, event: PlaneAskEvent): PlaneDecision {
|
|
|
149
381
|
|
|
150
382
|
function onSealed(state: PlaneState, event: { kind: "sealed"; at: number; threadKey: string }): PlaneDecision {
|
|
151
383
|
const liveThreads = state.liveThreads.filter((t) => t !== event.threadKey);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
384
|
+
const reservations = state.reservations.filter((r) => r.key !== event.threadKey);
|
|
385
|
+
const freed = liveThreads.length !== state.liveThreads.length || reservations.length !== state.reservations.length;
|
|
386
|
+
if (!freed && !hasWaiting(state, event.threadKey)) return { state, effects: [], writes: [] };
|
|
387
|
+
const writes: PlaneWrite[] =
|
|
388
|
+
reservations.length !== state.reservations.length
|
|
389
|
+
? [{ table: "plane_reservations", op: "del", key: event.threadKey }]
|
|
390
|
+
: [];
|
|
391
|
+
const walked = walk({ ...state, liveThreads, reservations }, event.at);
|
|
392
|
+
return { ...walked, writes: [...writes, ...walked.writes] };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function onWindow(
|
|
396
|
+
state: PlaneState,
|
|
397
|
+
event: { kind: "window"; at: number; window: string; phase: string },
|
|
398
|
+
): PlaneDecision {
|
|
399
|
+
if (event.phase === "opened") {
|
|
400
|
+
if (state.openWindows.includes(event.window)) return { state, effects: [], writes: [] };
|
|
401
|
+
return {
|
|
402
|
+
state: { ...state, openWindows: [...state.openWindows, event.window] },
|
|
403
|
+
effects: [],
|
|
404
|
+
writes: [{ table: "plane_windows", op: "put", window: event.window, at: event.at }],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (!state.openWindows.includes(event.window)) return { state, effects: [], writes: [] };
|
|
408
|
+
const next = { ...state, openWindows: state.openWindows.filter((w) => w !== event.window) };
|
|
409
|
+
const walked = walk(next, event.at);
|
|
410
|
+
return { ...walked, writes: [{ table: "plane_windows", op: "del", window: event.window }, ...walked.writes] };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A level report (record 0064): the row is upserted, and a `below` side walks the
|
|
414
|
+
* queue — the admitting event for every resident condition. An unchanged
|
|
415
|
+
* side is still written (the report time and generation move), but only a
|
|
416
|
+
* crossing to `below` can admit, and the walk judges that. */
|
|
417
|
+
function onLevel(
|
|
418
|
+
state: PlaneState,
|
|
419
|
+
event: {
|
|
420
|
+
kind: "level";
|
|
421
|
+
at: number;
|
|
422
|
+
resident: string;
|
|
423
|
+
name: "seat" | "memory";
|
|
424
|
+
side: "below" | "above";
|
|
425
|
+
generation: string;
|
|
426
|
+
},
|
|
427
|
+
): PlaneDecision {
|
|
428
|
+
const row: PlaneLevelRow = {
|
|
429
|
+
resident: event.resident,
|
|
430
|
+
name: event.name,
|
|
431
|
+
side: event.side,
|
|
432
|
+
reportedAt: event.at,
|
|
433
|
+
generation: event.generation,
|
|
434
|
+
};
|
|
435
|
+
const levels = [...state.levels.filter((l) => !(l.resident === event.resident && l.name === event.name)), row];
|
|
436
|
+
const next = { ...state, levels };
|
|
437
|
+
const write: PlaneWrite = { table: "plane_levels", op: "put", row };
|
|
438
|
+
if (event.side === "above") return { state: next, effects: [], writes: [write] };
|
|
439
|
+
const walked = walk(next, event.at);
|
|
440
|
+
return { ...walked, writes: [write, ...walked.writes] };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** A refusal-by-name met by an admitted run (record 0064): its queue row re-enters
|
|
444
|
+
* `waiting` at its old position on the refusal's condition, and a seat or
|
|
445
|
+
* memory refusal is itself evidence of the side — the level row is written
|
|
446
|
+
* `above` so the walk does not re-admit the run into the same refusal. A run
|
|
447
|
+
* the queue never held, or a refusal with no condition, is a no-op. */
|
|
448
|
+
function onObservation(
|
|
449
|
+
state: PlaneState,
|
|
450
|
+
event: { kind: "observation"; at: number; runId: string; resident: string; refusal: string },
|
|
451
|
+
): PlaneDecision {
|
|
452
|
+
const row = state.queue.find((r) => r.runId === event.runId && r.state === "admitted");
|
|
453
|
+
const condition = conditionOfRefusal(event.refusal, event.resident);
|
|
454
|
+
if (!row || !condition) return { state, effects: [], writes: [] };
|
|
455
|
+
const reentered: PlaneQueueRow = { ...row, state: "waiting", conditions: [condition] };
|
|
456
|
+
const writes: PlaneWrite[] = [{ table: "plane_queue", op: "put", row: reentered }];
|
|
457
|
+
let levels = state.levels;
|
|
458
|
+
if (condition.kind === "seat" || condition.kind === "memory") {
|
|
459
|
+
// The refusal names no generation, so the row keeps the resident's last
|
|
460
|
+
// known one (any name) — a refusal is evidence about the resident as it
|
|
461
|
+
// reports now, not about an older build.
|
|
462
|
+
const prior =
|
|
463
|
+
state.levels.find((l) => l.resident === event.resident && l.name === condition.kind) ??
|
|
464
|
+
state.levels.find((l) => l.resident === event.resident);
|
|
465
|
+
const level: PlaneLevelRow = {
|
|
466
|
+
resident: event.resident,
|
|
467
|
+
name: condition.kind,
|
|
468
|
+
side: "above",
|
|
469
|
+
reportedAt: event.at,
|
|
470
|
+
generation: prior?.generation ?? "",
|
|
471
|
+
};
|
|
472
|
+
levels = [...state.levels.filter((l) => !(l.resident === event.resident && l.name === condition.kind)), level];
|
|
473
|
+
writes.push({ table: "plane_levels", op: "put", row: level });
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
state: { ...state, queue: state.queue.map((r) => (r === row ? reentered : r)), levels },
|
|
477
|
+
effects: [],
|
|
478
|
+
writes,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The re-ask cadence (record 0064): for every resident a waiting row waits on
|
|
483
|
+
* whose last report is older than the cadence (or that never reported), one
|
|
484
|
+
* `probe` effect — id `probe:<resident>`, so the object holds at most one
|
|
485
|
+
* open probe per resident. Nothing else moves: the probe's answer arrives as
|
|
486
|
+
* a level event and that walks the queue. */
|
|
487
|
+
function onReask(state: PlaneState, event: { kind: "reask"; at: number; cadenceMs: number }): PlaneDecision {
|
|
488
|
+
const waitedOn = new Set<string>();
|
|
489
|
+
for (const r of state.queue)
|
|
490
|
+
if (r.state === "waiting")
|
|
491
|
+
for (const c of r.conditions) if (c.kind === "seat" || c.kind === "memory") waitedOn.add(c.resident);
|
|
492
|
+
const effects: PlaneEffect[] = [];
|
|
493
|
+
const writes: PlaneWrite[] = [];
|
|
494
|
+
for (const resident of [...waitedOn].sort()) {
|
|
495
|
+
const latest = Math.max(0, ...state.levels.filter((l) => l.resident === resident).map((l) => l.reportedAt));
|
|
496
|
+
if (latest > event.at - event.cadenceMs) continue;
|
|
497
|
+
const effect: PlaneEffect = { id: `probe:${resident}`, kind: "probe", resident };
|
|
498
|
+
effects.push(effect);
|
|
499
|
+
writes.push({ table: "plane_effects", op: "offer", effect, at: event.at });
|
|
500
|
+
}
|
|
501
|
+
return { state, effects, writes };
|
|
155
502
|
}
|
|
156
503
|
|
|
157
504
|
function onWithdraw(state: PlaneState, event: { kind: "withdraw"; at: number; runId: string }): PlaneDecision {
|
|
@@ -187,17 +534,37 @@ function walk(state: PlaneState, at: number): PlaneDecision {
|
|
|
187
534
|
threadKey: row.threadKey,
|
|
188
535
|
request: row.request,
|
|
189
536
|
};
|
|
537
|
+
// The admitted run reserves its thread like a fresh admission does, so the
|
|
538
|
+
// ledger claim under its id promotes the same row and a rival ask queues.
|
|
539
|
+
const reservation: PlaneReservation = { kind: "thread", key: row.threadKey, runId: row.runId, at };
|
|
190
540
|
next = {
|
|
541
|
+
...next,
|
|
191
542
|
queue: next.queue.map((r) => (r === row ? admitted : r)),
|
|
192
|
-
|
|
543
|
+
reservations: [...next.reservations, reservation],
|
|
193
544
|
};
|
|
194
545
|
effects.push(effect);
|
|
195
546
|
writes.push({ table: "plane_queue", op: "state", runId: row.runId, state: "admitted" });
|
|
196
547
|
writes.push({ table: "plane_effects", op: "offer", effect, at });
|
|
548
|
+
writes.push({ table: "plane_reservations", op: "put", row: reservation });
|
|
197
549
|
}
|
|
198
550
|
return { state: next, effects, writes };
|
|
199
551
|
}
|
|
200
552
|
|
|
201
553
|
function conditionsMet(state: PlaneState, row: PlaneQueueRow): boolean {
|
|
202
|
-
return row.conditions.every((c) =>
|
|
554
|
+
return row.conditions.every((c) => {
|
|
555
|
+
switch (c.kind) {
|
|
556
|
+
case "thread_free":
|
|
557
|
+
return !state.liveThreads.includes(c.threadKey) && !state.reservations.some((r) => r.key === c.threadKey);
|
|
558
|
+
case "window_open":
|
|
559
|
+
return !state.openWindows.includes(c.window);
|
|
560
|
+
case "deploy_settled":
|
|
561
|
+
return !state.openWindows.includes(DEPLOY_WINDOW);
|
|
562
|
+
// A resident condition is met only by a reported `below` side: an
|
|
563
|
+
// `unknown` resident admits nothing — the probe reaches it first (record 0064).
|
|
564
|
+
case "seat":
|
|
565
|
+
return residentSideOf(state.levels, c.resident, "seat") === "below";
|
|
566
|
+
case "memory":
|
|
567
|
+
return residentSideOf(state.levels, c.resident, "memory") === "below";
|
|
568
|
+
}
|
|
569
|
+
});
|
|
203
570
|
}
|
|
@@ -51,6 +51,9 @@ const CAUSE_OF = {
|
|
|
51
51
|
coordinator_thread_live: "system",
|
|
52
52
|
live_agent_allowlist: "policy",
|
|
53
53
|
follow_up_refused: "request",
|
|
54
|
+
// the seed thread of a live pipeline runner (record 0051's owner rule):
|
|
55
|
+
// a reply there is refused naming the unit thread, never run beside it
|
|
56
|
+
pipeline_thread_owned: "request",
|
|
54
57
|
elsewhere_agent_allowlist: "policy",
|
|
55
58
|
elsewhere_follow_up_refused: "request",
|
|
56
59
|
which_branch: "request",
|