@coreplane/switchboard 1.255.0 → 1.257.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 (61) hide show
  1. package/dist/assets/config/config.example.yaml +14 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +340 -7
  3. package/dist/assets/deploy/cloudflare-resident/drain.ts +100 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +227 -34
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +5 -3
  7. package/dist/assets/project.json +17 -9
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +55 -1
  10. package/dist/assets/src/core/authz/grants.ts +12 -0
  11. package/dist/assets/src/core/authz/policy.ts +15 -0
  12. package/dist/assets/src/core/budgets.ts +51 -17
  13. package/dist/assets/src/core/coordinator/contract.ts +45 -5
  14. package/dist/assets/src/core/coordinator/driver.ts +17 -4
  15. package/dist/assets/src/core/delivery.ts +8 -5
  16. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  17. package/dist/assets/src/core/plane/decide.ts +468 -12
  18. package/dist/assets/src/core/plane/findings.ts +120 -0
  19. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  20. package/dist/assets/src/core/runEvents.ts +41 -14
  21. package/dist/assets/src/core/runFriction.ts +16 -0
  22. package/dist/assets/src/core/runLedger/types.ts +16 -3
  23. package/dist/assets/src/core/runRecord.ts +12 -0
  24. package/dist/assets/src/core/ship/contract.ts +34 -32
  25. package/dist/assets/src/core/ship/coordinator.ts +291 -95
  26. package/dist/assets/src/core/ship/renewal.ts +19 -17
  27. package/dist/assets/src/core/trace/attrs.ts +1 -1
  28. package/dist/assets/src/core/untrusted.ts +35 -0
  29. package/dist/assets/src/execution/residentCredentials.ts +7 -4
  30. package/dist/assets/web/dist/.vite/manifest.json +82 -57
  31. package/dist/assets/web/dist/assets/HomePage-DJqJQzrL.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DGINv9XT.js → PendingTurnRow-fhKqKUVv.js} +1 -1
  33. package/dist/assets/web/dist/assets/PlanePage-Bood0hNO.js +1 -0
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-DOe1HtUC.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-TyCGjXM4.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-C_4t-sit.js +1 -0
  37. package/dist/assets/web/dist/assets/RunRoutePage-BNepPMRX.js +9 -0
  38. package/dist/assets/web/dist/assets/RunsIndexPage-jZnMw1mM.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-BkNoudDP.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-COvWGXl-.js} +1 -1
  41. package/dist/assets/web/dist/assets/SilentTurn-DNAANKgN.js +2 -0
  42. package/dist/assets/web/dist/assets/SlackMark-CtiHrNjr.js +1 -0
  43. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-BgmkSV0S.js} +1 -1
  44. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-DEUPuCPW.js} +1 -1
  45. package/dist/assets/web/dist/assets/UnitRoutePage-DNhdzxvY.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-CF3jz9LM.js} +1 -1
  47. package/dist/assets/web/dist/assets/durationTone-CVpX_yIk.js +1 -0
  48. package/dist/assets/web/dist/assets/main-7SAujY_s.js +28 -0
  49. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +1 -0
  50. package/dist/assets/web/dist/assets/{sseReplay-DE6wv1Ua.js → sseReplay-BRJEIh83.js} +1 -1
  51. package/dist/cli.js +2941 -1116
  52. package/package.json +1 -1
  53. package/dist/assets/web/dist/assets/HomePage-Be7jLLnU.js +0 -3
  54. package/dist/assets/web/dist/assets/PlanePage-JEj-lqgz.js +0 -1
  55. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  56. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  57. package/dist/assets/web/dist/assets/RunsIndexPage-DW-HHuZa.js +0 -1
  58. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
  59. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +0 -1
  60. package/dist/assets/web/dist/assets/main-C4QfwybO.css +0 -1
  61. package/dist/assets/web/dist/assets/main-mAKx_zo9.js +0 -28
@@ -343,7 +343,16 @@ import {
343
343
  } from "../../src/execution/residentDepsStore.js";
344
344
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
345
345
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
346
- import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
346
+ import {
347
+ drainRefusal,
348
+ holdDrain,
349
+ liftDrain,
350
+ liveDrain,
351
+ parseDrainRequest,
352
+ reportImageCurrent,
353
+ staleHolds,
354
+ type DrainRecord,
355
+ } from "./drain";
347
356
  import {
348
357
  CGROUP_READ_ARGV,
349
358
  MEMORY_PRESSURE_REASON,
@@ -1434,6 +1443,10 @@ const TEST_OVERRIDES_KEY = "testOverrides";
1434
1443
  * counts as a slot; it survives the isolate swap a deploy performs, which is
1435
1444
  * why the record carries its own end. */
1436
1445
  const DRAIN_KEY = "drain";
1446
+ /** Resident-DO key (issue 1931): set when a deploy's reconcile could not
1447
+ * verify this resident's container on the new image; the next reconcile that
1448
+ * finds it current reports to the registry and clears it. */
1449
+ const IMAGE_REPORT_PENDING_KEY = "imageReportPending";
1437
1450
 
1438
1451
  type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
1439
1452
 
@@ -1568,12 +1581,56 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1568
1581
  return record;
1569
1582
  }
1570
1583
 
1571
- /** Admin-only by construction (POST /undrain): true when a record was there.
1572
- * A cleared drain posts `below` — the plane's resident-drain window lifts. */
1573
- async clearDrain(): Promise<boolean> {
1584
+ /** Admin-only by construction (POST /undrain): `cleared` when a record was
1585
+ * there and was lifted — a cleared drain posts `below`, the plane's
1586
+ * resident-drain window lifts. With holds outstanding (issue 1931: a
1587
+ * container still to report the deploy's image) the fleet STAYS closed:
1588
+ * the record stands with `liftAsked` and the last container's report lifts
1589
+ * it (`reportContainerImageCurrent`); `until` remains the backstop. */
1590
+ async clearDrain(): Promise<{ cleared: boolean; held: string[] }> {
1591
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1592
+ if (record !== null) {
1593
+ const lift = liftDrain(record);
1594
+ if (!lift.cleared) {
1595
+ await this.ctx.storage.put(DRAIN_KEY, lift.record);
1596
+ return { cleared: false, held: lift.record.holds ?? [] };
1597
+ }
1598
+ }
1574
1599
  const had = await this.ctx.storage.delete(DRAIN_KEY);
1575
1600
  if (had) await this.pushDrainPost("below");
1576
- return had;
1601
+ return { cleared: had, held: [] };
1602
+ }
1603
+
1604
+ /** The deploy's reconcile could not verify these residents' containers on
1605
+ * the new image (issue 1931): hold the drain for each — the fleet must not
1606
+ * reopen onto them until they report. No live drain, nothing to hold. */
1607
+ async holdDrainFor(resources: string[]): Promise<void> {
1608
+ const now = systemClock();
1609
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), now);
1610
+ if (record === null || resources.length === 0) return;
1611
+ const held = holdDrain(record, resources, now);
1612
+ await this.ctx.storage.put(DRAIN_KEY, held);
1613
+ // The hold's liveness alarm (issue 2044): the cycle bound is the earlier
1614
+ // end, so the alarm fires there — reopening the fleet with the stale
1615
+ // containers named — instead of at `until`, the last resort.
1616
+ if (held.holdsUntil !== undefined) await this.ctx.storage.setAlarm(Date.parse(held.holdsUntil));
1617
+ }
1618
+
1619
+ /** One resident's word that its running container is on the deploy's image:
1620
+ * its hold drops, and when it was the last hold of a lift already asked the
1621
+ * drain lifts here — the reopen fires on the last container's report. */
1622
+ async reportContainerImageCurrent(resource: string): Promise<{ lifted: boolean }> {
1623
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1624
+ if (record === null) return { lifted: false };
1625
+ const report = reportImageCurrent(record, resource);
1626
+ if (report.record === null) {
1627
+ await this.ctx.storage.delete(DRAIN_KEY);
1628
+ await this.pushDrainPost("below");
1629
+ console.log(`[drain] fleet reopened — ${resource} was the last container to report the deploy's image`);
1630
+ return { lifted: true };
1631
+ }
1632
+ await this.ctx.storage.put(DRAIN_KEY, report.record);
1633
+ return { lifted: false };
1577
1634
  }
1578
1635
 
1579
1636
  /** The one alarm, at the drain's `until` (record 0064): a drain past
@@ -1585,11 +1642,25 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1585
1642
  const stored = await this.ctx.storage.get(DRAIN_KEY);
1586
1643
  if (stored === undefined) return;
1587
1644
  if (liveDrain(stored, now) === null) {
1645
+ // The hold's liveness (issue 2044): a reopen past the cycle bound names
1646
+ // the containers whose post-deploy cycle never landed — a warning, never
1647
+ // a silence; each restarts on its own next quiet attach or refresh.
1648
+ const stale = staleHolds(stored, now);
1649
+ if (stale !== null)
1650
+ console.log(
1651
+ `[drain] fleet reopened with ${stale.join(", ")} still on the pre-deploy image — the post-deploy cycle did not land within its bound; a stale container restarts on its next quiet attach or refresh`,
1652
+ );
1588
1653
  await this.ctx.storage.delete(DRAIN_KEY);
1589
1654
  await this.pushDrainPost("below");
1590
1655
  } else {
1591
- // Replaced with a later end under an already-armed alarm: re-arm at it.
1592
- await this.ctx.storage.setAlarm(Date.parse((stored as DrainRecord).until));
1656
+ // Replaced with a later end under an already-armed alarm: re-arm at the
1657
+ // record's own earlier end — the hold's cycle bound when one stands.
1658
+ const record = stored as DrainRecord;
1659
+ const ends = [
1660
+ Date.parse(record.until),
1661
+ ...(record.holdsUntil !== undefined ? [Date.parse(record.holdsUntil)] : []),
1662
+ ];
1663
+ await this.ctx.storage.setAlarm(Math.min(...ends.filter(Number.isFinite)));
1593
1664
  }
1594
1665
  }
1595
1666
 
@@ -3211,6 +3282,14 @@ export class ResidentDO extends Sandbox<Env> {
3211
3282
  await this.writeDiskMarkers({ ready: sha, depsKey: lockfileHash, builtSha: sha });
3212
3283
  this.deleteSchedules(PROVISIONING_CALLBACK);
3213
3284
  await this.setResidentState("warm");
3285
+ // A fresh provision runs on the image the deployed Worker pins, so it IS
3286
+ // the cycle a held drain waits for (issue 2044): report it — a rebuild
3287
+ // mid-drain reopens the fleet instead of leaving the hold standing on a
3288
+ // report `reconcileImage` would never send for a container it never saw
3289
+ // as stale. The marker is cleared with the report; a failure keeps both
3290
+ // for the next reconcile, and the hold's cycle bound is the backstop.
3291
+ await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
3292
+ await this.reportPendingImageCurrent("provision");
3214
3293
  // The first refresh instance is the cron's: the row now reads `warm`
3215
3294
  // with no instance recorded, so the next firing creates it (item 9).
3216
3295
  } catch (err) {
@@ -4936,41 +5015,121 @@ export class ResidentDO extends Sandbox<Env> {
4936
5015
  * registration whose release never came defers it only until the clean-idle
4937
5016
  * sweep drains that registration. Answers `restarted` when a stop was
4938
5017
  * issued, else why not. */
4939
- private async reconcileImage(where: string): Promise<ImageReconcileResult> {
4940
- if (!(await this.isRuntimeActive().catch(() => false))) return "inactive";
5018
+ private async reconcileImage(where: string, force = false): Promise<ImageReconcileResult> {
5019
+ 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);
5026
+ return "inactive";
5027
+ }
4941
5028
  const last = THREAD_USERS[THREAD_USERS.length - 1];
4942
- const probe = await this.run(["id", "-u", last]);
4943
- if (probe.exitCode === 0) return "current";
5029
+ // A pending report marker (issue 1931) means a deploy could not verify this
5030
+ // container on its image: the probe shortcut below is a POOL-USER check the
5031
+ // 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.
5034
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5035
+ if (!force && pending === undefined) {
5036
+ const probe = await this.run(["id", "-u", last]);
5037
+ if (probe.exitCode === 0) return "current";
5038
+ }
5039
+ const stale = force
5040
+ ? "the container predates the deploy"
5041
+ : pending !== undefined
5042
+ ? "a deploy's new-image report is pending, so the running container cannot be trusted current"
5043
+ : `${last} missing in the running container`;
4944
5044
  const busy = this.inFlightCount();
4945
5045
  if (busy > 0) {
4946
- console.log(
4947
- `image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
4948
- );
5046
+ console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
4949
5047
  return "deferred";
4950
5048
  }
4951
5049
  const registered = await this.registeredRunsBeyondOps();
4952
5050
  if (registered > 0) {
4953
5051
  console.log(
4954
- `image-stale (${where}): ${last} missing but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
5052
+ `image-stale (${where}): ${stale} but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
4955
5053
  );
4956
5054
  return "deferred";
4957
5055
  }
4958
- console.log(
4959
- `image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
4960
- );
5056
+ console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
4961
5057
  this.swapIncarnation(); // deliberate incarnation swap
4962
5058
  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
+ }
4963
5072
  return "restarted";
4964
5073
  }
4965
5074
 
5075
+ /** The report a held drain waits for (issue 1931): when the deploy's
5076
+ * reconcile could not verify this resident's fresh container on the new
5077
+ * 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. */
5085
+ private async reportPendingImageCurrent(where: string): Promise<boolean> {
5086
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5087
+ if (pending === undefined) return true;
5088
+ try {
5089
+ const { lifted } = await registryStub(this.env).reportContainerImageCurrent(pending.resource);
5090
+ await this.ctx.storage.delete(IMAGE_REPORT_PENDING_KEY);
5091
+ console.log(
5092
+ `[reconcile] ${pending.resource} reports the deploy's image (${where})${lifted ? " — drain lifted" : ""}`,
5093
+ );
5094
+ return true;
5095
+ } catch (err) {
5096
+ console.log(
5097
+ `[reconcile] ${pending.resource}: the new-image report failed (${where}): ${errMsg(err)} — retried on the next reconcile`,
5098
+ );
5099
+ return false;
5100
+ }
5101
+ }
5102
+
4966
5103
  /** The deploy's reconcile, inside the drain window (item 69's order: the
4967
5104
  * runs in flight end, the swap lands, the containers reconcile, the fleet
4968
5105
  * reopens): `POST /reconcile` calls this on every resident after the Worker
4969
- * deploy landed and BEFORE the drain is lifted, so a container that
4970
- * predates the new image restarts while nothing can be admitted onto it —
4971
- * never under the first run the reopened fleet admits. */
4972
- async reconcileForDeploy(): Promise<{ result: ImageReconcileResult }> {
4973
- return { result: await this.reconcileImage("deploy") };
5106
+ * deploy landed and BEFORE the drain is lifted. The pool-user probe cannot
5107
+ * tell the pre-deploy image from the new one when the pool did not change,
5108
+ * and the platform replaces the container processes asynchronously after an
5109
+ * 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.
5116
+ *
5117
+ * The hold lands BEFORE the marker: the marker is what lets any concurrent
5118
+ * attach/refresh reconcile report, and a report that reaches the registry
5119
+ * before the hold is a no-op — the marker would be consumed and the hold
5120
+ * added afterwards would wait for a report nothing sends (until the drain's
5121
+ * `until`). Held first, every report finds its hold. */
5122
+ async reconcileForDeploy(resource: string): Promise<{ result: ImageReconcileResult; verified: boolean }> {
5123
+ await registryStub(this.env).holdDrainFor([resource]);
5124
+ await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
5125
+ const result = await this.reconcileImage("deploy", true);
5126
+ 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.
5131
+ const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
5132
+ return { result, verified };
4974
5133
  }
4975
5134
 
4976
5135
  // -- watchdog (the sparse cron; it re-arms nothing) --------------------------
@@ -7467,6 +7626,19 @@ export class ResidentDO extends Sandbox<Env> {
7467
7626
  }
7468
7627
 
7469
7628
  private async runOpTraced(op: "test" | "build", refArg: string | null, t0: number): Promise<OpRunOk | ThreadErr> {
7629
+ // The fleet drain (item 69; issue 2044): a typed op is a new piece of work
7630
+ // like a new run's attach, and during the incident one waited silently at
7631
+ // the drain — so it is answered with the drain's own record at once, a
7632
+ // deterministic refusal the command surfaces as its reason, never a wait.
7633
+ const drain = await this.fleetDrain();
7634
+ if (drain !== null) {
7635
+ return {
7636
+ error: `op-refused: the resident fleet is drained for ${drain.reason} (asked by ${drain.by}, ends by ${drain.until}) — re-run the command when the fleet reopens`,
7637
+ status: 503,
7638
+ reason: "draining",
7639
+ cause: "system",
7640
+ };
7641
+ }
7470
7642
  try {
7471
7643
  await this.ensureHydrated();
7472
7644
  } catch (err) {
@@ -7940,6 +8112,7 @@ export class ResidentDO extends Sandbox<Env> {
7940
8112
  AUTO_REBUILDS_KEY,
7941
8113
  INFRA_STREAK_KEY,
7942
8114
  MEMORY_KEY,
8115
+ IMAGE_REPORT_PENDING_KEY,
7943
8116
  ]);
7944
8117
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
7945
8118
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -8017,6 +8190,11 @@ export class ResidentDO extends Sandbox<Env> {
8017
8190
  inFlight: this.inFlightCount() + registeredRuns,
8018
8191
  // The runs alone (no refresh cycle): what the deploy preflight refuses on.
8019
8192
  runsInFlight: this.runsInFlightCount() + registeredRuns,
8193
+ // Issue 2044: whether this container's new-image report is still owed —
8194
+ // `pending` names a resident a held drain waits on, so `repo list` and
8195
+ // the deploy's readers never print a bare "warm" for a repository whose
8196
+ // running container the last deploy could not verify.
8197
+ imageReport: map.get(IMAGE_REPORT_PENDING_KEY) !== undefined ? "pending" : "current",
8020
8198
  // Item 22: who holds what, as the rows say — the mirror mutex and the
8021
8199
  // cycle/hydration leases, each judged against this incarnation.
8022
8200
  incarnation: this.incarnation,
@@ -9118,32 +9296,47 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
9118
9296
  return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
9119
9297
  }
9120
9298
 
9121
- /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
9122
- * a drain stood. */
9299
+ /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says
9300
+ * whether a drain stood and was lifted. With containers still to report the
9301
+ * deploy's image (issue 1931) the fleet STAYS closed: `held` names them, the
9302
+ * record stands with the lift asked, and the last container's report reopens
9303
+ * the fleet — a fact, never a timer; the drain's `until` is the backstop. */
9123
9304
  async function handleUndrain(env: Env): Promise<Response> {
9124
- const cleared = await registryStub(env).clearDrain();
9125
- console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
9126
- return json({ draining: null, cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
9305
+ const lift = await registryStub(env).clearDrain();
9306
+ if (!lift.cleared && lift.held.length > 0) {
9307
+ console.log(`[drain] fleet stays closed — containers still to report the deploy's image: ${lift.held.join(", ")}`);
9308
+ const draining = liveDrain(await registryStub(env).getDrain(), systemClock());
9309
+ return json({ draining, cleared: false, held: lift.held, planeOutbox: await registryStub(env).getDrainOutbox() });
9310
+ }
9311
+ console.log(`[drain] fleet reopened (${lift.cleared ? "a drain stood" : "no drain stood"})`);
9312
+ return json({ draining: null, cleared: lift.cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
9127
9313
  }
9128
9314
 
9129
9315
  /** POST /reconcile (drain scope): reconcile every resident's container onto
9130
9316
  * the current image — the deploy runner posts it after its Worker deploy
9131
9317
  * landed and BEFORE its `/undrain`, so a stale container restarts inside the
9132
9318
  * drain window (item 69's order) and never under a run the reopened fleet
9133
- * admits. Each resident answers what its reconcile decided; a failing one
9134
- * degrades to `error` without touching its neighbors, and a `deferred` or
9135
- * failed one restarts on its own next quiet attach or refresh cycle. */
9319
+ * admits. Each resident answers what its reconcile decided and whether its
9320
+ * fresh container was VERIFIED on the deploy's image (issue 1931); one not
9321
+ * verified — deferred, a failed fresh probe, an error — leaves a hold on the
9322
+ * drain, so the fleet reopens only on that container's later report. */
9136
9323
  async function handleReconcile(env: Env): Promise<Response> {
9137
9324
  const residents = await registryStub(env).list();
9138
9325
  const settled = await Promise.allSettled(
9139
- residents.map((record) => residentStub(env, record.resource).reconcileForDeploy()),
9326
+ residents.map((record) => residentStub(env, record.resource).reconcileForDeploy(record.resource)),
9140
9327
  );
9141
9328
  const reconciled = residents.map((record, i) => {
9142
9329
  const s = settled[i];
9143
9330
  return s.status === "fulfilled"
9144
- ? { resource: record.resource, result: s.value.result }
9145
- : { resource: record.resource, result: "error" as const, error: errMsg(s.reason) };
9331
+ ? { resource: record.resource, result: s.value.result, verified: s.value.verified }
9332
+ : { resource: record.resource, result: "error" as const, verified: false, error: errMsg(s.reason) };
9146
9333
  });
9334
+ // Each resident held ITSELF before its marker (reconcileForDeploy), so no
9335
+ // report can outrun its hold; this pass is the backstop for a resident whose
9336
+ // call rejected before it could — holdDrain deduplicates, so re-holding a
9337
+ // deferred resident changes nothing.
9338
+ const unverified = reconciled.filter((r) => !r.verified).map((r) => r.resource);
9339
+ if (unverified.length > 0) await registryStub(env).holdDrainFor(unverified);
9147
9340
  console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
9148
9341
  return json({ reconciled });
9149
9342
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.255.0",
3
+ "version": "1.257.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.255.0",
9
+ "version": "1.257.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.255.0",
20448
+ "version": "1.257.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.255.0",
3
+ "version": "1.257.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -39,9 +39,9 @@
39
39
  "cli": "tsx src/cli.ts",
40
40
  "verify": "npm run verify:root && npm run verify --workspaces --if-present && npm run check:site",
41
41
  "verify:root": "npm run check:consistency && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:dist",
42
- "check:consistency": "npm run check:lockfile && npm run check:sandbox-pair && npm run skills:check && npm run check:deps-drift && npm run licenses:check && npm run docs:check && npm run pr-title:check && npm run specs:check && npm run decisions:check && npm run hygiene:check && npm run check:project-facts && npm run check:registry-drift && npm run agents:check && npm run clock:check && npm run screenshots:check",
42
+ "check:consistency": "npm run check:lockfile && npm run check:sandbox-pair && npm run skills:check && npm run check:deps-drift && npm run licenses:check && npm run docs:check && npm run pr-title:check && npm run specs:check && npm run decisions:check && npm run hygiene:check && npm run vocabulary:check && npm run check:project-facts && npm run check:registry-drift && npm run agents:check && npm run clock:check && npm run screenshots:check",
43
43
  "ci:gate": "node scripts/ci-gate.mjs",
44
- "fix": "npm run docs:gen && npm run pr-title:gen && npm run agents:gen && npm run clock:gen && npm run hygiene:gen && npm run deploy:gen && npm run skills:sync && npm run lint:fix && npm run format",
44
+ "fix": "npm run docs:gen && npm run pr-title:gen && npm run agents:gen && npm run clock:gen && npm run hygiene:gen && npm run vocabulary:gen && npm run deploy:gen && npm run skills:sync && npm run lint:fix && npm run format",
45
45
  "deploy:gen": "npm run --silent cli -- deploy init",
46
46
  "deploy:check": "npm run --silent cli -- deploy init --check",
47
47
  "check:lockfile": "node scripts/check-lockfile.mjs",
@@ -73,6 +73,8 @@
73
73
  "decisions:check": "tsx scripts/decisions-check.ts",
74
74
  "hygiene:check": "node scripts/public-hygiene.mjs",
75
75
  "hygiene:gen": "node scripts/public-hygiene.mjs --write",
76
+ "vocabulary:check": "node scripts/vocabulary-check.mjs",
77
+ "vocabulary:gen": "node scripts/vocabulary-check.mjs --write",
76
78
  "docs:changed": "node scripts/docs-changed.mjs",
77
79
  "deploy:targets": "node scripts/deploy-targets.mjs",
78
80
  "docs:dev": "npm run dev -w docs --",
@@ -32,7 +32,7 @@
32
32
  "commands": {
33
33
  "$comment": "What each root npm script does and when to run it — the source of the Commands table in AGENTS.md (npm run agents:gen). A script without an entry fails agents:check, so a new script cannot ship undocumented.",
34
34
  "verify": {
35
- "does": "The whole gate: every root check, every workspace's verify, the site check — exactly what CI runs.",
35
+ "does": "The whole gate: every check, every workspace — exactly what CI runs.",
36
36
  "when": "Local sessions, before requesting review. ~4 min. A pipeline agent leaves it to CI."
37
37
  },
38
38
  "verify:root": {
@@ -40,11 +40,11 @@
40
40
  "when": "When only the bot changed."
41
41
  },
42
42
  "check:consistency": {
43
- "does": "The sub-second checks that generated and declared things equal the code (lockfile, sandbox pairs, skills, licenses, docs tables, specs, project facts, this table).",
43
+ "does": "The sub-second checks that generated and declared things equal the code, this table included.",
44
44
  "when": "After touching a generated or declared artifact; one CI leg."
45
45
  },
46
46
  "test": {
47
- "does": "The whole vitest suite (bot, web, the plain-Node Worker tests) from one entry, after `deploy:gen`.",
47
+ "does": "The whole vitest suite from one entry, after `deploy:gen`.",
48
48
  "when": "Local sessions, before pushing; a pipeline agent runs the changed set — the full suite is CI's."
49
49
  },
50
50
  "typecheck": {
@@ -160,21 +160,29 @@
160
160
  "when": "After renaming a test or editing a spec; `-- --fix` makes truncated titles explicit."
161
161
  },
162
162
  "specs:coverage": {
163
- "does": "Maps a change's paths to the specs whose `Code`/`Tests` headers cover them, then lists changed source paths no spec covers.",
163
+ "does": "Maps a change's paths to the specs whose headers cover them, then lists paths no spec covers.",
164
164
  "when": "`-- --changed origin/main...HEAD [--test-guard]` before review; `-- --require` fails on an uncovered path; `-- --json` for machines."
165
165
  },
166
166
  "decisions:check": {
167
- "does": "Every record under `docs/decisions/` and `docs/plans/` has a valid `status`, a superseded one names its successor, an accepted body only gains an appended `## Amended`.",
168
- "when": "Part of `check:consistency`; a failing record is superseded or amended by appending, never edited."
167
+ "does": "Every record under `docs/decisions/` and `docs/plans/` has a valid `status`, a superseded one names its successor, an accepted body only gains `## Amended`.",
168
+ "when": "Part of `check:consistency`; a failing record is superseded or amended, never edited."
169
169
  },
170
170
  "hygiene:check": {
171
171
  "does": "The public tree's imprint (company, people, trackers, plan ids, ids, dates) equals the recorded list, which only shrinks.",
172
- "when": "Part of `check:consistency`. New hit: rewrite the line or allow it in `scripts/public-hygiene.allow`."
172
+ "when": "Part of `check:consistency`. New hit: rewrite the line or allow it by name."
173
173
  },
174
174
  "hygiene:gen": {
175
175
  "does": "Records the tree's remaining imprint after a scrub; refuses growth unless `-- --force`.",
176
176
  "when": "Part of `fix`; new imprint fails it like `hygiene:check`."
177
177
  },
178
+ "vocabulary:check": {
179
+ "does": "No internal word prints on a user surface; the baseline only shrinks.",
180
+ "when": "Part of `check:consistency`; a hit is rewritten in the user's nouns."
181
+ },
182
+ "vocabulary:gen": {
183
+ "does": "Records the remaining internal words; refuses growth unless `-- --force`.",
184
+ "when": "Part of `fix`."
185
+ },
178
186
  "agents:gen": {
179
187
  "does": "Writes the Commands table in AGENTS.md from `package.json` and this file.",
180
188
  "when": "After adding or changing a script; part of `fix`."
@@ -220,8 +228,8 @@
220
228
  "when": "After a `web/` change."
221
229
  },
222
230
  "screenshots:gen": {
223
- "does": "Renders the dashboard screenshots whose inputs changed, both themes, recording each surface's input hashes in `docs/public/screenshots/manifest/` (`--force`: all).",
224
- "when": "After a `web/` or fixture change, once `screenshots:check` names it; needs `npx playwright-core install chromium`, so not in `fix`."
231
+ "does": "Renders the dashboard screenshots whose inputs changed, both themes, recording input hashes in the manifest (`--force`: all).",
232
+ "when": "After a `web/` or fixture change; needs `npx playwright-core install chromium`, so not in `fix`."
225
233
  },
226
234
  "screenshots:check": {
227
235
  "does": "Each surface's inputs still hash to what its screenshots were rendered from — no browser.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.255.0",
3
- "commit": "b19caf8a347aca9f78503cac8525f3b3206ba2ff",
4
- "builtAt": "2026-09-20T02:32:25.817Z"
2
+ "version": "1.257.0",
3
+ "commit": "222646c5fe1a6686c238020f6571c69c3ab87e43",
4
+ "builtAt": "2026-09-20T15:27:11.679Z"
5
5
  }
@@ -254,6 +254,48 @@ Keep notes with the \`notes\` tool: one short document, replaced whole each time
254
254
  // classes are by duration, the project's own scripts and CI say which is which.
255
255
  export const CHECKS_BY_COST = `CHECKS BY COST — push before the expensive ones. Every check you might run has a cost class: seconds (a formatter or a linter on the files you touched, one test file, a docs, link or spec check, the typecheck of one package) or minutes (the whole test suite, a build, a dependency install, an end-to-end or full verification). Know a command's class before you run it — from the project's own scripts and CI configuration, from how long it took last time, or by the class above when you have nothing better. Prove each change with the cheapest check that can prove it, matched to the change's scope and scoped to the changed set — the tests nearest your change, the touched project's typecheck, the changed files' formatting, never the whole tree: a documentation change gets the documentation checks, one module gets its own tests, a shared type gets the typecheck. Every CI pipeline runs the tests, the types, the formatting and the full verification on your push, so you never run them again: you validate and fix your own change before pushing, at the changed-set scope. Passing the full test suite and the full typecheck is NOT part of your criteria: CI is that gate and the only place they run — on a shared machine they cost minutes that every other run pays for. As soon as the change exists and those checks pass, commit and push — the pushed branch is the deliverable, and an unpushed tree does not survive the run's end. Beyond the changed set, use judgement about what this change needs rather than a checklist, fixing forward with further commits and pushes. Never start an operation whose expected duration does not fit the time you have left minus what a commit, a push and the description need: push what there is and say plainly what is unverified instead. At the wind-down note, commit and push what compiles, say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS} The description's validation names exactly what ran; what did not run is CI's to gate, and you say so.`;
256
256
 
257
+ // Every coding prompt carries this verbatim, right after the checks-by-cost
258
+ // rule (docs/reference/specs/agent-coding.md item 13; issue 1796): the fast
259
+ // gates before every push, each the changed-set form with its command named,
260
+ // and the full verification named as CI's gate. The paragraph lived in the
261
+ // ship contract's first instruction alone, so every ask that was not a plan
262
+ // unit had to repeat it by hand or watch the run spend most of its budget on
263
+ // the whole suite or the full verification before its first push (issue 1909
264
+ // measured three such command shapes at 85–95 % of a child's life). One
265
+ // constant, one text: the three coding variants carry the same bytes, the
266
+ // contract's first instruction points here instead of re-stating it
267
+ // (src/core/ship/contract.ts), and no requester repeats it. Unlike the
268
+ // checks-by-cost rule above, this paragraph names its commands on purpose —
269
+ // children handed only the classes chose wrong in both directions — and a
270
+ // repository on another stack maps each gate by its class.
271
+ export const TOUCHED_TESTS_COMMAND = "`npx vitest run` on the test files you touched, by name,";
272
+
273
+ export const FAST_GATES_BEFORE_PUSH =
274
+ "THE FAST GATES, before every push — each scoped to the changed set, never the whole project: " +
275
+ `${TOUCHED_TESTS_COMMAND} once (never \`--changed\`, never a directory: on a moving base that is most of the suite), ` +
276
+ "`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
277
+ "`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
278
+ "then your judgement on what else this change needs, not a longer checklist. The full verification is " +
279
+ "CI's gate — `npm run verify` runs there on your push, never here: push a head early and let CI judge it, " +
280
+ "fixing forward with further commits and pushes.";
281
+
282
+ // Every coding prompt carries this verbatim, right after the fast gates
283
+ // (agent-coding item 13; record 0071 mechanism one, issue 1747): the rebase
284
+ // before every push, always and never configurable. Approved pull requests
285
+ // went stale behind sibling merges and each needed a hand-posted rebase; a
286
+ // rebase against a base the run just fetched, with the change's context still
287
+ // in its window, is the cheapest rebase the system will ever run, so it is
288
+ // unconditional. One constant, one text: the three coding variants carry the
289
+ // same bytes — a ship fix round's child runs these same prompts — and the ship
290
+ // contract's first instruction points here instead of re-stating it
291
+ // (src/core/ship/contract.ts).
292
+ export const REBASE_BEFORE_PUSH =
293
+ "REBASE BEFORE EVERY PUSH — always, not configurable. Immediately before each push: fetch your base branch, " +
294
+ "rebase your branch onto it, resolve any conflict with the context you already have (the repository's " +
295
+ "AGENTS.md says how a generated file is regenerated — regenerate it, never hand-merge it), re-run THE FAST " +
296
+ "GATES on the rebased tree, and only then push. Every head that reaches review is then current with its " +
297
+ "base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
298
+
257
299
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
258
300
 
259
301
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -276,6 +318,10 @@ Workflow for shipping a PR:
276
318
 
277
319
  ${CHECKS_BY_COST}
278
320
 
321
+ ${FAST_GATES_BEFORE_PUSH}
322
+
323
+ ${REBASE_BEFORE_PUSH}
324
+
279
325
  ${NEVER_MERGE}
280
326
 
281
327
  ${UNIT_CONTRACT}
@@ -323,6 +369,10 @@ Workflow for shipping a change:
323
369
 
324
370
  ${CHECKS_BY_COST}
325
371
 
372
+ ${FAST_GATES_BEFORE_PUSH}
373
+
374
+ ${REBASE_BEFORE_PUSH}
375
+
326
376
  ${NEVER_MERGE}
327
377
 
328
378
  ${UNIT_CONTRACT}
@@ -366,6 +416,10 @@ Workflow for shipping a change:
366
416
 
367
417
  ${CHECKS_BY_COST}
368
418
 
419
+ ${FAST_GATES_BEFORE_PUSH}
420
+
421
+ ${REBASE_BEFORE_PUSH}
422
+
369
423
  ${NEVER_MERGE}
370
424
 
371
425
  ${UNIT_CONTRACT}
@@ -558,7 +612,7 @@ Your tools work without a workspace: the GitHub tools — \`github_repos\` (the
558
612
 
559
613
  ${statusCardRule('"Read the issue and its thread", "Post the comment"')} A one-step answer needs no checklist; post one when the request has steps the person would wait on.
560
614
 
561
- You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web — and you cannot hand off, route, forward or start another run: never say you will hand off, route, forward or start anything, because you cannot start another agent's run. Other Switchboard agents can do those things, and a plain message reaches them by itself — but only a new top-level message in the channel reaches the router; a reply in this thread comes back to you. For a code change or a pull request, say what you found and that the change is not yours to make, and that asking for it in plain words in a new top-level message in the channel — not a reply in this thread — like "in acme/api: fix the failing login test" starts the agent that makes the change, opens the PR and loops review; the same for a PR review ("review <PR URL>") and a web-research question ("compare X and Y on the web"). Never hand back a command or an \`agent:…\` line for the person to type: describe the ask in their words. Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
615
+ You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web — and you cannot hand off, route, forward or start another run: never say you will hand off, route, forward or start anything, because you cannot start another agent's run. Other Switchboard agents can do those things, and a plain message reaches them by itself: Switchboard's door reads every message — a reply in this thread included — and starts the right agent from the person's own words. For a code change or a pull request, say what you found and that the change is not yours to make, and that asking for it in plain words — right here in the thread or anywhere in the channel, like "in acme/api: fix the failing login test" — starts the agent that makes the change, opens the PR and loops review; the same for a PR review ("review <PR URL>") and a web-research question ("compare X and Y on the web"). NEVER tell the person to post a new top-level message, re-post, re-ask elsewhere or retype their request — their words have already been said and the door reads thread replies; and never hand back a command or an \`agent:…\` line to type: describe the ask in their words. Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
562
616
 
563
617
  // The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
564
618
  // investigation — "run our CI locally and validate the claims", "how long does
@@ -13,6 +13,18 @@ import { NO_GRANTS, type Grants, type GrantSet } from "./types.js";
13
13
  /** Every grant, on every axis. What admins and the local CLI hold. */
14
14
  export const ALL_GRANTS: Grants = Object.freeze({ actions: "all", channels: "all", repos: "all" });
15
15
 
16
+ /** The plane's system actor (record 0064; record 0057 as amended): the run
17
+ * ledger object steers write-preset runs itself — the checkpoint steer and the
18
+ * held turn's reissue land as `run_inbox` rows with sender `plane` — under
19
+ * this one standing grant. Recorded here so the table stays the whole story:
20
+ * the write happens on the object, which holds no other authority. */
21
+ export const PLANE_ACTOR_ID = "plane";
22
+ export const PLANE_GRANTS: Grants = Object.freeze({
23
+ actions: new Set(["steer:write"]),
24
+ channels: "all",
25
+ repos: "all",
26
+ });
27
+
16
28
  /** The namespaces a native `grants` key may use (invariant 4). `cli:` is not
17
29
  * configurable (the local CLI always holds everything) and `agent:` actors
18
30
  * derive their grants from their principal, so neither is listed. */