@coreplane/switchboard 1.255.0 → 1.256.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +177 -32
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/agents/registry.ts +31 -0
  7. package/dist/assets/src/core/coordinator/driver.ts +11 -1
  8. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  9. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  10. package/dist/assets/src/core/ship/contract.ts +20 -30
  11. package/dist/assets/src/core/ship/coordinator.ts +195 -68
  12. package/dist/assets/web/dist/.vite/manifest.json +31 -31
  13. package/dist/assets/web/dist/assets/{HomePage-Be7jLLnU.js → HomePage-BEiStFwW.js} +1 -1
  14. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  15. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  16. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  17. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-DW-HHuZa.js → RunsIndexPage-JqVSDOok.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-Viim-bus.js} +1 -1
  20. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-CYUy8McC.js} +1 -1
  21. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-Dv6UMaPy.js} +1 -1
  22. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-Bd-5Rypv.js} +1 -1
  23. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  24. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-Nl3uaxrP.js} +1 -1
  25. package/dist/assets/web/dist/assets/{main-mAKx_zo9.js → main-DRxWlffc.js} +2 -2
  26. package/dist/cli.js +319 -64
  27. package/package.json +1 -1
  28. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  29. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  30. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
@@ -23,6 +23,17 @@ export interface DrainRecord {
23
23
  by: string;
24
24
  /** Why, in the words the attach refusal repeats. */
25
25
  reason: string;
26
+ /** The residents whose containers still run the pre-deploy image (issue
27
+ * 1931): the deploy's reconcile marks the Durable Objects but the platform
28
+ * replaces the container processes asynchronously, so "reconciled" is not
29
+ * "swapped" — each hold stands until that resident reports its running
30
+ * container on the deploy's image, and the fleet reopens on the LAST
31
+ * report, a fact, never a wait. Absent or empty: no swap outstanding. */
32
+ holds?: string[];
33
+ /** Whether `POST /undrain` already asked for the lift while holds stood:
34
+ * the record then clears itself on the last hold's report instead of
35
+ * waiting for a second lift. */
36
+ liftAsked?: boolean;
26
37
  }
27
38
 
28
39
  /** The longest a drain may run, and the default, from the one clock table
@@ -88,7 +99,50 @@ export function liveDrain(stored: unknown, now: number): DrainRecord | null {
88
99
  return null;
89
100
  const until = Date.parse(r.until);
90
101
  if (!Number.isFinite(until) || until <= now) return null;
91
- return { since: r.since, until: r.until, by: r.by, reason: r.reason };
102
+ const holds = Array.isArray(r.holds) ? r.holds.filter((h): h is string => typeof h === "string") : [];
103
+ return {
104
+ since: r.since,
105
+ until: r.until,
106
+ by: r.by,
107
+ reason: r.reason,
108
+ ...(holds.length > 0 ? { holds } : {}),
109
+ ...(r.liftAsked === true ? { liftAsked: true } : {}),
110
+ };
111
+ }
112
+
113
+ /** The record with the named residents held (issue 1931): the deploy's
114
+ * reconcile could not verify their running containers on the new image, so
115
+ * the fleet must not reopen onto them until each reports. Deduplicated;
116
+ * an empty set changes nothing. */
117
+ export function holdDrain(record: DrainRecord, resources: readonly string[]): DrainRecord {
118
+ const holds = [...new Set([...(record.holds ?? []), ...resources])];
119
+ if (holds.length === 0) return record;
120
+ return { ...record, holds };
121
+ }
122
+
123
+ /** `POST /undrain`'s decision over the stored record: with no holds the drain
124
+ * clears; with holds outstanding the fleet STAYS closed — the record keeps
125
+ * standing with `liftAsked`, so the last container's new-image report lifts
126
+ * it (a fact, never a timer; `until` remains the backstop for a report that
127
+ * never comes). */
128
+ export function liftDrain(record: DrainRecord): { cleared: true } | { cleared: false; record: DrainRecord } {
129
+ const holds = record.holds ?? [];
130
+ if (holds.length === 0) return { cleared: true };
131
+ return { cleared: false, record: { ...record, liftAsked: true } };
132
+ }
133
+
134
+ /** One resident's word that its running container is on the deploy's image:
135
+ * its hold drops; when it was the last hold and the lift was already asked,
136
+ * the drain lifts here — the reopen fires on the last container's report. */
137
+ export function reportImageCurrent(
138
+ record: DrainRecord,
139
+ resource: string,
140
+ ): { lifted: boolean; record: DrainRecord | null } {
141
+ const holds = (record.holds ?? []).filter((h) => h !== resource);
142
+ const next: DrainRecord = { ...record, ...(holds.length > 0 ? { holds } : {}) };
143
+ if (holds.length === 0) delete next.holds;
144
+ if (holds.length === 0 && record.liftAsked === true) return { lifted: true, record: null };
145
+ return { lifted: false, record: next };
92
146
  }
93
147
 
94
148
  /** The `/attach` answer while the fleet is drained: a 503 whose body carries
@@ -343,7 +343,15 @@ 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
+ type DrainRecord,
354
+ } from "./drain";
347
355
  import {
348
356
  CGROUP_READ_ARGV,
349
357
  MEMORY_PRESSURE_REASON,
@@ -1434,6 +1442,10 @@ const TEST_OVERRIDES_KEY = "testOverrides";
1434
1442
  * counts as a slot; it survives the isolate swap a deploy performs, which is
1435
1443
  * why the record carries its own end. */
1436
1444
  const DRAIN_KEY = "drain";
1445
+ /** Resident-DO key (issue 1931): set when a deploy's reconcile could not
1446
+ * verify this resident's container on the new image; the next reconcile that
1447
+ * finds it current reports to the registry and clears it. */
1448
+ const IMAGE_REPORT_PENDING_KEY = "imageReportPending";
1437
1449
 
1438
1450
  type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
1439
1451
 
@@ -1568,12 +1580,50 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1568
1580
  return record;
1569
1581
  }
1570
1582
 
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> {
1583
+ /** Admin-only by construction (POST /undrain): `cleared` when a record was
1584
+ * there and was lifted — a cleared drain posts `below`, the plane's
1585
+ * resident-drain window lifts. With holds outstanding (issue 1931: a
1586
+ * container still to report the deploy's image) the fleet STAYS closed:
1587
+ * the record stands with `liftAsked` and the last container's report lifts
1588
+ * it (`reportContainerImageCurrent`); `until` remains the backstop. */
1589
+ async clearDrain(): Promise<{ cleared: boolean; held: string[] }> {
1590
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1591
+ if (record !== null) {
1592
+ const lift = liftDrain(record);
1593
+ if (!lift.cleared) {
1594
+ await this.ctx.storage.put(DRAIN_KEY, lift.record);
1595
+ return { cleared: false, held: lift.record.holds ?? [] };
1596
+ }
1597
+ }
1574
1598
  const had = await this.ctx.storage.delete(DRAIN_KEY);
1575
1599
  if (had) await this.pushDrainPost("below");
1576
- return had;
1600
+ return { cleared: had, held: [] };
1601
+ }
1602
+
1603
+ /** The deploy's reconcile could not verify these residents' containers on
1604
+ * the new image (issue 1931): hold the drain for each — the fleet must not
1605
+ * reopen onto them until they report. No live drain, nothing to hold. */
1606
+ async holdDrainFor(resources: string[]): Promise<void> {
1607
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1608
+ if (record === null || resources.length === 0) return;
1609
+ await this.ctx.storage.put(DRAIN_KEY, holdDrain(record, resources));
1610
+ }
1611
+
1612
+ /** One resident's word that its running container is on the deploy's image:
1613
+ * its hold drops, and when it was the last hold of a lift already asked the
1614
+ * drain lifts here — the reopen fires on the last container's report. */
1615
+ async reportContainerImageCurrent(resource: string): Promise<{ lifted: boolean }> {
1616
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1617
+ if (record === null) return { lifted: false };
1618
+ const report = reportImageCurrent(record, resource);
1619
+ if (report.record === null) {
1620
+ await this.ctx.storage.delete(DRAIN_KEY);
1621
+ await this.pushDrainPost("below");
1622
+ console.log(`[drain] fleet reopened — ${resource} was the last container to report the deploy's image`);
1623
+ return { lifted: true };
1624
+ }
1625
+ await this.ctx.storage.put(DRAIN_KEY, report.record);
1626
+ return { lifted: false };
1577
1627
  }
1578
1628
 
1579
1629
  /** The one alarm, at the drain's `until` (record 0064): a drain past
@@ -4936,41 +4986,121 @@ export class ResidentDO extends Sandbox<Env> {
4936
4986
  * registration whose release never came defers it only until the clean-idle
4937
4987
  * sweep drains that registration. Answers `restarted` when a stop was
4938
4988
  * issued, else why not. */
4939
- private async reconcileImage(where: string): Promise<ImageReconcileResult> {
4940
- if (!(await this.isRuntimeActive().catch(() => false))) return "inactive";
4989
+ private async reconcileImage(where: string, force = false): Promise<ImageReconcileResult> {
4990
+ if (!(await this.isRuntimeActive().catch(() => false))) {
4991
+ // An inactive container's next start is on the deployed image by
4992
+ // construction — and a held resident whose container idled out has no
4993
+ // other reporter left: new attaches are refused by the very drain the
4994
+ // hold keeps standing, so without this report the fleet stays closed
4995
+ // until the drain's `until` backstop (issue 1931).
4996
+ await this.reportPendingImageCurrent(where);
4997
+ return "inactive";
4998
+ }
4941
4999
  const last = THREAD_USERS[THREAD_USERS.length - 1];
4942
- const probe = await this.run(["id", "-u", last]);
4943
- if (probe.exitCode === 0) return "current";
5000
+ // A pending report marker (issue 1931) means a deploy could not verify this
5001
+ // container on its image: the probe shortcut below is a POOL-USER check the
5002
+ // pre-deploy image passes when the pool did not change, so it can never
5003
+ // satisfy the marker — the container is treated as stale until it is cycled
5004
+ // post-deploy and the fresh start reported.
5005
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5006
+ if (!force && pending === undefined) {
5007
+ const probe = await this.run(["id", "-u", last]);
5008
+ if (probe.exitCode === 0) return "current";
5009
+ }
5010
+ const stale = force
5011
+ ? "the container predates the deploy"
5012
+ : pending !== undefined
5013
+ ? "a deploy's new-image report is pending, so the running container cannot be trusted current"
5014
+ : `${last} missing in the running container`;
4944
5015
  const busy = this.inFlightCount();
4945
5016
  if (busy > 0) {
4946
- console.log(
4947
- `image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
4948
- );
5017
+ console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
4949
5018
  return "deferred";
4950
5019
  }
4951
5020
  const registered = await this.registeredRunsBeyondOps();
4952
5021
  if (registered > 0) {
4953
5022
  console.log(
4954
- `image-stale (${where}): ${last} missing but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
5023
+ `image-stale (${where}): ${stale} but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
4955
5024
  );
4956
5025
  return "deferred";
4957
5026
  }
4958
- console.log(
4959
- `image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
4960
- );
5027
+ console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
4961
5028
  this.swapIncarnation(); // deliberate incarnation swap
4962
5029
  await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
5030
+ if (pending !== undefined) {
5031
+ // The post-deploy cycle just happened: probe the FRESH container — the
5032
+ // SDK boots it for this command on the image the deployed Worker pins,
5033
+ // so a fresh start answering after the deploy is the verified fact
5034
+ // (issue 1931), never the pool-user probe on the old process. A failed
5035
+ // probe keeps the marker: the next reconcile tries the cycle again.
5036
+ const fresh = await this.run(["id", "-u", last])
5037
+ .then((p) => p.exitCode === 0)
5038
+ .catch(() => false);
5039
+ if (fresh) await this.reportPendingImageCurrent(where);
5040
+ else
5041
+ console.log(`image-stale (${where}): the fresh container's probe failed — the new-image report stays pending`);
5042
+ }
4963
5043
  return "restarted";
4964
5044
  }
4965
5045
 
5046
+ /** The report a held drain waits for (issue 1931): when the deploy's
5047
+ * reconcile could not verify this resident's fresh container on the new
5048
+ * image, a marker stays in storage and the registry holds the drain. The
5049
+ * fact the report stands on is a fresh container start AFTER the deploy —
5050
+ * the marker makes `reconcileImage` treat the container as stale until the
5051
+ * cycle happens, so this is called only behind that fresh start (or on an
5052
+ * inactive resident, whose next start is on the new image by construction).
5053
+ * The marker is deleted only after a successful report: a transient failure
5054
+ * keeps it for the next reconcile, and the drain's `until` is the backstop
5055
+ * for a report that never lands. */
5056
+ private async reportPendingImageCurrent(where: string): Promise<boolean> {
5057
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5058
+ if (pending === undefined) return true;
5059
+ try {
5060
+ const { lifted } = await registryStub(this.env).reportContainerImageCurrent(pending.resource);
5061
+ await this.ctx.storage.delete(IMAGE_REPORT_PENDING_KEY);
5062
+ console.log(
5063
+ `[reconcile] ${pending.resource} reports the deploy's image (${where})${lifted ? " — drain lifted" : ""}`,
5064
+ );
5065
+ return true;
5066
+ } catch (err) {
5067
+ console.log(
5068
+ `[reconcile] ${pending.resource}: the new-image report failed (${where}): ${errMsg(err)} — retried on the next reconcile`,
5069
+ );
5070
+ return false;
5071
+ }
5072
+ }
5073
+
4966
5074
  /** The deploy's reconcile, inside the drain window (item 69's order: the
4967
5075
  * runs in flight end, the swap lands, the containers reconcile, the fleet
4968
5076
  * 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") };
5077
+ * deploy landed and BEFORE the drain is lifted. The pool-user probe cannot
5078
+ * tell the pre-deploy image from the new one when the pool did not change,
5079
+ * and the platform replaces the container processes asynchronously after an
5080
+ * image-changing deploy (issue 1931) — so this path never trusts "current":
5081
+ * an active, quiet container is always cycled, and the fresh start —
5082
+ * necessarily on the deploy's image, which landed before this call — is
5083
+ * probed. `verified` true is the fact the reopen may stand on; anything
5084
+ * else (deferred, a failed fresh probe) leaves a hold on the drain, cleared
5085
+ * by this resident's later report (`reportPendingImageCurrent`), never by a
5086
+ * timer.
5087
+ *
5088
+ * The hold lands BEFORE the marker: the marker is what lets any concurrent
5089
+ * attach/refresh reconcile report, and a report that reaches the registry
5090
+ * before the hold is a no-op — the marker would be consumed and the hold
5091
+ * added afterwards would wait for a report nothing sends (until the drain's
5092
+ * `until`). Held first, every report finds its hold. */
5093
+ async reconcileForDeploy(resource: string): Promise<{ result: ImageReconcileResult; verified: boolean }> {
5094
+ await registryStub(this.env).holdDrainFor([resource]);
5095
+ await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
5096
+ const result = await this.reconcileImage("deploy", true);
5097
+ if (result === "deferred") return { result, verified: false };
5098
+ // Inactive: the inactive early-return reported (the next start is on the
5099
+ // new image by construction). Restarted: the cycle's fresh probe and
5100
+ // report ran inside reconcileImage. Either way the marker gone is the
5101
+ // verification.
5102
+ const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
5103
+ return { result, verified };
4974
5104
  }
4975
5105
 
4976
5106
  // -- watchdog (the sparse cron; it re-arms nothing) --------------------------
@@ -9118,32 +9248,47 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
9118
9248
  return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
9119
9249
  }
9120
9250
 
9121
- /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
9122
- * a drain stood. */
9251
+ /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says
9252
+ * whether a drain stood and was lifted. With containers still to report the
9253
+ * deploy's image (issue 1931) the fleet STAYS closed: `held` names them, the
9254
+ * record stands with the lift asked, and the last container's report reopens
9255
+ * the fleet — a fact, never a timer; the drain's `until` is the backstop. */
9123
9256
  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() });
9257
+ const lift = await registryStub(env).clearDrain();
9258
+ if (!lift.cleared && lift.held.length > 0) {
9259
+ console.log(`[drain] fleet stays closed — containers still to report the deploy's image: ${lift.held.join(", ")}`);
9260
+ const draining = liveDrain(await registryStub(env).getDrain(), systemClock());
9261
+ return json({ draining, cleared: false, held: lift.held, planeOutbox: await registryStub(env).getDrainOutbox() });
9262
+ }
9263
+ console.log(`[drain] fleet reopened (${lift.cleared ? "a drain stood" : "no drain stood"})`);
9264
+ return json({ draining: null, cleared: lift.cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
9127
9265
  }
9128
9266
 
9129
9267
  /** POST /reconcile (drain scope): reconcile every resident's container onto
9130
9268
  * the current image — the deploy runner posts it after its Worker deploy
9131
9269
  * landed and BEFORE its `/undrain`, so a stale container restarts inside the
9132
9270
  * 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. */
9271
+ * admits. Each resident answers what its reconcile decided and whether its
9272
+ * fresh container was VERIFIED on the deploy's image (issue 1931); one not
9273
+ * verified — deferred, a failed fresh probe, an error — leaves a hold on the
9274
+ * drain, so the fleet reopens only on that container's later report. */
9136
9275
  async function handleReconcile(env: Env): Promise<Response> {
9137
9276
  const residents = await registryStub(env).list();
9138
9277
  const settled = await Promise.allSettled(
9139
- residents.map((record) => residentStub(env, record.resource).reconcileForDeploy()),
9278
+ residents.map((record) => residentStub(env, record.resource).reconcileForDeploy(record.resource)),
9140
9279
  );
9141
9280
  const reconciled = residents.map((record, i) => {
9142
9281
  const s = settled[i];
9143
9282
  return s.status === "fulfilled"
9144
- ? { resource: record.resource, result: s.value.result }
9145
- : { resource: record.resource, result: "error" as const, error: errMsg(s.reason) };
9283
+ ? { resource: record.resource, result: s.value.result, verified: s.value.verified }
9284
+ : { resource: record.resource, result: "error" as const, verified: false, error: errMsg(s.reason) };
9146
9285
  });
9286
+ // Each resident held ITSELF before its marker (reconcileForDeploy), so no
9287
+ // report can outrun its hold; this pass is the backstop for a resident whose
9288
+ // call rejected before it could — holdDrain deduplicates, so re-holding a
9289
+ // deferred resident changes nothing.
9290
+ const unverified = reconciled.filter((r) => !r.verified).map((r) => r.resource);
9291
+ if (unverified.length > 0) await registryStub(env).holdDrainFor(unverified);
9147
9292
  console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
9148
9293
  return json({ reconciled });
9149
9294
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.255.0",
3
+ "version": "1.256.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.256.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.256.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.256.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",
@@ -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.256.0",
3
+ "commit": "fd814ee9ad2421f38178b15103d49ff4d9c5eb7c",
4
+ "builtAt": "2026-09-20T04:37:22.536Z"
5
5
  }
@@ -254,6 +254,31 @@ 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
+
257
282
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
258
283
 
259
284
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -276,6 +301,8 @@ Workflow for shipping a PR:
276
301
 
277
302
  ${CHECKS_BY_COST}
278
303
 
304
+ ${FAST_GATES_BEFORE_PUSH}
305
+
279
306
  ${NEVER_MERGE}
280
307
 
281
308
  ${UNIT_CONTRACT}
@@ -323,6 +350,8 @@ Workflow for shipping a change:
323
350
 
324
351
  ${CHECKS_BY_COST}
325
352
 
353
+ ${FAST_GATES_BEFORE_PUSH}
354
+
326
355
  ${NEVER_MERGE}
327
356
 
328
357
  ${UNIT_CONTRACT}
@@ -366,6 +395,8 @@ Workflow for shipping a change:
366
395
 
367
396
  ${CHECKS_BY_COST}
368
397
 
398
+ ${FAST_GATES_BEFORE_PUSH}
399
+
369
400
  ${NEVER_MERGE}
370
401
 
371
402
  ${UNIT_CONTRACT}
@@ -316,7 +316,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
316
316
  // The hard stop's mark, as the bot's answer carries it (record 0060; issue
317
317
  // 1924): a finished child's unit ends stopped on it.
318
318
  const stopped = a.body.stopped === true ? { stopped: true as const } : {};
319
- if (!run.finished) return { type: "read-record", step, run: { finished: false }, ...stopped, at: a.body.at };
319
+ // The interrupted child restarted from its request (issue 1903): the bot
320
+ // answers the live successor's id, and the machine keeps the wait on it.
321
+ const restarted = typeof a.body.restartedAs === "string" ? { restartedAs: a.body.restartedAs } : {};
322
+ if (!run.finished)
323
+ return { type: "read-record", step, run: { finished: false }, ...stopped, ...restarted, at: a.body.at };
320
324
  if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
321
325
  // The typed artifacts as the bot's record carries them — shape-checked where
322
326
  // they were written (the run record's validator), read here as they are.
@@ -337,6 +341,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
337
341
  costUsd,
338
342
  handoffLists,
339
343
  failure,
344
+ interruption,
340
345
  } = facts;
341
346
  return {
342
347
  type: "read-record",
@@ -363,6 +368,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
363
368
  // The failure by name (run-history item 57), shape-checked: a
364
369
  // `provider_transient` drives the round-0 re-run (agent-ship item 9).
365
370
  ...(isRecord(failure) && typeof failure.kind === "string" ? { failure: { kind: failure.kind } } : {}),
371
+ // What ended an interrupted child (issue 1876): the ending's sentence
372
+ // names the cause instead of claiming a bot restart for every one.
373
+ ...(interruption === "bot_restart" || interruption === "container_replaced" || interruption === "sandbox_fault"
374
+ ? { interruption }
375
+ : {}),
366
376
  },
367
377
  at: a.body.at,
368
378
  };
@@ -130,6 +130,63 @@ export const ENDING_STAGE = {
130
130
  blocked: "ended",
131
131
  } as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
132
132
 
133
+ /** The user's words for a unit's ending (record 0066): what a card, a plan
134
+ * summary and the web unit page print instead of the internal token —
135
+ * `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
136
+ * never the snake_case kind. Pinned to the union plus `blocked` (the driver's
137
+ * never-started status), so a new ending kind fails the build here. */
138
+ export const ENDING_WORDS = {
139
+ merged: "merged",
140
+ already_landed: "merged",
141
+ merge_ready: "merge-ready",
142
+ held: "held",
143
+ merge_refused: "not merged",
144
+ round_cap: "round cap reached",
145
+ wall_clock_cap: "out of budget",
146
+ review_pending: "review pending",
147
+ stopped: "stopped",
148
+ aborted: "aborted",
149
+ transient: "aborted",
150
+ continued: "continuing",
151
+ no_verdict: "no verdict",
152
+ interrupted: "interrupted",
153
+ refused: "refused",
154
+ idle: "idle",
155
+ blocked: "blocked",
156
+ } as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
157
+
158
+ /** The user's words for a round outcome (record 0066): the card's unit line
159
+ * prints these beside the round header, never `checks_failed` or another
160
+ * internal token. Pinned to the union: a new outcome fails the build here. */
161
+ export const ROUND_OUTCOME_WORDS = {
162
+ started: "started",
163
+ pr_opened: "pull request opened",
164
+ completed: "done",
165
+ approve: "approved",
166
+ request_changes: "changes requested",
167
+ no_verdict: "no verdict",
168
+ checks_failed: "checks failed",
169
+ transient: "retried",
170
+ enqueued: "queued to merge",
171
+ dequeued: "removed from the merge queue",
172
+ aborted: "aborted",
173
+ stopped: "stopped",
174
+ continued: "continuing",
175
+ idle: "idle",
176
+ } as const satisfies Record<ShipRoundOutcome, string>;
177
+
178
+ /** The user's word for any ending or unit-status token — total over strings
179
+ * read off a stored row, so an unknown word prints as itself rather than
180
+ * throwing on an older record. */
181
+ export function endingWordOf(kind: string): string {
182
+ return (ENDING_WORDS as Record<string, string>)[kind] ?? kind;
183
+ }
184
+
185
+ /** The user's word for any round-outcome token, total like `endingWordOf`. */
186
+ export function roundOutcomeWordOf(outcome: string): string {
187
+ return (ROUND_OUTCOME_WORDS as Record<string, string>)[outcome] ?? outcome;
188
+ }
189
+
133
190
  type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
134
191
 
135
192
  function isShipEvent(e: RunEvent): e is ShipEvent {
@@ -33,6 +33,7 @@
33
33
 
34
34
  import { normalizeHead } from "./reviewedHead.js";
35
35
  import { redactSecrets } from "./redact.js";
36
+ import { shows, type Verbosity } from "./verbosity.js";
36
37
 
37
38
  export type ReviewVerdictKind = "approve" | "request_changes";
38
39
 
@@ -248,6 +249,37 @@ export function verdictLine(verdict: ReviewVerdict | undefined): string {
248
249
  return summary ? `${token} ${summary}` : token;
249
250
  }
250
251
 
252
+ /** The counted plural for a severity: `blocker` and `nit` inflect, `major`
253
+ * and `minor` read as adjectives and stay uninflected. */
254
+ function severityCount(sev: FindingSeverity, n: number): string {
255
+ if (sev === "blocking") return `${n} blocker${n === 1 ? "" : "s"}`;
256
+ if (sev === "nit") return `${n} nit${n === 1 ? "" : "s"}`;
257
+ return `${n} ${sev}`;
258
+ }
259
+
260
+ /** The verdict as ONE line in the user's words (record 0066): `LGTM` for an
261
+ * approve, `Changes requested: 2 blockers, 1 major, 2 minor, 3 nits` — only
262
+ * the non-zero counts, most severe first — for a request for changes, the
263
+ * bare token word when the findings were not itemized or none were filed.
264
+ * The quiet thread reply prints this and nothing more; the full verdict line
265
+ * and the finding bullets are `verbose` material and stay on the pull
266
+ * request, where the post-step put them. */
267
+ export function verdictCountsLine(verdict: ReviewVerdict): string {
268
+ if (verdict.verdict === "approve") return "LGTM";
269
+ const counts = severityCounts(verdict.findings ?? []);
270
+ return counts ? `Changes requested: ${counts}` : "Changes requested";
271
+ }
272
+
273
+ /** The non-zero severity counts of a finding list, most severe first —
274
+ * `2 blockers, 1 major, 2 minor, 3 nits` — or the empty string for none.
275
+ * Shared by the quiet verdict line and the quiet round-cap report. */
276
+ export function severityCounts(findings: readonly Finding[]): string {
277
+ return FINDING_SEVERITIES.map((sev) => [sev, findings.filter((f) => f.severity === sev).length] as const)
278
+ .filter(([, n]) => n > 0)
279
+ .map(([sev, n]) => severityCount(sev, n))
280
+ .join(", ");
281
+ }
282
+
251
283
  /** One compact finding line — `[severity] id file[:line] — title` — shared by
252
284
  * the posted body's list (bulleted below) and ship's synthesized child turns. */
253
285
  export function formatFinding(f: Finding): string {
@@ -381,6 +413,13 @@ export function buildReviewPostBody(
381
413
  * opt-out, a guard refusal) or findings not itemized (a list that says
382
414
  * nothing is no substitute) — so the review's text is always somewhere a
383
415
  * person reads it. No verdict → the bare answer with the link, as before.
416
+ *
417
+ * The request's verbosity decides how much of the verdict the thread hears
418
+ * (routing-and-config item 28, record 0066): below `verbose`, a verdict whose
419
+ * findings are itemized and posted to GitHub is ONE line — `verdictCountsLine`
420
+ * with the pull request link — because the full verdict, the finding lines and
421
+ * the prose already stand on the pull request; at `verbose`, and whenever the
422
+ * text would otherwise land nowhere a person reads it, the full render above.
384
423
  */
385
424
  export function buildReviewChannelReply(input: {
386
425
  answer: string;
@@ -388,8 +427,18 @@ export function buildReviewChannelReply(input: {
388
427
  /** The PR the post landed on, or undefined when nothing was posted. */
389
428
  posted: { repo: string; number: number } | undefined;
390
429
  liveUrl: string | undefined;
430
+ /** The request's level (default `verbose`: the full render, the row's shape). */
431
+ verbosity?: Verbosity;
391
432
  }): string {
392
433
  const { answer, verdict, posted, liveUrl } = input;
434
+ if (
435
+ !shows(input.verbosity ?? "verbose", "verbose") &&
436
+ verdict !== undefined &&
437
+ verdict.findings !== undefined &&
438
+ posted !== undefined
439
+ ) {
440
+ return `${verdictCountsLine(verdict)} — https://github.com/${posted.repo}/pull/${posted.number}`;
441
+ }
393
442
  const tail = [
394
443
  ...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
395
444
  ...(liveUrl ? [`[Live run](${liveUrl})`] : []),