@coreplane/switchboard 1.259.0 → 1.260.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/assets/config/config.example.yaml +17 -24
  2. package/dist/assets/deploy/cloudflare/worker.ts +17 -56
  3. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  7. package/dist/assets/deploy/profile.example.json +2 -1
  8. package/dist/assets/package-lock.json +3 -3
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +3 -5
  12. package/dist/assets/src/core/authz/policy.ts +5 -0
  13. package/dist/assets/src/core/budgets.ts +19 -6
  14. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  15. package/dist/assets/src/core/coordinator/driver.ts +395 -128
  16. package/dist/assets/src/core/memory/types.ts +5 -0
  17. package/dist/assets/src/core/pipelineStanding.ts +13 -4
  18. package/dist/assets/src/core/plane/decide.ts +53 -12
  19. package/dist/assets/src/core/provider.ts +5 -0
  20. package/dist/assets/src/core/runEvents.ts +22 -15
  21. package/dist/assets/src/core/runLedger/types.ts +3 -3
  22. package/dist/assets/src/core/runRecord.ts +64 -11
  23. package/dist/assets/src/core/ship/coordinator.ts +202 -53
  24. package/dist/assets/src/core/ship/renewal.ts +23 -8
  25. package/dist/assets/src/core/types.ts +11 -0
  26. package/dist/assets/src/deploy/liveGate.ts +8 -0
  27. package/dist/assets/src/deploy/profile.ts +8 -0
  28. package/dist/assets/src/deploy/restart.ts +49 -35
  29. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  30. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
  31. package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  33. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
  38. package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
  42. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
  44. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  45. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
  47. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
  48. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
  49. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  50. package/dist/cli.js +9136 -8197
  51. package/package.json +1 -1
  52. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  54. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
  55. package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
  56. package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
@@ -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" \
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
2
+ "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `restart.deployer` is the SWITCHBOARD_INGRESS_TOKENS subject the Worker itself allows to restart the bot, independent of runtime config so config recovery cannot lock itself out; `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
3
3
  "account": "00000000000000000000000000000000",
4
4
  "zone": "example.com",
5
5
  "workers": {
@@ -9,5 +9,6 @@
9
9
  "sandbox": { "script": "switchboard-sandbox", "hostname": "switchboard-sandbox.example.com" }
10
10
  },
11
11
  "configSource": "config/config.yaml",
12
+ "restart": { "deployer": "deployer" },
12
13
  "images": "registry"
13
14
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.259.0",
3
+ "version": "1.260.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.259.0",
9
+ "version": "1.260.1",
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.259.0",
20448
+ "version": "1.260.1",
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.259.0",
3
+ "version": "1.260.1",
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.259.0",
3
- "commit": "0b28f0fb4925d069547233cbae61f1d3187972d8",
4
- "builtAt": "2026-09-21T02:10:55.399Z"
2
+ "version": "1.260.1",
3
+ "commit": "c57cb7b614e8916b3fd0ab86c848a8398d8dc781",
4
+ "builtAt": "2026-09-21T18:18:08.788Z"
5
5
  }
@@ -47,11 +47,9 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
47
47
  export const IDENTITIES = ["none", "read", "write"] as const;
48
48
  export type Identity = (typeof IDENTITIES)[number];
49
49
 
50
- /** The model tiers a preset may run on (the one-door plan's tiers rule): the
51
- * `fast` tier is the router's own model (`routing.model`), everything else is
52
- * `strong`. Each preset declares its allowed set below (`AgentDef.tiers`);
53
- * a parent choosing a child's model at spawn is held to the child preset's
54
- * set, and escalation is a new run — a run's tier is fixed at dispatch. */
50
+ /** The model tiers a preset may run on. The retired classifier leaves no
51
+ * configured fast-tier ref, so current run models resolve strong; declarations
52
+ * remain explicit for model-card tiering and future configured catalogues. */
55
53
  export const MODEL_TIERS = ["fast", "strong"] as const;
56
54
  export type ModelTier = (typeof MODEL_TIERS)[number];
57
55
 
@@ -118,6 +118,11 @@ export const POLICY: readonly Rule[] = [
118
118
  // `pulls rebase` force-pushes the pipeline's branches and can start a paid
119
119
  // fix round (record 0071): a grant, never a chat baseline.
120
120
  { action: "pulls:write", resource: "command", when: [grant("pulls:write")] },
121
+ // `pulls merge|enqueue` (record 0070, criterion 4): a person's merge through
122
+ // the door — the grant, never a baseline, so a grant-less person has a
123
+ // thread that reads everything and merges nothing. The release pull
124
+ // request's handoff and the approval fences are the handler's own refusals.
125
+ { action: "merge:write", resource: "command", when: [grant("merge:write")] },
121
126
 
122
127
  // ── repos ────────────────────────────────────────────────────────────────
123
128
  { action: "repo:read", resource: "command", when: [grant("repo:read")] },
@@ -104,11 +104,18 @@ export const HARNESS_PROBE_WAIT_MS = 5 * MINUTE_MS;
104
104
  * `maxMinutes` so one nobody lifted is an hour and a half, not a day. A run
105
105
  * asked during a drain waits at its attach one `pollMs` at a time under its
106
106
  * own lease less `leaseReserveMs` (what the attach and the work after it
107
- * need), `waitMaxMs` with no lease to clip it. */
107
+ * need), `waitMaxMs` with no lease to clip it — unless a fallback stands
108
+ * behind the attach: `fallbackWaitMs` bounds the wait by the FALLBACK's own
109
+ * cost, never the deploy's (issue 2101: a run refused by the drain waited
110
+ * 18 minutes for the whole deploy, then did the job in a seeded sandbox that
111
+ * stands up in about two), so a drain refusal on a first attach falls to the
112
+ * seeded sandbox after at most a few minutes; a re-attach with no fallback —
113
+ * a resumed run, a mid-run recovery — keeps the lease's bound. */
108
114
  export const DRAIN = {
109
115
  pollMs: 30_000,
110
116
  waitMaxMs: 60 * MINUTE_MS,
111
117
  leaseReserveMs: 10 * MINUTE_MS,
118
+ fallbackWaitMs: 3 * MINUTE_MS,
112
119
  deployWaitMaxMs: 60 * MINUTE_MS,
113
120
  marginMinutes: 5,
114
121
  maxMinutes: 90,
@@ -218,11 +225,10 @@ export const MERGE_WAIT_ASK_MINUTES = 60;
218
225
  * request's line with the conflict named, never a retry loop. */
219
226
  export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
220
227
 
221
- /** The provider retry ladder (issue 1932): the backoff before each retry of a
222
- * transient model-call failure — a gateway 5xx, a stream cut before
223
- * `message_stop`, a gateway timeout. Three attempts with growing waits,
224
- * charged to the run's lease; the waits stay small next to the run's minutes
225
- * because the observed blips are edge transients of seconds. */
228
+ /** The provider retry ladder: the first backoffs for a transport-class
229
+ * model-call failure. The final rung repeats while the run's loop lease has
230
+ * time, so these are pacing intervals rather than a three-attempt terminal
231
+ * budget. */
226
232
  export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
227
233
 
228
234
  /** A hosted ship parent's deadline margin past the pipeline's wall clock
@@ -236,6 +242,13 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
236
242
  * and the pause before a busy spawn is asked again. */
237
243
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
238
244
 
245
+ /** How long a restarting close gives its replacement dispatch to claim the
246
+ * same run before a read with no successor treats the close as final
247
+ * (run-history item 47a). One whole coordinator wait chunk lets an ordinary
248
+ * replacement claim without shortening the parent's first wait; a successor
249
+ * found after this deadline still wins because read-record searches first. */
250
+ export const RESTART_CLAIM_GRACE_MS = minutesToMs(SHIP_WAIT.chunkMinutes);
251
+
239
252
  /** The plane's table (docs/decisions/0064): how long a finished run stays on
240
253
  * it, a reservation's window, and the default re-ask cadence — how often a
241
254
  * silent resident something waits on is probed (`plane.reaskMinutes`
@@ -111,12 +111,24 @@ export interface ThreadEventAttachment {
111
111
  mediaType: string;
112
112
  data: string;
113
113
  name?: string;
114
+ /** The accepted channel file's by-reference source, when one exists, so the
115
+ * coding child can stage it without moving the inline bytes through a
116
+ * second request. */
117
+ staged?: {
118
+ name: string;
119
+ size: number;
120
+ type: string;
121
+ url: string;
122
+ messageId: string;
123
+ workspaceIndex?: number;
124
+ };
114
125
  }
115
126
 
116
127
  export interface ThreadEvent {
117
128
  /** Assigned by the store's append, in arrival order, per unit. */
118
129
  seq: number;
119
- /** The channel's message id, when the platform gave one. */
130
+ /** The channel's message id, or another producer's stable event id. An
131
+ * append that repeats it on one unit returns the original row. */
120
132
  id?: string;
121
133
  /** The sender (platform-namespaced) and the display name the channel knew. */
122
134
  sender: string;
@@ -174,11 +186,24 @@ export function capThreadEvent<T extends Omit<ThreadEvent, "seq"> & { seq?: numb
174
186
  }
175
187
  }
176
188
 
189
+ const isThreadEventStagedFile = (v: unknown): v is NonNullable<ThreadEventAttachment["staged"]> =>
190
+ isObject(v) &&
191
+ typeof v.name === "string" &&
192
+ typeof v.size === "number" &&
193
+ Number.isInteger(v.size) &&
194
+ v.size > 0 &&
195
+ typeof v.type === "string" &&
196
+ typeof v.url === "string" &&
197
+ typeof v.messageId === "string" &&
198
+ (v.workspaceIndex === undefined ||
199
+ (typeof v.workspaceIndex === "number" && Number.isInteger(v.workspaceIndex) && v.workspaceIndex >= 0));
200
+
177
201
  const isThreadEventAttachment = (v: unknown): v is ThreadEventAttachment =>
178
202
  isObject(v) &&
179
203
  typeof v.mediaType === "string" &&
180
204
  typeof v.data === "string" &&
181
- (v.name === undefined || typeof v.name === "string");
205
+ (v.name === undefined || typeof v.name === "string") &&
206
+ (v.staged === undefined || isThreadEventStagedFile(v.staged));
182
207
 
183
208
  const isThreadEventMode = (v: unknown): v is ThreadEventMode => v === "steer" || v === "wake" || v === "interrupt";
184
209
 
@@ -399,6 +424,26 @@ export interface UnitIdle {
399
424
  wakes: number;
400
425
  }
401
426
 
427
+ /** The durable answer to one indexed idle wait. The wait id is the key on the
428
+ * unit row, so a reclaimed runner receives byte-for-byte the decision the
429
+ * first caller stored instead of spending a renewal or consuming an event
430
+ * twice. */
431
+ export type UnitWakeAnswer =
432
+ | {
433
+ kind: "segment";
434
+ index: number;
435
+ from?: string;
436
+ runId?: string;
437
+ spendUsd: number | null;
438
+ handoff?: Handoff;
439
+ texts: string[];
440
+ senders: string[];
441
+ leaseMs?: number;
442
+ }
443
+ | { kind: "answered"; reply: string }
444
+ | { kind: "stopped" }
445
+ | { kind: "expired" };
446
+
402
447
  /** What the severity gate caught on a round: the level in force and the gated findings as `id (severity)`. */
403
448
  export interface RoundGate {
404
449
  level: AddressSeverity;
@@ -456,14 +501,20 @@ export interface CoordinatorUnit {
456
501
  * (null once any run's cost is unknown), `handoff` that child's lists, and
457
502
  * `wakes` how many wakes this idle has answered — zero at the write. */
458
503
  idle?: UnitIdle;
504
+ /** Answers to indexed idle waits, keyed by the wait step's durable identity. */
505
+ wakes?: Record<string, UnitWakeAnswer>;
459
506
  /** The round boundaries the coordinator reported, oldest first (the `ship_round`
460
507
  * vocabulary). `gate` rides an approve the machine's severity check caught
461
508
  * carrying a finding at or above the level in force ([agent-ship](../../../docs/reference/specs/agent-ship.md)
462
509
  * item 9) — a mismatch to be seen, since the child's parser holds an approve
463
510
  * to the same level. */
464
511
  rounds: Array<{ index: number; agent: string; outcome: string; at: number; gate?: RoundGate }>;
465
- /** How the unit ended: the ending's kind and the thread's report, when it has. */
466
- ending?: { kind: string; report: string; at: number };
512
+ /** How the unit ended: the ending's kind and the thread's report, when it
513
+ * has. `cause` names the machine's reason behind a driver-posted kind;
514
+ * `step` and `round` locate that reason without parsing the report. For a
515
+ * `step_threw` failure the driver records all available fields before it
516
+ * rethrows (issue 2100); unit-start has no round yet. */
517
+ ending?: { kind: string; report: string; at: number; cause?: string; step?: string; round?: number };
467
518
  startedAt?: number;
468
519
  }
469
520
 
@@ -548,6 +599,27 @@ const isSegment = (v: unknown): boolean =>
548
599
  (v.runId === undefined || isText(v.runId)) &&
549
600
  typeof v.at === "number";
550
601
 
602
+ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
603
+ if (!isObject(v)) return false;
604
+ if (v.kind === "answered") return typeof v.reply === "string";
605
+ if (v.kind === "stopped" || v.kind === "expired") return true;
606
+ return (
607
+ v.kind === "segment" &&
608
+ typeof v.index === "number" &&
609
+ Number.isInteger(v.index) &&
610
+ v.index >= 1 &&
611
+ (v.from === undefined || isText(v.from)) &&
612
+ (v.runId === undefined || isText(v.runId)) &&
613
+ (v.spendUsd === null || isFinite(v.spendUsd)) &&
614
+ (v.handoff === undefined || isHandoffShape(v.handoff)) &&
615
+ Array.isArray(v.texts) &&
616
+ v.texts.every((text) => typeof text === "string") &&
617
+ Array.isArray(v.senders) &&
618
+ v.senders.every((sender) => isText(sender)) &&
619
+ (v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0))
620
+ );
621
+ };
622
+
551
623
  export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
552
624
  if (!isObject(v)) return false;
553
625
  const r = v;
@@ -562,6 +634,13 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
562
634
  if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
563
635
  if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
564
636
  if (r.idle !== undefined && !isUnitIdle(r.idle)) return false;
637
+ if (
638
+ r.wakes !== undefined &&
639
+ (!isObject(r.wakes) ||
640
+ Array.isArray(r.wakes) ||
641
+ !Object.entries(r.wakes).every(([waitId, answer]) => STEP_NAME_PATTERN.test(waitId) && isUnitWakeAnswer(answer)))
642
+ )
643
+ return false;
565
644
  if (
566
645
  !Array.isArray(r.rounds) ||
567
646
  r.rounds.length > MAX_ROUNDS ||
@@ -578,7 +657,16 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
578
657
  return false;
579
658
  if (
580
659
  r.ending !== undefined &&
581
- !(isObject(r.ending) && isText(r.ending.kind) && isText(r.ending.report, MAX_REPORT) && isFinite(r.ending.at))
660
+ !(
661
+ isObject(r.ending) &&
662
+ isText(r.ending.kind) &&
663
+ isText(r.ending.report, MAX_REPORT) &&
664
+ isFinite(r.ending.at) &&
665
+ (r.ending.cause === undefined || isText(r.ending.cause, 64)) &&
666
+ (r.ending.step === undefined || (typeof r.ending.step === "string" && STEP_NAME_PATTERN.test(r.ending.step))) &&
667
+ (r.ending.round === undefined ||
668
+ (typeof r.ending.round === "number" && Number.isInteger(r.ending.round) && r.ending.round >= 0))
669
+ )
582
670
  )
583
671
  return false;
584
672
  if (r.startedAt !== undefined && !isFinite(r.startedAt)) return false;