@coreplane/switchboard 1.259.0 → 1.260.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 (47) hide show
  1. package/dist/assets/config/config.example.yaml +16 -24
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  4. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +3 -5
  9. package/dist/assets/src/core/authz/policy.ts +5 -0
  10. package/dist/assets/src/core/budgets.ts +7 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  12. package/dist/assets/src/core/coordinator/driver.ts +386 -124
  13. package/dist/assets/src/core/memory/types.ts +5 -0
  14. package/dist/assets/src/core/pipelineStanding.ts +11 -4
  15. package/dist/assets/src/core/plane/decide.ts +53 -12
  16. package/dist/assets/src/core/provider.ts +5 -0
  17. package/dist/assets/src/core/runEvents.ts +14 -15
  18. package/dist/assets/src/core/runRecord.ts +36 -3
  19. package/dist/assets/src/core/ship/coordinator.ts +58 -18
  20. package/dist/assets/src/core/ship/renewal.ts +21 -8
  21. package/dist/assets/src/core/types.ts +11 -0
  22. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  23. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-DvMrWUg7.js} +1 -1
  24. package/dist/assets/web/dist/assets/HomePage-CcGEJ4w0.js +1 -0
  25. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-BzGVxDYs.js} +1 -1
  26. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-Arj9cyd5.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-CBPPe4Sj.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-CEpXgGo7.js} +1 -1
  29. package/dist/assets/web/dist/assets/RunFoldRow-BJq7tnLr.js +1 -0
  30. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-DWiny7LN.js} +4 -4
  31. package/dist/assets/web/dist/assets/RunsIndexPage-Cxufw5sV.js +1 -0
  32. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-SBcuDdVg.js} +1 -1
  33. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-DrD_vWdp.js} +1 -1
  34. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-Bjl7EPEN.js} +1 -1
  35. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-BqteQTav.js} +1 -1
  36. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-KRqJXEPN.js} +1 -1
  37. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-Cv28FV4I.js} +1 -1
  38. package/dist/assets/web/dist/assets/{budgets-BRLm6oID.js → budgets-DQXVllsm.js} +1 -1
  39. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-CTASUSno.js} +1 -1
  40. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-B1YBy_IL.js} +1 -1
  41. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-eTAe9-hk.js} +2 -2
  42. package/dist/assets/web/dist/assets/{sseReplay-BjlKmQsY.js → sseReplay-BcsNbn2j.js} +1 -1
  43. package/dist/cli.js +8299 -7929
  44. package/package.json +1 -1
  45. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  46. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  47. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
@@ -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
@@ -368,7 +369,10 @@ workspaceDir: ./workspaces
368
369
  # # records (soft delete, status `evicted`) in the same write.
369
370
  # model: anthropic/claude-haiku-4-5
370
371
  # # <provider>/<model> for the reflection pass — pick a
371
- # # cheap tier. Absent → the run's own resolved model.
372
+ # # cheap level. Absent → the run's own resolved model.
373
+ # effort: low # how hard the reflection model thinks per distillation —
374
+ # # low | medium | high | xhigh | max, applied through the
375
+ # # model card; unset → the model's own default.
372
376
 
373
377
  # Self-improvement proposals (docs/reference/specs/run-friction.md). Every finished run's friction
374
378
  # diagnosis (docs/reference/specs/run-friction.md) is stored with its run record, so the
@@ -479,26 +483,11 @@ workspaceDir: ./workspaces
479
483
  # spawn:
480
484
  # maxChildren: 3
481
485
 
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`.
486
+ # The one door's mode (routing-and-config item 29): `on` (default) — the
487
+ # operator's typed decision is what runs; `shadow` — one operator turn per
488
+ # admitted chat event, its decision recorded and nothing run from it; `off` —
489
+ # the rollback lever. The retired readers' router has no model or effort key.
498
490
  # routing:
499
- # auto: false
500
- # model: anthropic/claude-haiku-4-5
501
- # answer: text
502
491
  # operator: off
503
492
 
504
493
  # 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 +501,17 @@ workspaceDir: ./workspaces
512
501
  # model call) or `always` (today's answer-every-reply path, byte for byte).
513
502
  # Also settable per channel and per user: `intake: { threadReplies: <mode> }`
514
503
  # 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>.
504
+ # `model` is the verdict's model; default `defaults.models.general`. `effort`
505
+ # is how hard that model thinks per verdict — low | medium | high | xhigh |
506
+ # max, applied through the model card; unset → the model's own default. A
507
+ # mention, a DM and a top-level post never pass through the gate in any mode.
508
+ # A model whose operator card (`providers.<p>.
518
509
  # models.<id>.answers`) declares it answers neither a forced tool call nor the
519
510
  # text contract is refused at load when the default mode is `classify`.
520
511
  # intake:
521
512
  # threadReplies: classify
522
513
  # model: anthropic/claude-haiku-4-5
514
+ # effort: low
523
515
 
524
516
  # 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
517
  # docs/reference/specs/routing-and-config.md item 31; orchestration-plane.md).
@@ -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);
@@ -1221,9 +1221,12 @@ interface ThreadBinding {
1221
1221
  /** What one image reconcile decided (`reconcileImage`): the container was
1222
1222
  * stopped to restart on the current image (`restarted`), already runs it
1223
1223
  * (`current`), is not running so the next start uses it anyway (`inactive`),
1224
- * or is busy — an operation, an attach or a registered run in flight — and
1225
- * the restart is deferred to the next quiet check (`deferred`). */
1226
- type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred";
1224
+ * is busy — an operation, an attach or a registered run in flight — and the
1225
+ * restart is deferred to the next quiet check (`deferred`), or predates the
1226
+ * deploy on the attach path, which never stops a container (issue 2101): the
1227
+ * attach is refused so the new run takes the fallback sandbox, and the
1228
+ * restart stays the refresh cycle's or the deploy's (`stale`). */
1229
+ type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred" | "stale";
1227
1230
 
1228
1231
  interface AttachOk {
1229
1232
  workspace: string;
@@ -3406,6 +3409,7 @@ export class ResidentDO extends Sandbox<Env> {
3406
3409
  if ((await this.restoreCheckout(snap, deadlineMs)).done) {
3407
3410
  // Raced a container start that already had the right disk.
3408
3411
  await this.setResidentState("warm");
3412
+ await this.reportPendingImageCurrent("hydrate");
3409
3413
  return;
3410
3414
  }
3411
3415
  // The snapshot carries the checkout's tree, not the store (item 59): adopt
@@ -3479,6 +3483,11 @@ export class ResidentDO extends Sandbox<Env> {
3479
3483
  lastRestore: { at: new Date(systemClock()).toISOString(), ms: systemClock() - t0 },
3480
3484
  } satisfies RepoFacts);
3481
3485
  await this.setResidentState("warm");
3486
+ // This is the deploy reconcile's missing fact: the replacement itself has
3487
+ // started, restored its snapshot and become usable. Reporting after `warm`
3488
+ // avoids both clearing the marker on stop and probing while restore still
3489
+ // owns the fresh container (issue 2101).
3490
+ await this.reportPendingImageCurrent("hydrate");
3482
3491
  }
3483
3492
 
3484
3493
  // -- freshness (the refresh cycle's phases, one per instance step) -----------
@@ -4177,8 +4186,8 @@ export class ResidentDO extends Sandbox<Env> {
4177
4186
  }
4178
4187
 
4179
4188
  /** Run one step of the refresh instance: counted in flight once past the
4180
- * entry gates (so an attach-path reconcileImage never stops the container
4181
- * under it — while the gates themselves, `isIdle`, `reconcileImage("refresh")`
4189
+ * entry gates (so a concurrent reconcile — the deploy's — never stops the
4190
+ * container under it — while the gates themselves, `isIdle`, `reconcileImage("refresh")`
4182
4191
  * and the disk-full recycle, must not see the probing cycle as an operation
4183
4192
  * in flight, or no resident would ever park, restart a stale image or
4184
4193
  * recycle a full disk; the step is handed `cycle.count` and calls it once
@@ -5011,26 +5020,26 @@ export class ResidentDO extends Sandbox<Env> {
5011
5020
  * restart decided on the op counters alone stops the container under a live
5012
5021
  * run — stop the container so it restarts on the current image (state is DO
5013
5022
  * storage + R2 — the disk is a cache). A deferred restart re-checks on
5014
- * every later attach and refresh cycle until the resident is quiet; a
5015
- * registration whose release never came defers it only until the clean-idle
5016
- * sweep drains that registration. Answers `restarted` when a stop was
5017
- * issued, else why not. */
5018
- private async reconcileImage(where: string, force = false): Promise<ImageReconcileResult> {
5023
+ * every later refresh cycle until the resident is quiet; a registration
5024
+ * whose release never came defers it only until the clean-idle sweep drains
5025
+ * that registration. The attach path never stops the container (issue 2101,
5026
+ * the `stale` answer below); the stop is the refresh cycle's or the
5027
+ * deploy's alone. Answers `restarted` when a stop was issued, else why
5028
+ * not. */
5029
+ private async reconcileImage(where: "attach" | "refresh" | "deploy", force = false): Promise<ImageReconcileResult> {
5019
5030
  if (!(await this.isRuntimeActive().catch(() => false))) {
5020
- // An inactive container's next start is on the deployed image by
5021
- // construction — and a held resident whose container idled out has no
5022
- // other reporter left: new attaches are refused by the very drain the
5023
- // hold keeps standing, so without this report the fleet stays closed
5024
- // until the drain's `until` backstop (issue 1931).
5025
- await this.reportPendingImageCurrent(where);
5031
+ // Inactivity proves only that the old process is gone, not that its
5032
+ // replacement started successfully. Keep the marker: the next refresh's
5033
+ // hydration starts the deployed image and reports only after it reaches
5034
+ // `warm` (issue 2101).
5026
5035
  return "inactive";
5027
5036
  }
5028
5037
  const last = THREAD_USERS[THREAD_USERS.length - 1];
5029
5038
  // A pending report marker (issue 1931) means a deploy could not verify this
5030
5039
  // container on its image: the probe shortcut below is a POOL-USER check the
5031
5040
  // pre-deploy image passes when the pool did not change, so it can never
5032
- // satisfy the marker — the container is treated as stale until it is cycled
5033
- // post-deploy and the fresh start reported.
5041
+ // satisfy the marker — the container is treated as stale until it is
5042
+ // cycled post-deploy.
5034
5043
  const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5035
5044
  if (!force && pending === undefined) {
5036
5045
  const probe = await this.run(["id", "-u", last]);
@@ -5041,6 +5050,22 @@ export class ResidentDO extends Sandbox<Env> {
5041
5050
  : pending !== undefined
5042
5051
  ? "a deploy's new-image report is pending, so the running container cannot be trusted current"
5043
5052
  : `${last} missing in the running container`;
5053
+ if (where === "attach") {
5054
+ // An attach is never what restarts the container (issue 2101): the
5055
+ // post-deploy restart loop was exactly this — each attach stopped the
5056
+ // container the previous attach had stopped and probed a fresh start
5057
+ // still restoring, so the marker never cleared. While the marker is
5058
+ // pending every NEW attach is refused (`stale`) — the run takes the
5059
+ // fallback sandbox and the resident can go quiet for the refresh cycle's
5060
+ // restart; a stale pool with no marker keeps admitting onto a busy
5061
+ // container, as the deferral always did.
5062
+ if (pending === undefined && (this.inFlightCount() > 0 || (await this.registeredRunsBeyondOps()) > 0)) {
5063
+ console.log(`image-stale (attach): ${stale} but the container is busy — deferring to the refresh cycle`);
5064
+ return "deferred";
5065
+ }
5066
+ console.log(`image-stale (attach): ${stale} — the attach is refused; the refresh cycle restarts the container`);
5067
+ return "stale";
5068
+ }
5044
5069
  const busy = this.inFlightCount();
5045
5070
  if (busy > 0) {
5046
5071
  console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
@@ -5056,32 +5081,22 @@ export class ResidentDO extends Sandbox<Env> {
5056
5081
  console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
5057
5082
  this.swapIncarnation(); // deliberate incarnation swap
5058
5083
  await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
5059
- if (pending !== undefined) {
5060
- // The post-deploy cycle just happened: probe the FRESH container — the
5061
- // SDK boots it for this command on the image the deployed Worker pins,
5062
- // so a fresh start answering after the deploy is the verified fact
5063
- // (issue 1931), never the pool-user probe on the old process. A failed
5064
- // probe keeps the marker: the next reconcile tries the cycle again.
5065
- const fresh = await this.run(["id", "-u", last])
5066
- .then((p) => p.exitCode === 0)
5067
- .catch(() => false);
5068
- if (fresh) await this.reportPendingImageCurrent(where);
5069
- else
5070
- console.log(`image-stale (${where}): the fresh container's probe failed — the new-image report stays pending`);
5071
- }
5084
+ // A successful stop proves only that the old process is gone. The pending
5085
+ // marker deliberately survives it: the replacement's own hydration reports
5086
+ // after reaching `warm`, so `imageReport: current` can never precede a
5087
+ // successful post-deploy start (issue 2101).
5072
5088
  return "restarted";
5073
5089
  }
5074
5090
 
5075
5091
  /** The report a held drain waits for (issue 1931): when the deploy's
5076
5092
  * reconcile could not verify this resident's fresh container on the new
5077
5093
  * image, a marker stays in storage and the registry holds the drain. The
5078
- * fact the report stands on is a fresh container start AFTER the deploy —
5079
- * the marker makes `reconcileImage` treat the container as stale until the
5080
- * cycle happens, so this is called only behind that fresh start (or on an
5081
- * inactive resident, whose next start is on the new image by construction).
5082
- * The marker is deleted only after a successful report: a transient failure
5083
- * keeps it for the next reconcile, and the drain's `until` is the backstop
5084
- * for a report that never lands. */
5094
+ * fact the report stands on is the replacement's own completed hydration:
5095
+ * the fresh container has restored its snapshot and reached `warm`. A stop
5096
+ * or inactive runtime does not report; each keeps the marker for that next
5097
+ * start (issue 2101). The marker is deleted only after a successful report:
5098
+ * a transient failure keeps it for the next reconcile, and the drain's
5099
+ * `until` is the backstop for a report that never lands. */
5085
5100
  private async reportPendingImageCurrent(where: string): Promise<boolean> {
5086
5101
  const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5087
5102
  if (pending === undefined) return true;
@@ -5107,12 +5122,12 @@ export class ResidentDO extends Sandbox<Env> {
5107
5122
  * tell the pre-deploy image from the new one when the pool did not change,
5108
5123
  * and the platform replaces the container processes asynchronously after an
5109
5124
  * image-changing deploy (issue 1931) — so this path never trusts "current":
5110
- * an active, quiet container is always cycled, and the fresh start —
5111
- * necessarily on the deploy's image, which landed before this call — is
5112
- * probed. `verified` true is the fact the reopen may stand on; anything
5113
- * else (deferred, a failed fresh probe) leaves a hold on the drain, cleared
5114
- * by this resident's later report (`reportPendingImageCurrent`), never by a
5115
- * timer.
5125
+ * an active, quiet container is always cycled, but that stop is not a report.
5126
+ * The marker survives until the replacement hydrates and reaches `warm` on
5127
+ * the deploy's image (issue 2101: the old probe raced that restore and lost).
5128
+ * `verified` true is the fact the reopen may stand on; a cycle or inactive
5129
+ * result leaves a hold on the drain, cleared by this resident's later
5130
+ * `reportPendingImageCurrent`, never by a timer.
5116
5131
  *
5117
5132
  * The hold lands BEFORE the marker: the marker is what lets any concurrent
5118
5133
  * attach/refresh reconcile report, and a report that reaches the registry
@@ -5124,10 +5139,10 @@ export class ResidentDO extends Sandbox<Env> {
5124
5139
  await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
5125
5140
  const result = await this.reconcileImage("deploy", true);
5126
5141
  if (result === "deferred") return { result, verified: false };
5127
- // Inactive: the inactive early-return reported (the next start is on the
5128
- // new image by construction). Restarted: the cycle's fresh probe and
5129
- // report ran inside reconcileImage. Either way the marker gone is the
5130
- // verification.
5142
+ // Neither inactivity nor a successful stop is verification: only the
5143
+ // replacement's completed hydration clears the marker. A concurrent fresh
5144
+ // start may have done so while this reconcile yielded, hence the storage
5145
+ // read rather than an unconditional false.
5131
5146
  const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
5132
5147
  return { result, verified };
5133
5148
  }
@@ -5471,12 +5486,19 @@ export class ResidentDO extends Sandbox<Env> {
5471
5486
  const memory = await this.memoryGate("attach", registered);
5472
5487
  if (memory) return memory;
5473
5488
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
5474
- if ((await this.reconcileImage("attach")) === "restarted") {
5489
+ // An attach never restarts the container (issue 2101): a `stale` verdict
5490
+ // refuses the NEW run — it falls back to the seeded sandbox — while a
5491
+ // registered run's re-attach passes exactly as it passes the drain and
5492
+ // the memory gate; the restart itself is the refresh cycle's or the
5493
+ // deploy's.
5494
+ if ((await this.reconcileImage("attach")) === "stale" && !registered) {
5495
+ const s = await this.getStatus();
5475
5496
  return {
5476
- error: "image-stale: the container predates the current pool and is restarting; retry shortly",
5497
+ error:
5498
+ "image-stale: the container predates the deploy and restarts on the next quiet refresh; new runs use the fallback sandbox until then",
5477
5499
  status: 503,
5478
- state: "restoring",
5479
- stateReason: "",
5500
+ state: s.state,
5501
+ stateReason: s.reason,
5480
5502
  reason: "image-stale",
5481
5503
  };
5482
5504
  }
@@ -48,11 +48,14 @@ ENV NODE_OPTIONS=--max-old-space-size=8192
48
48
  # item 25) restores the resident's snapshot with the SDK's presigned restore,
49
49
  # which MOUNTS the archive (squashfuse + fuse-overlayfs) instead of extracting
50
50
  # it; the Worker extracts it onto the disk the way the resident does
51
- # (resident-repos item 61) so the checkout is one plain ext4 tree. The build
52
- # asserts the binary with the probe the extract script uses (`command -v`).
51
+ # (resident-repos item 61) so the checkout is one plain ext4 tree.
52
+ # ripgrep (`rg`): the search binary is an image invariant for both fresh and
53
+ # seeded sandboxes; restoring a seed changes the disk, not the image tools.
54
+ # The build asserts both binaries with the probes their callers use.
53
55
  RUN apt-get update \
54
- && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools \
56
+ && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools ripgrep \
55
57
  && command -v unsquashfs >/dev/null \
58
+ && command -v rg >/dev/null \
56
59
  && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
57
60
  -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
58
61
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \