@coreplane/switchboard 1.256.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 (58) 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 +49 -4
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +52 -4
  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 +24 -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 +6 -3
  15. package/dist/assets/src/core/delivery.ts +8 -5
  16. package/dist/assets/src/core/plane/decide.ts +468 -12
  17. package/dist/assets/src/core/plane/findings.ts +120 -0
  18. package/dist/assets/src/core/runEvents.ts +41 -14
  19. package/dist/assets/src/core/runFriction.ts +16 -0
  20. package/dist/assets/src/core/runLedger/types.ts +16 -3
  21. package/dist/assets/src/core/runRecord.ts +12 -0
  22. package/dist/assets/src/core/ship/contract.ts +14 -2
  23. package/dist/assets/src/core/ship/coordinator.ts +98 -29
  24. package/dist/assets/src/core/ship/renewal.ts +19 -17
  25. package/dist/assets/src/core/trace/attrs.ts +1 -1
  26. package/dist/assets/src/core/untrusted.ts +35 -0
  27. package/dist/assets/src/execution/residentCredentials.ts +7 -4
  28. package/dist/assets/web/dist/.vite/manifest.json +80 -55
  29. package/dist/assets/web/dist/assets/HomePage-DJqJQzrL.js +1 -0
  30. package/dist/assets/web/dist/assets/{PendingTurnRow-DGINv9XT.js → PendingTurnRow-fhKqKUVv.js} +1 -1
  31. package/dist/assets/web/dist/assets/PlanePage-Bood0hNO.js +1 -0
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-jYhEeeyu.js → ResidentDetailPage-DOe1HtUC.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-DvFmlV4U.js → ResidentsIndexPage-TyCGjXM4.js} +1 -1
  34. package/dist/assets/web/dist/assets/RunFoldRow-C_4t-sit.js +1 -0
  35. package/dist/assets/web/dist/assets/{RunRoutePage-CvZ-TOT3.js → RunRoutePage-BNepPMRX.js} +4 -4
  36. package/dist/assets/web/dist/assets/RunsIndexPage-jZnMw1mM.js +1 -0
  37. package/dist/assets/web/dist/assets/{ScheduledPage-Viim-bus.js → ScheduledPage-BkNoudDP.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingsPage-CYUy8McC.js → SettingsPage-COvWGXl-.js} +1 -1
  39. package/dist/assets/web/dist/assets/SilentTurn-DNAANKgN.js +2 -0
  40. package/dist/assets/web/dist/assets/SlackMark-CtiHrNjr.js +1 -0
  41. package/dist/assets/web/dist/assets/{StatusDot-Dv6UMaPy.js → StatusDot-BgmkSV0S.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-Bd-5Rypv.js → Tooltip-DEUPuCPW.js} +1 -1
  43. package/dist/assets/web/dist/assets/UnitRoutePage-DNhdzxvY.js +1 -0
  44. package/dist/assets/web/dist/assets/{dist-Nl3uaxrP.js → dist-CF3jz9LM.js} +1 -1
  45. package/dist/assets/web/dist/assets/durationTone-CVpX_yIk.js +1 -0
  46. package/dist/assets/web/dist/assets/main-7SAujY_s.js +28 -0
  47. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +1 -0
  48. package/dist/assets/web/dist/assets/{sseReplay-DE6wv1Ua.js → sseReplay-BRJEIh83.js} +1 -1
  49. package/dist/cli.js +2672 -1102
  50. package/package.json +1 -1
  51. package/dist/assets/web/dist/assets/HomePage-BEiStFwW.js +0 -3
  52. package/dist/assets/web/dist/assets/PlanePage-JEj-lqgz.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +0 -1
  54. package/dist/assets/web/dist/assets/RunsIndexPage-JqVSDOok.js +0 -1
  55. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +0 -1
  56. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +0 -1
  57. package/dist/assets/web/dist/assets/main-C4QfwybO.css +0 -1
  58. package/dist/assets/web/dist/assets/main-DRxWlffc.js +0 -28
@@ -350,6 +350,7 @@ import {
350
350
  liveDrain,
351
351
  parseDrainRequest,
352
352
  reportImageCurrent,
353
+ staleHolds,
353
354
  type DrainRecord,
354
355
  } from "./drain";
355
356
  import {
@@ -1604,9 +1605,15 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1604
1605
  * the new image (issue 1931): hold the drain for each — the fleet must not
1605
1606
  * reopen onto them until they report. No live drain, nothing to hold. */
1606
1607
  async holdDrainFor(resources: string[]): Promise<void> {
1607
- const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1608
+ const now = systemClock();
1609
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), now);
1608
1610
  if (record === null || resources.length === 0) return;
1609
- await this.ctx.storage.put(DRAIN_KEY, holdDrain(record, resources));
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));
1610
1617
  }
1611
1618
 
1612
1619
  /** One resident's word that its running container is on the deploy's image:
@@ -1635,11 +1642,25 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1635
1642
  const stored = await this.ctx.storage.get(DRAIN_KEY);
1636
1643
  if (stored === undefined) return;
1637
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
+ );
1638
1653
  await this.ctx.storage.delete(DRAIN_KEY);
1639
1654
  await this.pushDrainPost("below");
1640
1655
  } else {
1641
- // Replaced with a later end under an already-armed alarm: re-arm at it.
1642
- 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)));
1643
1664
  }
1644
1665
  }
1645
1666
 
@@ -3261,6 +3282,14 @@ export class ResidentDO extends Sandbox<Env> {
3261
3282
  await this.writeDiskMarkers({ ready: sha, depsKey: lockfileHash, builtSha: sha });
3262
3283
  this.deleteSchedules(PROVISIONING_CALLBACK);
3263
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");
3264
3293
  // The first refresh instance is the cron's: the row now reads `warm`
3265
3294
  // with no instance recorded, so the next firing creates it (item 9).
3266
3295
  } catch (err) {
@@ -7597,6 +7626,19 @@ export class ResidentDO extends Sandbox<Env> {
7597
7626
  }
7598
7627
 
7599
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
+ }
7600
7642
  try {
7601
7643
  await this.ensureHydrated();
7602
7644
  } catch (err) {
@@ -8070,6 +8112,7 @@ export class ResidentDO extends Sandbox<Env> {
8070
8112
  AUTO_REBUILDS_KEY,
8071
8113
  INFRA_STREAK_KEY,
8072
8114
  MEMORY_KEY,
8115
+ IMAGE_REPORT_PENDING_KEY,
8073
8116
  ]);
8074
8117
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
8075
8118
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
@@ -8147,6 +8190,11 @@ export class ResidentDO extends Sandbox<Env> {
8147
8190
  inFlight: this.inFlightCount() + registeredRuns,
8148
8191
  // The runs alone (no refresh cycle): what the deploy preflight refuses on.
8149
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",
8150
8198
  // Item 22: who holds what, as the rows say — the mirror mutex and the
8151
8199
  // cycle/hydration leases, each judged against this incarnation.
8152
8200
  incarnation: this.incarnation,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.256.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.256.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.256.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.256.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.256.0",
3
- "commit": "fd814ee9ad2421f38178b15103d49ff4d9c5eb7c",
4
- "builtAt": "2026-09-20T04:37:22.536Z"
2
+ "version": "1.257.0",
3
+ "commit": "222646c5fe1a6686c238020f6571c69c3ab87e43",
4
+ "builtAt": "2026-09-20T15:27:11.679Z"
5
5
  }
@@ -279,6 +279,23 @@ export const FAST_GATES_BEFORE_PUSH =
279
279
  "CI's gate — `npm run verify` runs there on your push, never here: push a head early and let CI judge it, " +
280
280
  "fixing forward with further commits and pushes.";
281
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
+
282
299
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
283
300
 
284
301
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -303,6 +320,8 @@ ${CHECKS_BY_COST}
303
320
 
304
321
  ${FAST_GATES_BEFORE_PUSH}
305
322
 
323
+ ${REBASE_BEFORE_PUSH}
324
+
306
325
  ${NEVER_MERGE}
307
326
 
308
327
  ${UNIT_CONTRACT}
@@ -352,6 +371,8 @@ ${CHECKS_BY_COST}
352
371
 
353
372
  ${FAST_GATES_BEFORE_PUSH}
354
373
 
374
+ ${REBASE_BEFORE_PUSH}
375
+
355
376
  ${NEVER_MERGE}
356
377
 
357
378
  ${UNIT_CONTRACT}
@@ -397,6 +418,8 @@ ${CHECKS_BY_COST}
397
418
 
398
419
  ${FAST_GATES_BEFORE_PUSH}
399
420
 
421
+ ${REBASE_BEFORE_PUSH}
422
+
400
423
  ${NEVER_MERGE}
401
424
 
402
425
  ${UNIT_CONTRACT}
@@ -589,7 +612,7 @@ Your tools work without a workspace: the GitHub tools — \`github_repos\` (the
589
612
 
590
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.
591
614
 
592
- 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).`;
593
616
 
594
617
  // The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
595
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. */
@@ -63,6 +63,12 @@ export const POLICY: readonly Rule[] = [
63
63
  // NAMED run (`authorizeSteerOwner`, authorization item 16a) — the requester,
64
64
  // a linked id, or `runs:write` — which no command-door row can see.
65
65
  { action: "steer:write", resource: "command", when: [grant("steer:write")] },
66
+ // The plane's checkpoint steer (record 0064): the system actor `plane`
67
+ // (`PLANE_ACTOR_ID`, a `service` actor holding `PLANE_GRANTS`' one standing
68
+ // `steer:write`) writes the steer into a write-preset run's inbox on the
69
+ // ledger object itself, sender `plane`; this row records that authority in
70
+ // the one table even though the object, not the bot, executes the write.
71
+ { action: "steer:write", resource: "run", actorKinds: ["service"], when: [grant("steer:write")] },
66
72
 
67
73
  // ── conversations ────────────────────────────────────────────────────────
68
74
  // Who may point the bot at another thread (record 0037). Asked for a
@@ -108,6 +114,11 @@ export const POLICY: readonly Rule[] = [
108
114
  // read is never a chat baseline, like the costs reads.
109
115
  { action: "providers:read", resource: "command", when: [grant("providers:read")] },
110
116
 
117
+ // ── pull requests ──────────────────────────────────────────────────
118
+ // `pulls rebase` force-pushes the pipeline's branches and can start a paid
119
+ // fix round (record 0071): a grant, never a chat baseline.
120
+ { action: "pulls:write", resource: "command", when: [grant("pulls:write")] },
121
+
111
122
  // ── repos ────────────────────────────────────────────────────────────────
112
123
  { action: "repo:read", resource: "command", when: [grant("repo:read")] },
113
124
  { action: "repo:write", resource: "repo", when: [grant("repo:write")] },
@@ -150,6 +161,10 @@ export const POLICY: readonly Rule[] = [
150
161
  // channel-config right — whoever may set the channel may set a thread in it;
151
162
  // never a baseline, so membership alone admits nobody.
152
163
  { action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
164
+ // The ORG scope (`config set org --pulls.…`, record 0071) — and a repository
165
+ // scope, the org setting's per-repository slice — affects every requester:
166
+ // the same `config:write` grant, never a baseline.
167
+ { action: "config:write", resource: "config-scope", resourceKind: "org", when: [grant("config:write")] },
153
168
  // A user edits only their own scope.
154
169
  { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
155
170
  // The author binding's one trusted write (record 0062, authorization.md item
@@ -16,6 +16,10 @@
16
16
  // Workflow-driven coordinator and the deploy Workers can bundle it — the
17
17
  // dependency runs registry → budgets, never the reverse.
18
18
 
19
+ export const SECOND_MS = 1_000;
20
+
21
+ export const secondsToMs = (seconds: number): number => seconds * SECOND_MS;
22
+
19
23
  export const MINUTE_MS = 60_000;
20
24
 
21
25
  /** Minutes → milliseconds, for a duration a request names in minutes (a drain's
@@ -109,6 +113,14 @@ export const DRAIN = {
109
113
  marginMinutes: 5,
110
114
  maxMinutes: 90,
111
115
  defaultMinutes: 60,
116
+ /** How long a held drain waits for a container's post-deploy cycle before
117
+ * reopening the fleet anyway with the stale container named (issue 2044):
118
+ * the platform replaces container processes three to ten minutes after a
119
+ * deploy (docs/reference/specs/resident-repos.md item 65), so a cycle that
120
+ * has not landed within this bound is not coming on its own — the gate
121
+ * asked for the cycle, so it owns the outcome and never holds the fleet
122
+ * closed to the drain's whole `until` on a report nothing will send. */
123
+ cycleBoundMinutes: 10,
112
124
  } as const;
113
125
 
114
126
  /** How long an intake receipt row is kept on the run history object
@@ -143,7 +155,7 @@ export type Preset = LoopPreset | "ship";
143
155
  * that pushes at minute 29 and then runs the repo's whole gate (seven to ten
144
156
  * minutes on a resident) was cut at 45 one run in twelve, so the ask is
145
157
  * double the 90th percentile (31.5) with the gate inside it. Ship's holds
146
- * its own loop at the default rounds with a fix round at that ask, not at
158
+ * its own loop at the default rounds with a findings round at that ask, not at
147
159
  * its floor (`fit` proves the floor case; the ask leaves room above it). */
148
160
  export const ASKS: Readonly<Record<Preset, number>> = {
149
161
  general: 5,
@@ -155,17 +167,18 @@ export const ASKS: Readonly<Record<Preset, number>> = {
155
167
  conductor: 120,
156
168
  };
157
169
 
158
- /** The rounds a ship loop is made of. `fix` is a coding child handed the
170
+ /** The rounds a ship loop is made of. `findings` is a coding child handed the
159
171
  * review's findings; `merge` is the runner's wait on the guards. */
160
- export type RoundKind = "coding" | "review" | "fix" | "merge";
172
+ export type RoundKind = "coding" | "review" | "findings" | "merge";
161
173
 
162
174
  /** The least lease in which a round does useful work, in minutes. A carve that
163
175
  * falls under the floor is refused rather than dispatched: a two-minute
164
- * review or fix costs an attach and a model turn and finishes nothing.
165
- * Review's is the ledger's 90th percentile of completed reviews (5.1 min over
166
- * 181); coding's is the least a fix round can run the repo's gate in — the
167
- * gate alone takes seven to ten minutes on a resident, and a fix that cannot
168
- * run it finishes nothing its contract asks; the merge wait's is a guess
176
+ * review or findings round costs an attach and a model turn and finishes
177
+ * nothing. Review's is the ledger's 90th percentile of completed reviews (5.1
178
+ * min over 181); coding's is the least a findings round can run the repo's
179
+ * gate in — the gate alone takes seven to ten minutes on a resident, and a
180
+ * round that cannot run it finishes nothing its contract asks; the merge
181
+ * wait's is a guess
169
182
  * until the ledger says. */
170
183
  export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
171
184
  general: 2,
@@ -177,7 +190,7 @@ export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
177
190
  };
178
191
  export const FLOORS: Readonly<Record<RoundKind, number>> = {
179
192
  coding: PRESET_FLOORS.coding,
180
- fix: PRESET_FLOORS.coding,
193
+ findings: PRESET_FLOORS.coding,
181
194
  review: PRESET_FLOORS.review,
182
195
  merge: 10,
183
196
  };
@@ -186,6 +199,12 @@ export const FLOORS: Readonly<Record<RoundKind, number>> = {
186
199
  * when the remainder allows it. */
187
200
  export const MERGE_WAIT_ASK_MINUTES = 60;
188
201
 
202
+ /** The sweep's bounds (record 0071, mechanism two): the short lease of the one
203
+ * model round a conflicting pull request may buy, and the spend that round is
204
+ * capped at per pull request — a round refused at either bound ends the pull
205
+ * request's line with the conflict named, never a retry loop. */
206
+ export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
207
+
189
208
  /** The provider retry ladder (issue 1932): the backoff before each retry of a
190
209
  * transient model-call failure — a gateway 5xx, a stream cut before
191
210
  * `message_stop`, a gateway timeout. Three attempts with growing waits,
@@ -208,7 +227,15 @@ export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes:
208
227
  * it, a reservation's window, and the default re-ask cadence — how often a
209
228
  * silent resident something waits on is probed (`plane.reaskMinutes`
210
229
  * overrides it per deployment). */
211
- export const PLANE = { recentMinutes: 60, reservationMinutes: 2, reaskMinutes: 2 } as const;
230
+ export const PLANE = {
231
+ recentMinutes: 60,
232
+ reservationMinutes: 2,
233
+ reaskMinutes: 2,
234
+ /** A coding round with no pushed head past this is steered once (record 0064, "The backpressure contract"). */
235
+ noPushMinutes: 15,
236
+ /** The line an in-flight call with NO declared bound is judged against for the same steer. */
237
+ noBoundMinutes: 20,
238
+ } as const;
212
239
 
213
240
  /** The named amounts a lease holds back, in minutes. Each stands for a step
214
241
  * every run or round pays: `provision` is attach and restore before the
@@ -328,6 +355,12 @@ export function provisionalBearerExpiresAt(now: number, leaseMinutes: number): n
328
355
  return now + (ALLOWANCES.provision + leaseMinutes + ALLOWANCES.bearerGrace) * MINUTE_MS;
329
356
  }
330
357
 
358
+ /** The e2b credential-write exec's own timeout: the store-file write is one
359
+ * `printf` plus a `git config` reset, so a minute is generous headroom while
360
+ * still failing fast when the micro-VM is wedged
361
+ * (docs/reference/specs/execution.md item 5). */
362
+ export const SANDBOX_CREDENTIAL_WRITE_TIMEOUT_MS = MINUTE_MS;
363
+
331
364
  /** The per-command bash budget's rows: the default when a call names none, the
332
365
  * ceiling a call may raise it to, and the floor under which a number is a
333
366
  * typo (docs/reference/specs/execution.md item 11). */
@@ -361,19 +394,20 @@ export interface RoundPosition {
361
394
  }
362
395
 
363
396
  /** The rounds of a loop in order: the coding round, then a review and, after
364
- * every review but the last, a fix, then the merge wait. */
397
+ * every review but the last, a findings round, then the merge wait. */
365
398
  export function loopRounds(loop: Loop): RoundKind[] {
366
399
  const rounds: RoundKind[] = ["coding"];
367
400
  for (let i = 0; i < loop.maxRounds; i++) {
368
401
  rounds.push("review");
369
- if (i < loop.maxRounds - 1) rounds.push("fix");
402
+ if (i < loop.maxRounds - 1) rounds.push("findings");
370
403
  }
371
404
  rounds.push("merge");
372
405
  return rounds;
373
406
  }
374
407
 
375
408
  /** Where a round sits in `loopRounds`: the coding round first; review round
376
- * `n` (counted from 1) and the fix that follows it at `2n − 1` and `2n`; the
409
+ * `n` (counted from 1) and the findings round that follows it at `2n − 1`
410
+ * and `2n`; the
377
411
  * merge wait last. The ship coordinator numbers its rounds this way, so the
378
412
  * reserve it carves with is the one the fit assumed. */
379
413
  export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
@@ -382,7 +416,7 @@ export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
382
416
  return 0;
383
417
  case "review":
384
418
  return 2 * n - 1;
385
- case "fix":
419
+ case "findings":
386
420
  return 2 * n;
387
421
  case "merge":
388
422
  return 2 * loop.maxRounds;
@@ -403,7 +437,7 @@ export function carveChildOfParent(
403
437
  }
404
438
 
405
439
  /** The minutes a round holds back for what must follow it in the loop: the
406
- * floor plus provisioning of every later review and fix, and the merge
440
+ * floor plus provisioning of every later review and findings round, and the merge
407
441
  * wait's floor. A merge holds nothing back; a round with no loop (a
408
442
  * conductor's child) holds nothing back either. */
409
443
  export function reserveMinutes(
@@ -419,12 +453,12 @@ export function reserveMinutes(
419
453
  return reserve;
420
454
  }
421
455
 
422
- /** What a round asks for: the preset's ask for a coding or fix round, the
456
+ /** What a round asks for: the preset's ask for a coding or findings round, the
423
457
  * review's for a review, the merge wait's own for the merge. */
424
458
  export function roundAskMinutes(kind: RoundKind): number {
425
459
  switch (kind) {
426
460
  case "coding":
427
- case "fix":
461
+ case "findings":
428
462
  return ASKS.coding;
429
463
  case "review":
430
464
  return ASKS.review;
@@ -233,6 +233,26 @@ export interface ChecksSettledPayload {
233
233
  settledAt: number;
234
234
  }
235
235
 
236
+ /** The event the merge watch sends a waiting unit when its pull request
237
+ * merged, whoever merged (record 0071, mechanism three): the unit ends
238
+ * `merged` by other. The type names the unit within the instance, the
239
+ * nudge's alphabet and clip rule. */
240
+ export const PULL_MERGED_EVENT_PREFIX = "pr-merged-";
241
+ export function pullMergedEventType(key: { instanceId: string; unit: string }): string {
242
+ const suffix = `-${key.unit}`;
243
+ const room = 100 - PULL_MERGED_EVENT_PREFIX.length - suffix.length;
244
+ return `${PULL_MERGED_EVENT_PREFIX}${key.instanceId.slice(0, room)}${suffix}`;
245
+ }
246
+
247
+ /** What the merged event carries — the pull request and the merge's facts;
248
+ * the waiting unit confirms through its own pr-check before it acts. */
249
+ export interface PullMergedPayload {
250
+ repo: string;
251
+ number: number;
252
+ sha: string;
253
+ mergedAt: string;
254
+ }
255
+
236
256
  /** What the event carries — ids, a status and a clock; the parent confirms
237
257
  * through `read-record` before it acts, so nothing more rides here. */
238
258
  export interface RunFinishedPayload {
@@ -379,17 +399,17 @@ export interface UnitIdle {
379
399
  wakes: number;
380
400
  }
381
401
 
382
- /** One unit of the plan an instance runs (a task string is a generated plan of
383
- * one unit, `U1`): its branch, the units it waits on, and — as the runner
384
- * reaches it — its thread, its pull request, the round boundaries the card
385
- * drew and how it ended. One row a person can read for "what happened to this
386
- * unit". */
387
402
  /** What the severity gate caught on a round: the level in force and the gated findings as `id (severity)`. */
388
403
  export interface RoundGate {
389
404
  level: AddressSeverity;
390
405
  findings: string[];
391
406
  }
392
407
 
408
+ /** One unit of the plan an instance runs (a task string is a generated plan of
409
+ * one unit, `U1`): its branch, the units it waits on, and — as the runner
410
+ * reaches it — its thread, its pull request, the round boundaries the card
411
+ * drew and how it ended. One row a person can read for "what happened to this
412
+ * unit". */
393
413
  export interface CoordinatorUnit {
394
414
  instanceId: string;
395
415
  /** `U<n>` as the plan spells it. */
@@ -606,6 +626,26 @@ export async function sendChecksSettled(
606
626
  }
607
627
  }
608
628
 
629
+ /** The one send per merged fact the watch observed (record 0071): best effort
630
+ * like `sendRunFinished` — a refusal is answered, never thrown; the waiting
631
+ * unit's bounded wait re-asks its own pr-check on its next chunk. */
632
+ export async function sendPullMerged(
633
+ workflow: WorkflowSender | undefined,
634
+ key: { instanceId: string; unit: string },
635
+ payload: PullMergedPayload,
636
+ ): Promise<RunFinishedSend> {
637
+ const instance = key.instanceId;
638
+ if (!workflow) return { kind: "no-binding", instance };
639
+ const type = pullMergedEventType(key);
640
+ try {
641
+ const handle = await workflow.get(instance);
642
+ await handle.sendEvent({ type, payload });
643
+ return { kind: "sent", instance, type };
644
+ } catch (err) {
645
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
646
+ }
647
+ }
648
+
609
649
  /** One deploy-roll signal to the child's parent (run-history item 47a): best
610
650
  * effort like `sendRunFinished` — a refusal is answered, never thrown; a lost
611
651
  * send costs the wait a chunk, never the round. */
@@ -61,7 +61,7 @@ import {
61
61
  type UnitEnding,
62
62
  type UnitPipelineState,
63
63
  stepPrefixOf,
64
- type UnitSession,
64
+ type LeaseSegmentProgress,
65
65
  } from "../ship/coordinator.js";
66
66
  import {
67
67
  checksSettledEventType,
@@ -451,12 +451,15 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
451
451
  * ask's answer carries `retried` instead: whether the re-run was dispatched,
452
452
  * so the machine never waits on a head an undispatched re-run left unchanged. */
453
453
  function checksReturn(step: string, a: BotAnswer): StepReturn {
454
- const { ok, checks, retried, at } = a.body;
454
+ const { ok, checks, draft, retried, at } = a.body;
455
455
  if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
456
456
  return {
457
457
  type: "checks",
458
458
  step,
459
459
  ...(isRoundChecks(checks) ? { checks } : {}),
460
+ // The pull request is a draft (issue 2063): the machine's table holds the
461
+ // unit for the ready event instead of merging or ending without a cause.
462
+ ...(draft === true ? { draft: true } : {}),
460
463
  ...(typeof retried === "boolean" ? { retried } : {}),
461
464
  at,
462
465
  };
@@ -691,7 +694,7 @@ async function runUnit(
691
694
  instanceId: string,
692
695
  node: PlanUnitNode,
693
696
  plan: PlanFacts,
694
- session?: UnitSession,
697
+ session?: LeaseSegmentProgress,
695
698
  ): Promise<UnitEnding> {
696
699
  const unit = node.id;
697
700
  // A renewal's segment names its steps under the segment (`U10/s2/…`), so