@coreplane/switchboard 1.259.0 → 1.260.1

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 (56) hide show
  1. package/dist/assets/config/config.example.yaml +17 -24
  2. package/dist/assets/deploy/cloudflare/worker.ts +17 -56
  3. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  7. package/dist/assets/deploy/profile.example.json +2 -1
  8. package/dist/assets/package-lock.json +3 -3
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +3 -5
  12. package/dist/assets/src/core/authz/policy.ts +5 -0
  13. package/dist/assets/src/core/budgets.ts +19 -6
  14. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  15. package/dist/assets/src/core/coordinator/driver.ts +395 -128
  16. package/dist/assets/src/core/memory/types.ts +5 -0
  17. package/dist/assets/src/core/pipelineStanding.ts +13 -4
  18. package/dist/assets/src/core/plane/decide.ts +53 -12
  19. package/dist/assets/src/core/provider.ts +5 -0
  20. package/dist/assets/src/core/runEvents.ts +22 -15
  21. package/dist/assets/src/core/runLedger/types.ts +3 -3
  22. package/dist/assets/src/core/runRecord.ts +64 -11
  23. package/dist/assets/src/core/ship/coordinator.ts +202 -53
  24. package/dist/assets/src/core/ship/renewal.ts +23 -8
  25. package/dist/assets/src/core/types.ts +11 -0
  26. package/dist/assets/src/deploy/liveGate.ts +8 -0
  27. package/dist/assets/src/deploy/profile.ts +8 -0
  28. package/dist/assets/src/deploy/restart.ts +49 -35
  29. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  30. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
  31. package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  33. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
  38. package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
  42. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
  44. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  45. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
  47. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
  48. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
  49. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  50. package/dist/cli.js +9136 -8197
  51. package/package.json +1 -1
  52. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  54. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
  55. package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
  56. package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
@@ -97,7 +97,8 @@ defaults:
97
97
  # thinks per turn; lower = much faster turns). Same layering as models —
98
98
  # per-channel/user `effort` / `efforts.<agent>` and a per-request `effort:`
99
99
  # directive override these; unset → the agent's built-in effort, else the
100
- # provider default. Skipped for models without effort support.
100
+ # provider default. Skipped for models without effort support. The `general`
101
+ # entry also controls the operator turn on `defaults.models.general`.
101
102
  # efforts:
102
103
  # coding: medium
103
104
  # How much the bot says about its own doing (docs/reference/specs/routing-and-config.md
@@ -171,6 +172,7 @@ defaults:
171
172
  # "config set channel ..." are stored in data/overrides.json and win over these.
172
173
  # channels:
173
174
  # slack:C012345:
175
+ # repo: acme/api # default repository for repo-bound asks in this channel
174
176
  # agent: review
175
177
  # models:
176
178
  # review: anthropic/claude-opus-5
@@ -368,7 +370,10 @@ workspaceDir: ./workspaces
368
370
  # # records (soft delete, status `evicted`) in the same write.
369
371
  # model: anthropic/claude-haiku-4-5
370
372
  # # <provider>/<model> for the reflection pass — pick a
371
- # # cheap tier. Absent → the run's own resolved model.
373
+ # # cheap level. Absent → the run's own resolved model.
374
+ # effort: low # how hard the reflection model thinks per distillation —
375
+ # # low | medium | high | xhigh | max, applied through the
376
+ # # model card; unset → the model's own default.
372
377
 
373
378
  # Self-improvement proposals (docs/reference/specs/run-friction.md). Every finished run's friction
374
379
  # diagnosis (docs/reference/specs/run-friction.md) is stored with its run record, so the
@@ -479,26 +484,11 @@ workspaceDir: ./workspaces
479
484
  # spawn:
480
485
  # maxChildren: 3
481
486
 
482
- # The request router (docs/reference/specs/routing-and-config.md item 21). ON BY
483
- # DEFAULT: a plain message — no `agent:` directive, no sticky preset, no channel
484
- # or user `agent` — asks the fast model which preset it means and runs as that
485
- # preset, the card saying why (`routed: <reason>`) and how to run it another way.
486
- # `ship` is never routed. The one way off, so every plain message runs
487
- # `defaults.agent` as before the router: `auto: false`. `model` is the router's
488
- # model; default `defaults.models.general`. `answer` is how that model answers:
489
- # `tool` (default) forces a call to the router's `route` tool, whose schema is
490
- # the answer, so prose cannot occur; `text` is the one-JSON-object text contract
491
- # alone — set it for a provider or model that cannot take a forced tool call.
492
- # `operator` is the one door's flag (routing-and-config item 29): `on`
493
- # (default) — the operator's decision is what runs; `shadow` — one operator
494
- # turn per admitted chat event, ahead of stage A, its decision written beside
495
- # the routed request in the run store and nothing run from it; `off` — the
496
- # rollback lever: it turns the operator back off after a bad day, not a choice
497
- # to make up front. `shadow` and `on` run even under `auto: false`.
487
+ # The one door's mode (routing-and-config item 29): `on` (default) — the
488
+ # operator's typed decision is what runs; `shadow` — one operator turn per
489
+ # admitted chat event, its decision recorded and nothing run from it; `off` —
490
+ # the rollback lever. The retired readers' router has no model or effort key.
498
491
  # routing:
499
- # auto: false
500
- # model: anthropic/claude-haiku-4-5
501
- # answer: text
502
492
  # operator: off
503
493
 
504
494
  # The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
@@ -512,14 +502,17 @@ workspaceDir: ./workspaces
512
502
  # model call) or `always` (today's answer-every-reply path, byte for byte).
513
503
  # Also settable per channel and per user: `intake: { threadReplies: <mode> }`
514
504
  # on a `channels.<id>` or `users.<id>` scope, user over channel over here.
515
- # `model` is the verdict's model; default `routing.model`, else
516
- # `defaults.models.general`. A mention, a DM and a top-level post never pass
517
- # through the gate in any mode. A model whose operator card (`providers.<p>.
505
+ # `model` is the verdict's model; default `defaults.models.general`. `effort`
506
+ # is how hard that model thinks per verdict — low | medium | high | xhigh |
507
+ # max, applied through the model card; unset → the model's own default. A
508
+ # mention, a DM and a top-level post never pass through the gate in any mode.
509
+ # A model whose operator card (`providers.<p>.
518
510
  # models.<id>.answers`) declares it answers neither a forced tool call nor the
519
511
  # text contract is refused at load when the default mode is `classify`.
520
512
  # intake:
521
513
  # threadReplies: classify
522
514
  # model: anthropic/claude-haiku-4-5
515
+ # effort: low
523
516
 
524
517
  # The orchestration plane (docs/decisions/0064-the-plane-owns-every-runs-state-a-refusal-becomes-a-queue-position-an-ending-is-judged-by-the-ledger-that-saw-it-and-a-release-is-a-quiet-window-a-person-closes.md;
525
518
  # docs/reference/specs/routing-and-config.md item 31; orchestration-plane.md).
@@ -28,14 +28,10 @@ import {
28
28
  import {
29
29
  authenticateIngressBearer,
30
30
  authenticateRestart,
31
+ authorizeRestartDeployer,
31
32
  decideRestart,
32
- parseRestartAuthorization,
33
33
  parseRestartRequest,
34
- RESTART_AUTHORIZE_PATH,
35
- RESTART_SUBJECT_HEADER,
36
34
  restartResponse,
37
- stripRestartSubject,
38
- type RestartAuth,
39
35
  type RestartOutcome,
40
36
  } from "../../src/deploy/restart.ts";
41
37
  import {
@@ -89,7 +85,8 @@ export interface Env {
89
85
  ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
90
86
  ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
91
87
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
92
- SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present; an entry whose `http:<subject>` actor holds `deploy:write` in the bot's config may POST /admin/restart
88
+ SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
89
+ SWITCHBOARD_RESTART_DEPLOYER?: string; // deployment-profile grant: the token-map subject allowed to POST /admin/restart; Worker-only, never runtime config
93
90
  BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
94
91
  GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
95
92
  CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
@@ -211,37 +208,6 @@ export class SwitchboardServer extends Container<Env> {
211
208
  return this.restartRunning(opts);
212
209
  }
213
210
 
214
- /**
215
- * `deploy restart`, authorized: the Worker knows WHO the bearer is (the token
216
- * map); WHETHER that identity may restart is the bot's config (`grants` —
217
- * authorization.md item 9), which only the container holds. So ask it —
218
- * `POST /admin/restart/authorize` with the authenticated subject in
219
- * `RESTART_SUBJECT_HEADER` (never the bearer: the container may still hold the
220
- * token map from before a rotation) — and stop only on a 200. A container that
221
- * is not running is started first (the bot must answer): if the bearer is
222
- * allowed, that start already put the current env live, so nothing is
223
- * stopped and the outcome is `not-running`, exactly as before; if not, the
224
- * refusal is relayed and the started container simply keeps serving.
225
- */
226
- async restartAuthorized(
227
- subject: string,
228
- opts: { force: boolean },
229
- ): Promise<{ auth: RestartAuth; outcome?: RestartOutcome }> {
230
- const wasRunning = this.ctx.container?.running === true;
231
- await this.startBot();
232
- const answer = await this.containerFetch(
233
- new Request(`${INTERNAL}${RESTART_AUTHORIZE_PATH}`, {
234
- method: "POST",
235
- headers: { [RESTART_SUBJECT_HEADER]: subject },
236
- }),
237
- this.defaultPort,
238
- );
239
- const auth = parseRestartAuthorization(answer.status, await answer.text().catch(() => ""));
240
- if (!auth.ok) return { auth };
241
- if (!wasRunning) return { auth, outcome: { kind: "not-running" } };
242
- return { auth, outcome: await this.restartRunning(opts) };
243
- }
244
-
245
211
  /** The stop itself, for a running container: the preflight's refusal rules over `/healthz`, then SIGTERM. */
246
212
  private async restartRunning(opts: { force: boolean }): Promise<RestartOutcome> {
247
213
  const health = await this.containerFetch(new Request(`${INTERNAL}/healthz`), this.defaultPort);
@@ -265,12 +231,12 @@ export class SwitchboardServer extends Container<Env> {
265
231
  }
266
232
  }
267
233
 
268
- /** `POST /admin/restart` — the operator surface behind `deploy restart`
269
- * (src/deploy/restart.ts documents the authorization choice: a
270
- * SWITCHBOARD_INGRESS_TOKENS bearer whose `http:<subject>` actor holds
271
- * `deploy:write` in the bot's config). The Worker authenticates the bearer
272
- * against the map it holds — an unknown bearer never touches the container —
273
- * and the Container DO asks the bot for the grant before stopping anything.
234
+ /** `POST /admin/restart` — the operator surface behind `deploy restart`.
235
+ * The Worker authenticates the bearer against its token map, then authorizes
236
+ * that subject against SWITCHBOARD_RESTART_DEPLOYER, rendered from the
237
+ * deployment profile (or set directly as a Worker var). Runtime config is
238
+ * never consulted: this route must remain usable when that document is what
239
+ * the restart is repairing.
274
240
  * Body `{ "force": true }` bypasses the fail-closed refusals (no JSON body,
275
241
  * impossible `inFlight`); runs in flight or a drain warn and never refuse. */
276
242
  async function handleAdminRestart(request: Request, env: Env): Promise<Response> {
@@ -283,14 +249,16 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
283
249
  console.warn(`[restart] ${authn.status} — ${authn.reason}`);
284
250
  return json(authn.status, { ok: false, error: authn.reason });
285
251
  }
252
+ const auth = authorizeRestartDeployer(authn.identity.subject, env.SWITCHBOARD_RESTART_DEPLOYER);
253
+ if (!auth.ok) {
254
+ console.warn(`[restart] ${auth.status} — ${auth.reason}`);
255
+ return json(auth.status, { ok: false, error: auth.reason });
256
+ }
286
257
  const parsed = parseRestartRequest(await request.text().catch(() => ""));
287
258
  if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
288
- let auth: RestartAuth;
289
- let outcome: RestartOutcome | undefined;
259
+ let outcome: RestartOutcome;
290
260
  try {
291
- ({ auth, outcome } = await getContainer(env.SWITCHBOARD, INSTANCE).restartAuthorized(authn.identity.subject, {
292
- force: parsed.force,
293
- }));
261
+ outcome = await getContainer(env.SWITCHBOARD, INSTANCE).restart({ force: parsed.force });
294
262
  } catch (err) {
295
263
  // The container's /healthz probe or the DO call threw (container mid-transition,
296
264
  // port not answering): fail closed in the route's own JSON shape so the CLI reads
@@ -299,11 +267,6 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
299
267
  console.error(`[restart] ${authn.identity.subject} → error before stop: ${reason}`);
300
268
  return json(500, { ok: false, error: `restart failed before stopping anything: ${reason}` });
301
269
  }
302
- if (!auth.ok || outcome === undefined) {
303
- const refusal = auth.ok ? { status: 503 as const, reason: "restart disabled: no outcome" } : auth;
304
- console.warn(`[restart] ${refusal.status} — ${refusal.reason}`);
305
- return json(refusal.status, { ok: false, error: refusal.reason });
306
- }
307
270
  console.log(
308
271
  `[restart] ${auth.subject} → ${outcome.kind}${outcome.kind === "refused" ? `: ${outcome.problems.join("; ")}` : ""}`,
309
272
  );
@@ -494,9 +457,7 @@ export default {
494
457
  // caller sent is stripped, and what the container sees carries this
495
458
  // Worker's own root. A static asset or the live view's SSE stream gets no
496
459
  // root; a refusal's line is dropped by the sink's filter.
497
- // The restart-subject header is the Worker's own word to the container (the
498
- // authorize call below); a caller cannot be allowed to speak it.
499
- const inbound = stripRestartSubject(stripTraceContext(request));
460
+ const inbound = stripTraceContext(request);
500
461
  const route = shimRoute(pathname);
501
462
  if (route === undefined) return withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(inbound));
502
463
  const root = tracer.start("bot-shim.fetch", { sinks: traceSinks, attrs: { route } });
@@ -31,6 +31,12 @@
31
31
  "ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
32
32
  "ACCESS_AUD": "{{access.aud}}",
33
33
  // {{/if}}
34
+ // The restart route's deployment-side grant. The Worker authenticates a
35
+ // bearer through SWITCHBOARD_INGRESS_TOKENS and compares its subject here;
36
+ // it never asks the runtime config it may be restarting to repair.
37
+ // {{#if restart}}
38
+ "SWITCHBOARD_RESTART_DEPLOYER": "{{restart.deployer}}",
39
+ // {{/if}}
34
40
  // The state Worker (deploy/cloudflare-memory/): where worker.ts records each
35
41
  // scheduled firing for the /runs Scheduled panel, with MEMORY_TOKEN.
36
42
  // A profile without a memory Worker renders no var: firings go unrecorded.
@@ -109,13 +109,16 @@ import {
109
109
  isCoordinatorInstance,
110
110
  isCoordinatorUnit,
111
111
  isThreadEvent,
112
+ isUnitWakeAnswer,
112
113
  sendChildSignal,
113
114
  sendRunFinished,
115
+ STEP_NAME_PATTERN,
114
116
  UNIT_PATTERN,
115
117
  type CoordinatorInstance,
116
118
  type CoordinatorUnit,
117
119
  type RunFinishedSend,
118
120
  type ThreadEvent,
121
+ type UnitWakeAnswer,
119
122
  } from "../../src/core/coordinator/contract.ts";
120
123
  import {
121
124
  GEN_PATTERN,
@@ -1848,10 +1851,27 @@ export class RunHistoryDO extends DurableObject<Env> {
1848
1851
  queuedAt: r.queued_at,
1849
1852
  state: r.state as PlaneQueueRow["state"],
1850
1853
  }));
1851
- const liveThreads = this.sql
1852
- .exec<{ thread_key: string }>(`SELECT thread_key FROM live_runs WHERE run_id IS NOT ?`, excludeRunId ?? null)
1853
- .toArray()
1854
- .map((r) => r.thread_key);
1854
+ const liveRows = this.sql
1855
+ .exec<{ run_id: string; thread_key: string; meta_json: string }>(
1856
+ `SELECT run_id, thread_key, meta_json FROM live_runs WHERE run_id IS NOT ?`,
1857
+ excludeRunId ?? null,
1858
+ )
1859
+ .toArray();
1860
+ const liveThreads = liveRows.map((r) => r.thread_key);
1861
+ const liveRuns = Object.fromEntries(
1862
+ liveRows.flatMap((r) => {
1863
+ const meta = JSON.parse(r.meta_json) as Record<string, unknown>;
1864
+ return typeof meta.channelId === "string"
1865
+ ? [[r.run_id, { channelId: meta.channelId, threadKey: r.thread_key }] as const]
1866
+ : [];
1867
+ }),
1868
+ );
1869
+ const inboxSeqs = Object.fromEntries(
1870
+ this.sql
1871
+ .exec<{ run_id: string; seq: number }>(`SELECT run_id, MAX(seq) AS seq FROM run_inbox GROUP BY run_id`)
1872
+ .toArray()
1873
+ .map((r) => [r.run_id, r.seq]),
1874
+ );
1855
1875
  const levels = this.sql
1856
1876
  .exec<{ resident: string; name: string; side: string; reported_at: number; generation: string }>(
1857
1877
  `SELECT * FROM plane_levels`,
@@ -1864,7 +1884,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1864
1884
  reportedAt: r.reported_at,
1865
1885
  generation: r.generation,
1866
1886
  }));
1867
- return { queue, liveThreads, reservations, openWindows, levels };
1887
+ return { queue, liveThreads, liveRuns, inboxSeqs, reservations, openWindows, levels };
1868
1888
  }
1869
1889
 
1870
1890
  /** The decider's writes, applied inside the same `transactionSync` that read
@@ -1971,21 +1991,19 @@ export class RunHistoryDO extends DurableObject<Env> {
1971
1991
  this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL`).toArray()[0]
1972
1992
  ?.n ?? 0,
1973
1993
  );
1994
+ const effectRunId = w.effect.kind === "admit" || w.effect.kind === "steer" ? w.effect.runId : undefined;
1974
1995
  const forRun =
1975
- w.effect.kind === "admit"
1976
- ? Number(
1996
+ effectRunId === undefined
1997
+ ? 0
1998
+ : Number(
1977
1999
  this.sql
1978
- .exec<{
1979
- n: number;
1980
- }>(
1981
- // Exact id matching (`admit:<runId>`): the seal runs with bot-minted
1982
- // run ids, and a LIKE would read `%`/`_` in one as wildcards.
1983
- `SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`,
1984
- w.effect.runId,
2000
+ .exec<{ n: number }>(
2001
+ `SELECT COUNT(*) AS n FROM plane_effects
2002
+ WHERE acked_at IS NULL AND json_extract(body_json, '$.runId') = ?`,
2003
+ effectRunId,
1985
2004
  )
1986
2005
  .toArray()[0]?.n ?? 0,
1987
- )
1988
- : 0;
2006
+ );
1989
2007
  const refusal = effectCapRefusal({ total, forRun }, w.effect);
1990
2008
  if (refusal !== undefined) throw new Error(refusal);
1991
2009
  // A re-offer lands after an ack for any kind — a probe re-probes after
@@ -2330,6 +2348,13 @@ export class RunHistoryDO extends DurableObject<Env> {
2330
2348
  // Exact id matching (`admit:<runId>`), never LIKE: a bot-minted run id
2331
2349
  // can carry `%` or `_`, which a pattern would read as wildcards.
2332
2350
  this.sql.exec(`DELETE FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`, runId);
2351
+ this.sql.exec(
2352
+ `DELETE FROM plane_effects
2353
+ WHERE acked_at IS NULL
2354
+ AND json_extract(body_json, '$.kind') = 'steer'
2355
+ AND json_extract(body_json, '$.runId') = ?`,
2356
+ runId,
2357
+ );
2333
2358
  // The run's steer dedupe rows and any park go with it (record 0064):
2334
2359
  // a sealed run holds no turn and reads no steer.
2335
2360
  this.sql.exec(`DELETE FROM plane_reservations WHERE kind IN ('steer', 'park') AND run_id = ?`, runId);
@@ -2434,12 +2459,54 @@ export class RunHistoryDO extends DurableObject<Env> {
2434
2459
  this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_disagreements', ?)`, JSON.stringify(counts));
2435
2460
  }
2436
2461
 
2462
+ /** Fence a pushed steer to the owning live generation before its local
2463
+ * registry is touched. The effect check, owner check and lease renewal are
2464
+ * one transaction: an expired owner may renew before reclaim, but reclaim
2465
+ * can never cross the synchronous local delivery that follows a success. */
2466
+ async planeFenceSteer(
2467
+ id: string,
2468
+ runId: string,
2469
+ gen: string,
2470
+ leaseMs: number,
2471
+ now: number,
2472
+ ): Promise<{ accepted: boolean }> {
2473
+ let accepted = false;
2474
+ this.ctx.storage.transactionSync(() => {
2475
+ const offered = this.sql
2476
+ .exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
2477
+ .toArray()[0];
2478
+ if (!offered) return;
2479
+ const effect = JSON.parse(offered.body_json) as PlaneEffect;
2480
+ if (effect.kind !== "steer" || effect.runId !== runId) return;
2481
+ const live = this.liveRow(runId);
2482
+ if (!live || live.ownerGen !== gen || live.phase !== "live") return;
2483
+ this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
2484
+ accepted = true;
2485
+ });
2486
+ if (accepted) await this.ensurePlaneAlarm(now);
2487
+ return { accepted };
2488
+ }
2489
+
2437
2490
  /** An effect's acknowledgement by id (orchestration-plane item 7): `done` and `skipped` close it,
2438
- * `deferred` leaves it offered for the next answer. An unknown id is a
2439
- * no-op — the bot may ack an effect an older table never held. */
2440
- planeAck(id: string, outcome: PlaneAckOutcome, now: number): { ok: true } {
2441
- if (outcome !== "deferred")
2442
- this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
2491
+ * `deferred` leaves it offered for the next answer. A steer is special: the
2492
+ * acknowledging generation must still own its live row. A stale process
2493
+ * may retain registry state during reclaim overlap, but it cannot close the
2494
+ * durable offer. An unknown id is a no-op. */
2495
+ planeAck(id: string, outcome: PlaneAckOutcome, now: number, owner?: { runId: string; gen: string }): { ok: true } {
2496
+ if (outcome === "deferred") return { ok: true };
2497
+ const offered = this.sql
2498
+ .exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
2499
+ .toArray()[0];
2500
+ if (!offered) return { ok: true };
2501
+ const effect = JSON.parse(offered.body_json) as PlaneEffect;
2502
+ if (effect.kind === "steer") {
2503
+ if (!owner || owner.runId !== effect.runId) return { ok: true };
2504
+ const live = this.sql
2505
+ .exec<{ owner_gen: string }>(`SELECT owner_gen FROM live_runs WHERE run_id = ?`, owner.runId)
2506
+ .toArray()[0];
2507
+ if (live?.owner_gen !== owner.gen) return { ok: true };
2508
+ }
2509
+ this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
2443
2510
  return { ok: true };
2444
2511
  }
2445
2512
 
@@ -2543,7 +2610,8 @@ export class RunHistoryDO extends DurableObject<Env> {
2543
2610
  // ---- the thread events of a unit-owned thread (record 0051's reply-as-event rule) --------------
2544
2611
 
2545
2612
  /** The next sequence assigned in one transaction, the per-event cap applied
2546
- * (attachments dropped whole, the row saying how many). */
2613
+ * (attachments dropped whole, the row saying how many). A stable event id
2614
+ * already on the unit returns its first sequence without another row. */
2547
2615
  async appendUnitEvent(
2548
2616
  instanceId: string,
2549
2617
  unit: string,
@@ -2551,6 +2619,20 @@ export class RunHistoryDO extends DurableObject<Env> {
2551
2619
  ): Promise<{ ok: true; seq: number }> {
2552
2620
  let seq = 1;
2553
2621
  this.ctx.storage.transactionSync(() => {
2622
+ if (event.id !== undefined) {
2623
+ const existing = this.sql
2624
+ .exec<{ seq: number; json: string }>(
2625
+ `SELECT seq, json FROM coordinator_unit_events WHERE instance_id = ? AND unit = ? ORDER BY seq`,
2626
+ instanceId,
2627
+ unit,
2628
+ )
2629
+ .toArray()
2630
+ .find((row) => (JSON.parse(row.json) as ThreadEvent).id === event.id);
2631
+ if (existing !== undefined) {
2632
+ seq = existing.seq;
2633
+ return;
2634
+ }
2635
+ }
2554
2636
  const max = this.sql
2555
2637
  .exec<{
2556
2638
  m: number | null;
@@ -2602,6 +2684,38 @@ export class RunHistoryDO extends DurableObject<Env> {
2602
2684
  return { ok: true };
2603
2685
  }
2604
2686
 
2687
+ /** One transaction is the wake's decision: the indexed answer on the unit
2688
+ * row and every event it consumed become visible together. */
2689
+ async answerUnitWake(
2690
+ unit: CoordinatorUnit,
2691
+ waitId: string,
2692
+ answer: UnitWakeAnswer,
2693
+ seqs: number[],
2694
+ by: string,
2695
+ now: number,
2696
+ ): Promise<{ ok: true }> {
2697
+ this.ctx.storage.transactionSync(() => {
2698
+ const updated = { ...unit, wakes: { ...(unit.wakes ?? {}), [waitId]: answer } };
2699
+ this.sql.exec(
2700
+ `INSERT INTO coordinator_units (instance_id, unit, json, updated_at) VALUES (?, ?, ?, ?)
2701
+ ON CONFLICT(instance_id, unit) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at`,
2702
+ unit.instanceId,
2703
+ unit.unit,
2704
+ JSON.stringify(updated),
2705
+ now,
2706
+ );
2707
+ for (const seq of seqs)
2708
+ this.sql.exec(
2709
+ `UPDATE coordinator_unit_events SET consumed_by = ? WHERE instance_id = ? AND unit = ? AND seq = ? AND consumed_by IS NULL`,
2710
+ by,
2711
+ unit.instanceId,
2712
+ unit.unit,
2713
+ seq,
2714
+ );
2715
+ });
2716
+ return { ok: true };
2717
+ }
2718
+
2605
2719
  // ---- the live-run ledger (run-history items 28–34) --------------------------
2606
2720
 
2607
2721
  private liveRow(runId: string): LiveRunRow | undefined {
@@ -4820,6 +4934,7 @@ const LEDGER_ROUTES = new Set([
4820
4934
  "/runs/coordinator/events/append",
4821
4935
  "/runs/coordinator/events/list",
4822
4936
  "/runs/coordinator/events/mark-consumed",
4937
+ "/runs/coordinator/wake",
4823
4938
  "/runs/claim",
4824
4939
  "/runs/heartbeat",
4825
4940
  "/runs/append",
@@ -4854,12 +4969,13 @@ const LEDGER_ROUTES = new Set([
4854
4969
  "/runs/session/notepad/write",
4855
4970
  ]);
4856
4971
 
4857
- /** The plane's routes (record 0064; orchestration-plane items 7 and 8): the shadow outcome post and the
4858
- * effect acknowledgement. Both land on the ledger object of the given store
4859
- * key, like every `/runs/*` route. */
4972
+ /** The plane's routes (record 0064; orchestration-plane items 7 and 8):
4973
+ * outcomes, effect delivery fencing and acknowledgements land on the ledger
4974
+ * object of the given store key, like every `/runs/*` route. */
4860
4975
  const PLANE_ROUTES = new Set([
4861
4976
  "/plane/outcome",
4862
4977
  "/plane/ack",
4978
+ "/plane/steer/fence",
4863
4979
  "/plane/admit",
4864
4980
  "/plane/withdraw",
4865
4981
  "/plane/deploy",
@@ -4915,11 +5031,32 @@ async function handlePlane(pathname: string, body: unknown, env: Env): Promise<R
4915
5031
  );
4916
5032
  return json(r);
4917
5033
  }
5034
+ if (pathname === "/plane/steer/fence") {
5035
+ if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
5036
+ const runId = parseRunId(b.runId);
5037
+ if (!runId.ok) return json({ error: runId.error }, 400);
5038
+ const g = gen(b.gen);
5039
+ if (!g.ok) return json({ error: g.error }, 400);
5040
+ const lease = parseLeaseMs(b.leaseMs);
5041
+ if (!lease.ok) return json({ error: lease.error }, 400);
5042
+ return json(await stub.planeFenceSteer(b.id, runId.value, g.value, lease.value, now));
5043
+ }
4918
5044
  if (pathname === "/plane/ack") {
4919
5045
  if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
4920
5046
  if (typeof b.outcome !== "string" || !PLANE_ACK_OUTCOMES.has(b.outcome))
4921
5047
  return json({ error: "outcome must be done, skipped or deferred" }, 400);
4922
- return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now));
5048
+ let owner: { runId: string; gen: string } | undefined;
5049
+ if (b.owner !== undefined) {
5050
+ if (typeof b.owner !== "object" || b.owner === null || Array.isArray(b.owner))
5051
+ return json({ error: "owner must name a runId and generation" }, 400);
5052
+ const rawOwner = b.owner as Record<string, unknown>;
5053
+ const runId = parseRunId(rawOwner.runId);
5054
+ if (!runId.ok) return json({ error: runId.error }, 400);
5055
+ if (typeof rawOwner.gen !== "string" || rawOwner.gen.length === 0)
5056
+ return json({ error: "owner generation must be a non-empty string" }, 400);
5057
+ owner = { runId: runId.value, gen: rawOwner.gen };
5058
+ }
5059
+ return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now, owner));
4923
5060
  }
4924
5061
  if (pathname === "/plane/admit") {
4925
5062
  // The admission-stage ask (record 0064, "The queue"): the thread key, the
@@ -5457,8 +5594,22 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
5457
5594
  return json({ error: "instanceId must be a Workflow instance id" }, 400);
5458
5595
  return json({ units: await stub.listUnits(b.instanceId) });
5459
5596
  }
5597
+ if (pathname === "/runs/coordinator/wake") {
5598
+ if (!isCoordinatorUnit(b.unit)) return json({ error: "unit must be a coordinator unit row" }, 400);
5599
+ if (typeof b.waitId !== "string" || !STEP_NAME_PATTERN.test(b.waitId))
5600
+ return json({ error: "waitId must be a step name" }, 400);
5601
+ if (!isUnitWakeAnswer(b.answer)) return json({ error: "answer must be a unit wake answer" }, 400);
5602
+ if (!Array.isArray(b.seqs) || !b.seqs.every((s) => typeof s === "number" && Number.isInteger(s) && s >= 1))
5603
+ return json({ error: "seqs must be an array of sequence numbers" }, 400);
5604
+ if (typeof b.by !== "string" || b.by.length === 0 || b.by.length > 200)
5605
+ return json({ error: "by must name the consumer" }, 400);
5606
+ const r = await stub.answerUnitWake(b.unit, b.waitId, b.answer, b.seqs as number[], b.by, now);
5607
+ console.log(`[runs/coordinator/wake] ${key.value} ${b.unit.instanceId}:${b.unit.unit} ${b.waitId}`);
5608
+ return json(r);
5609
+ }
5460
5610
  // The thread events of a unit-owned thread (record 0051's reply-as-event rule): append assigns
5461
- // the sequence, list filters unconsumed, mark-consumed is idempotent.
5611
+ // the sequence (or returns the row with the same stable id), list filters
5612
+ // unconsumed, mark-consumed is idempotent.
5462
5613
  if (pathname.startsWith("/runs/coordinator/events/")) {
5463
5614
  if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
5464
5615
  return json({ error: "instanceId must be a Workflow instance id" }, 400);