@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.
Files changed (66) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  9. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  10. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  11. package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
  12. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  13. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  14. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  15. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  16. package/dist/assets/deploy/secrets.manifest.json +12 -0
  17. package/dist/assets/package-lock.json +3 -3
  18. package/dist/assets/package.json +1 -1
  19. package/dist/assets/source.json +3 -3
  20. package/dist/assets/src/agents/registry.ts +52 -0
  21. package/dist/assets/src/core/budgets.ts +35 -2
  22. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  23. package/dist/assets/src/core/coordinator/driver.ts +59 -6
  24. package/dist/assets/src/core/costs.ts +39 -16
  25. package/dist/assets/src/core/pipelineStanding.ts +62 -0
  26. package/dist/assets/src/core/plane/decide.ts +389 -22
  27. package/dist/assets/src/core/refusal.ts +3 -0
  28. package/dist/assets/src/core/reviewVerdict.ts +64 -2
  29. package/dist/assets/src/core/runEvents.ts +31 -12
  30. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  31. package/dist/assets/src/core/runLedger/types.ts +3 -0
  32. package/dist/assets/src/core/runRecord.ts +24 -7
  33. package/dist/assets/src/core/ship/contract.ts +20 -30
  34. package/dist/assets/src/core/ship/coordinator.ts +519 -126
  35. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  36. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  37. package/dist/assets/web/dist/.vite/manifest.json +61 -60
  38. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  39. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  40. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
  41. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  42. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  43. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  44. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  45. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  46. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  47. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
  48. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  53. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  54. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
  55. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  56. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
  57. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  58. package/dist/cli.js +4400 -3079
  59. package/package.json +1 -1
  60. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  61. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  62. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  63. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
  64. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  65. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  66. 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 union grows
17
- * one member per stage as the later units land. */
18
- export type PlaneCondition = { kind: "thread_free"; threadKey: string; met: boolean };
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
- PlaneAskEvent | { kind: "sealed"; at: number; threadKey: string } | { kind: "withdraw"; at: number; runId: string };
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
- id: string;
69
- kind: "admit";
70
- runId: string;
71
- threadKey: string;
72
- request: Record<string, unknown>;
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 threadLive = state.liveThreads.includes(event.threadKey);
130
- if (!threadLive) return { state, effects: [], writes: [] };
131
- const waitingAhead = state.queue.filter((r) => r.state === "waiting" && r.threadKey === event.threadKey).length;
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: [{ kind: "thread_free", threadKey: event.threadKey, met: false }],
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
- if (liveThreads.length === state.liveThreads.length && !hasWaiting(state, event.threadKey))
153
- return { state, effects: [], writes: [] };
154
- return walk({ ...state, liveThreads }, event.at);
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
- liveThreads: [...next.liveThreads, row.threadKey],
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) => !state.liveThreads.includes(c.threadKey));
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",