@coreplane/switchboard 1.260.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 (43) hide show
  1. package/dist/assets/config/config.example.yaml +1 -0
  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/profile.example.json +2 -1
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/core/budgets.ts +12 -6
  9. package/dist/assets/src/core/coordinator/driver.ts +9 -4
  10. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  11. package/dist/assets/src/core/runEvents.ts +9 -1
  12. package/dist/assets/src/core/runLedger/types.ts +3 -3
  13. package/dist/assets/src/core/runRecord.ts +28 -8
  14. package/dist/assets/src/core/ship/coordinator.ts +145 -36
  15. package/dist/assets/src/core/ship/renewal.ts +2 -0
  16. package/dist/assets/src/deploy/liveGate.ts +8 -0
  17. package/dist/assets/src/deploy/profile.ts +8 -0
  18. package/dist/assets/src/deploy/restart.ts +49 -35
  19. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  20. package/dist/assets/web/dist/assets/{DeliveryPage-DvMrWUg7.js → DeliveryPage-62qCSLvk.js} +1 -1
  21. package/dist/assets/web/dist/assets/{HomePage-CcGEJ4w0.js → HomePage-Dk3HRBc3.js} +1 -1
  22. package/dist/assets/web/dist/assets/{PendingTurnRow-BzGVxDYs.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  23. package/dist/assets/web/dist/assets/{PlanePage-Arj9cyd5.js → PlanePage-RD0M6O6W.js} +1 -1
  24. package/dist/assets/web/dist/assets/{ResidentDetailPage-CBPPe4Sj.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CEpXgGo7.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  26. package/dist/assets/web/dist/assets/{RunFoldRow-BJq7tnLr.js → RunFoldRow-CwHDL-Mo.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunRoutePage-DWiny7LN.js → RunRoutePage-Du1HqLZs.js} +3 -3
  28. package/dist/assets/web/dist/assets/{RunsIndexPage-Cxufw5sV.js → RunsIndexPage-BZphy3Hr.js} +1 -1
  29. package/dist/assets/web/dist/assets/{ScheduledPage-SBcuDdVg.js → ScheduledPage-B7jh898t.js} +1 -1
  30. package/dist/assets/web/dist/assets/{SettingsPage-DrD_vWdp.js → SettingsPage-3uLEsiiE.js} +1 -1
  31. package/dist/assets/web/dist/assets/{SilentTurn-Bjl7EPEN.js → SilentTurn-CKxSxnQC.js} +1 -1
  32. package/dist/assets/web/dist/assets/{StatusDot-BqteQTav.js → StatusDot-x7vcK0iE.js} +1 -1
  33. package/dist/assets/web/dist/assets/{Tooltip-KRqJXEPN.js → Tooltip-wVXCWhMp.js} +1 -1
  34. package/dist/assets/web/dist/assets/{UnitRoutePage-Cv28FV4I.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  35. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  36. package/dist/assets/web/dist/assets/{dist-CTASUSno.js → dist-C-XGnvM4.js} +1 -1
  37. package/dist/assets/web/dist/assets/{indexRow-B1YBy_IL.js → indexRow-Dxg7C8s9.js} +1 -1
  38. package/dist/assets/web/dist/assets/{main-eTAe9-hk.js → main-DAL--hyj.js} +2 -2
  39. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  40. package/dist/cli.js +857 -288
  41. package/package.json +1 -1
  42. package/dist/assets/web/dist/assets/budgets-DQXVllsm.js +0 -1
  43. package/dist/assets/web/dist/assets/sseReplay-BcsNbn2j.js +0 -11
@@ -172,6 +172,7 @@ defaults:
172
172
  # "config set channel ..." are stored in data/overrides.json and win over these.
173
173
  # channels:
174
174
  # slack:C012345:
175
+ # repo: acme/api # default repository for repo-bound asks in this channel
175
176
  # agent: review
176
177
  # models:
177
178
  # review: anthropic/claude-opus-5
@@ -28,14 +28,10 @@ import {
28
28
  import {
29
29
  authenticateIngressBearer,
30
30
  authenticateRestart,
31
+ authorizeRestartDeployer,
31
32
  decideRestart,
32
- parseRestartAuthorization,
33
33
  parseRestartRequest,
34
- RESTART_AUTHORIZE_PATH,
35
- RESTART_SUBJECT_HEADER,
36
34
  restartResponse,
37
- stripRestartSubject,
38
- type RestartAuth,
39
35
  type RestartOutcome,
40
36
  } from "../../src/deploy/restart.ts";
41
37
  import {
@@ -89,7 +85,8 @@ export interface Env {
89
85
  ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
90
86
  ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
91
87
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
92
- SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present; an entry whose `http:<subject>` actor holds `deploy:write` in the bot's config may POST /admin/restart
88
+ SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
89
+ SWITCHBOARD_RESTART_DEPLOYER?: string; // deployment-profile grant: the token-map subject allowed to POST /admin/restart; Worker-only, never runtime config
93
90
  BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
94
91
  GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
95
92
  CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
@@ -211,37 +208,6 @@ export class SwitchboardServer extends Container<Env> {
211
208
  return this.restartRunning(opts);
212
209
  }
213
210
 
214
- /**
215
- * `deploy restart`, authorized: the Worker knows WHO the bearer is (the token
216
- * map); WHETHER that identity may restart is the bot's config (`grants` —
217
- * authorization.md item 9), which only the container holds. So ask it —
218
- * `POST /admin/restart/authorize` with the authenticated subject in
219
- * `RESTART_SUBJECT_HEADER` (never the bearer: the container may still hold the
220
- * token map from before a rotation) — and stop only on a 200. A container that
221
- * is not running is started first (the bot must answer): if the bearer is
222
- * allowed, that start already put the current env live, so nothing is
223
- * stopped and the outcome is `not-running`, exactly as before; if not, the
224
- * refusal is relayed and the started container simply keeps serving.
225
- */
226
- async restartAuthorized(
227
- subject: string,
228
- opts: { force: boolean },
229
- ): Promise<{ auth: RestartAuth; outcome?: RestartOutcome }> {
230
- const wasRunning = this.ctx.container?.running === true;
231
- await this.startBot();
232
- const answer = await this.containerFetch(
233
- new Request(`${INTERNAL}${RESTART_AUTHORIZE_PATH}`, {
234
- method: "POST",
235
- headers: { [RESTART_SUBJECT_HEADER]: subject },
236
- }),
237
- this.defaultPort,
238
- );
239
- const auth = parseRestartAuthorization(answer.status, await answer.text().catch(() => ""));
240
- if (!auth.ok) return { auth };
241
- if (!wasRunning) return { auth, outcome: { kind: "not-running" } };
242
- return { auth, outcome: await this.restartRunning(opts) };
243
- }
244
-
245
211
  /** The stop itself, for a running container: the preflight's refusal rules over `/healthz`, then SIGTERM. */
246
212
  private async restartRunning(opts: { force: boolean }): Promise<RestartOutcome> {
247
213
  const health = await this.containerFetch(new Request(`${INTERNAL}/healthz`), this.defaultPort);
@@ -265,12 +231,12 @@ export class SwitchboardServer extends Container<Env> {
265
231
  }
266
232
  }
267
233
 
268
- /** `POST /admin/restart` — the operator surface behind `deploy restart`
269
- * (src/deploy/restart.ts documents the authorization choice: a
270
- * SWITCHBOARD_INGRESS_TOKENS bearer whose `http:<subject>` actor holds
271
- * `deploy:write` in the bot's config). The Worker authenticates the bearer
272
- * against the map it holds — an unknown bearer never touches the container —
273
- * and the Container DO asks the bot for the grant before stopping anything.
234
+ /** `POST /admin/restart` — the operator surface behind `deploy restart`.
235
+ * The Worker authenticates the bearer against its token map, then authorizes
236
+ * that subject against SWITCHBOARD_RESTART_DEPLOYER, rendered from the
237
+ * deployment profile (or set directly as a Worker var). Runtime config is
238
+ * never consulted: this route must remain usable when that document is what
239
+ * the restart is repairing.
274
240
  * Body `{ "force": true }` bypasses the fail-closed refusals (no JSON body,
275
241
  * impossible `inFlight`); runs in flight or a drain warn and never refuse. */
276
242
  async function handleAdminRestart(request: Request, env: Env): Promise<Response> {
@@ -283,14 +249,16 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
283
249
  console.warn(`[restart] ${authn.status} — ${authn.reason}`);
284
250
  return json(authn.status, { ok: false, error: authn.reason });
285
251
  }
252
+ const auth = authorizeRestartDeployer(authn.identity.subject, env.SWITCHBOARD_RESTART_DEPLOYER);
253
+ if (!auth.ok) {
254
+ console.warn(`[restart] ${auth.status} — ${auth.reason}`);
255
+ return json(auth.status, { ok: false, error: auth.reason });
256
+ }
286
257
  const parsed = parseRestartRequest(await request.text().catch(() => ""));
287
258
  if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
288
- let auth: RestartAuth;
289
- let outcome: RestartOutcome | undefined;
259
+ let outcome: RestartOutcome;
290
260
  try {
291
- ({ auth, outcome } = await getContainer(env.SWITCHBOARD, INSTANCE).restartAuthorized(authn.identity.subject, {
292
- force: parsed.force,
293
- }));
261
+ outcome = await getContainer(env.SWITCHBOARD, INSTANCE).restart({ force: parsed.force });
294
262
  } catch (err) {
295
263
  // The container's /healthz probe or the DO call threw (container mid-transition,
296
264
  // port not answering): fail closed in the route's own JSON shape so the CLI reads
@@ -299,11 +267,6 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
299
267
  console.error(`[restart] ${authn.identity.subject} → error before stop: ${reason}`);
300
268
  return json(500, { ok: false, error: `restart failed before stopping anything: ${reason}` });
301
269
  }
302
- if (!auth.ok || outcome === undefined) {
303
- const refusal = auth.ok ? { status: 503 as const, reason: "restart disabled: no outcome" } : auth;
304
- console.warn(`[restart] ${refusal.status} — ${refusal.reason}`);
305
- return json(refusal.status, { ok: false, error: refusal.reason });
306
- }
307
270
  console.log(
308
271
  `[restart] ${auth.subject} → ${outcome.kind}${outcome.kind === "refused" ? `: ${outcome.problems.join("; ")}` : ""}`,
309
272
  );
@@ -494,9 +457,7 @@ export default {
494
457
  // caller sent is stripped, and what the container sees carries this
495
458
  // Worker's own root. A static asset or the live view's SSE stream gets no
496
459
  // root; a refusal's line is dropped by the sink's filter.
497
- // The restart-subject header is the Worker's own word to the container (the
498
- // authorize call below); a caller cannot be allowed to speak it.
499
- const inbound = stripRestartSubject(stripTraceContext(request));
460
+ const inbound = stripTraceContext(request);
500
461
  const route = shimRoute(pathname);
501
462
  if (route === undefined) return withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(inbound));
502
463
  const root = tracer.start("bot-shim.fetch", { sinks: traceSinks, attrs: { route } });
@@ -31,6 +31,12 @@
31
31
  "ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
32
32
  "ACCESS_AUD": "{{access.aud}}",
33
33
  // {{/if}}
34
+ // The restart route's deployment-side grant. The Worker authenticates a
35
+ // bearer through SWITCHBOARD_INGRESS_TOKENS and compares its subject here;
36
+ // it never asks the runtime config it may be restarting to repair.
37
+ // {{#if restart}}
38
+ "SWITCHBOARD_RESTART_DEPLOYER": "{{restart.deployer}}",
39
+ // {{/if}}
34
40
  // The state Worker (deploy/cloudflare-memory/): where worker.ts records each
35
41
  // scheduled firing for the /runs Scheduled panel, with MEMORY_TOKEN.
36
42
  // A profile without a memory Worker renders no var: firings go unrecorded.
@@ -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.260.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.260.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.260.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.260.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.260.0",
3
- "commit": "33c6cd103ce9d8a49be5249f98306fe46160f81c",
4
- "builtAt": "2026-09-21T15:19:23.063Z"
2
+ "version": "1.260.1",
3
+ "commit": "c57cb7b614e8916b3fd0ab86c848a8398d8dc781",
4
+ "builtAt": "2026-09-21T18:18:08.788Z"
5
5
  }
@@ -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
@@ -469,7 +469,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
469
469
  * ask's answer carries `retried` instead: whether the re-run was dispatched,
470
470
  * so the machine never waits on a head an undispatched re-run left unchanged. */
471
471
  function checksReturn(step: string, a: BotAnswer): StepReturn {
472
- const { ok, checks, draft, retried, at } = a.body;
472
+ const { ok, checks, draft, retried, refired, at } = a.body;
473
473
  if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
474
474
  return {
475
475
  type: "checks",
@@ -479,6 +479,7 @@ function checksReturn(step: string, a: BotAnswer): StepReturn {
479
479
  // unit for the ready event instead of merging or ending without a cause.
480
480
  ...(draft === true ? { draft: true } : {}),
481
481
  ...(typeof retried === "boolean" ? { retried } : {}),
482
+ ...(typeof refired === "boolean" ? { refired } : {}),
482
483
  at,
483
484
  };
484
485
  }
@@ -489,7 +490,10 @@ const isRoundChecks = (v: unknown): v is RoundChecks =>
489
490
  Array.isArray(v.pending) &&
490
491
  v.pending.every((n: unknown) => typeof n === "string") &&
491
492
  Array.isArray(v.failed) &&
492
- v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string");
493
+ v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string") &&
494
+ (v.required === undefined ||
495
+ (Array.isArray(v.required) && v.required.every((n: unknown) => typeof n === "string"))) &&
496
+ (v.expected === undefined || (Array.isArray(v.expected) && v.expected.every((n: unknown) => typeof n === "string")));
493
497
 
494
498
  function mergeReturn(step: string, a: BotAnswer): StepReturn {
495
499
  const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
@@ -670,8 +674,8 @@ async function perform(
670
674
  }
671
675
  case "checks":
672
676
  // The round's checks step (record 0055): the bot reads the check runs at
673
- // the reviewed head with the merge door's own reading — or, on a retry
674
- // ask, re-runs the named failed checks' jobs first.
677
+ // the reviewed head with the merge door's own reading — or performs one
678
+ // of the step's bounded recovery effects first.
675
679
  return checksReturn(
676
680
  action.step,
677
681
  answerOf(
@@ -682,6 +686,7 @@ async function perform(
682
686
  prNumber: action.prNumber,
683
687
  headSha: action.headSha,
684
688
  ...(action.retry !== undefined ? { retry: action.retry } : {}),
689
+ ...(action.refire === true ? { refire: true } : {}),
685
690
  }),
686
691
  ),
687
692
  ),
@@ -94,6 +94,7 @@ export const ROUND_STAGE = {
94
94
  request_changes: "fix",
95
95
  no_verdict: "hold",
96
96
  checks_failed: "fix",
97
+ checks_restarted: "approved",
97
98
  transient: "hold",
98
99
  enqueued: "approved",
99
100
  dequeued: "fix",
@@ -174,6 +175,7 @@ export const ROUND_OUTCOME_WORDS = {
174
175
  request_changes: "changes requested",
175
176
  no_verdict: "no verdict",
176
177
  checks_failed: "checks failed",
178
+ checks_restarted: "checks restarted",
177
179
  transient: "retried",
178
180
  enqueued: "queued to merge",
179
181
  dequeued: "removed from the merge queue",
@@ -294,6 +294,10 @@ export type RunNoteKind =
294
294
  * there was nothing to push), so a re-issue starts from the partial work
295
295
  * (docs/reference/specs/agent-ship.md item 8). */
296
296
  | "budget_salvage"
297
+ /** A coordinator's coding child ended failed, stopped or interrupted with
298
+ * work in its tree: before teardown the run loop made a WIP commit and
299
+ * pushed the unit branch, or recorded why no push was possible. */
300
+ | "work_salvage"
297
301
  /** A pi run's compaction failed for good — the provider refused the summary
298
302
  * (harness-pi.md item 7) — and the run loop treated it as a checkpoint
299
303
  * signal: the tracked changes were committed and pushed to the run's own
@@ -363,6 +367,7 @@ export const RUN_NOTE_KINDS = [
363
367
  "tool_unnamed",
364
368
  "directory_reached",
365
369
  "budget_salvage",
370
+ "work_salvage",
366
371
  "compaction_salvage",
367
372
  "stuck_loop",
368
373
  "decline_cascade",
@@ -432,6 +437,9 @@ export type ShipRoundOutcome =
432
437
  * 0055): the failures become check findings and the findings step runs as
433
438
  * for any changes-requested round. */
434
439
  | "checks_failed"
440
+ /** No required check appeared during the checks step's grace chunk, so the
441
+ * runner re-fired the pull_request event once by closing and reopening it. */
442
+ | "checks_restarted"
435
443
  /** The round's coding child died on a provider transient with nothing
436
444
  * pushed (issue 1932): the first such boundary marks the round's one
437
445
  * re-run, a second the `transient` ending. */
@@ -1096,7 +1104,7 @@ export type RunEvent =
1096
1104
  * `model` is the ref the run uses, resolved from a model the person
1097
1105
  * named in plain words (the plain-words model unit) — applied at
1098
1106
  * directive precedence. Additive: unknown → no model. */
1099
- binds?: ReadonlyArray<{ line: string; reason: string; model?: string; confirmed?: true }>;
1107
+ binds?: ReadonlyArray<{ line: string; reason: string; model?: string; repo?: string; confirmed?: true }>;
1100
1108
  question?: string;
1101
1109
  /** A question's proposed line, redacted and cut like the receipt — what
1102
1110
  * the next turn's "yes" binds (`bindFromAnswer`). */
@@ -128,9 +128,9 @@ export interface LiveRunMeta {
128
128
  export type RunState = Record<string, unknown>;
129
129
 
130
130
  /** A hosted ship parent's hosting fact on the row's state (record 0060): the
131
- * runner instance the run hosts and the deadline past which a reclaim closes
132
- * the row `interrupted` instead of re-hosting it. Set by the ship branch once
133
- * the instance exists; read by the boot reclaim. */
131
+ * runner instance the run hosts and its original wall-clock estimate. Reclaim
132
+ * may outlive that estimate while the Workflow or one of its child rows is
133
+ * live; only no durable owner beyond it closes the row. */
134
134
  export interface HostingState {
135
135
  instanceId: string;
136
136
  /** Epoch ms: the hand-off time plus the instance's `caps.maxMinutes` plus
@@ -289,19 +289,23 @@ export interface RunLease {
289
289
  loopEndsAt: number;
290
290
  }
291
291
 
292
- /** A head a run pushed, as the record names it (item 2): the branch and the sha. */
292
+ /** A head a run pushed, as the record names it (item 2): the branch, sha,
293
+ * and whether the agent pushed it or the run's mechanical WIP salvage did.
294
+ * `by` is optional for records written before that distinction survived the
295
+ * event fold. */
293
296
  export interface PushedHead {
294
297
  ref: string;
295
298
  sha: string;
299
+ by?: "push" | "salvage";
296
300
  }
297
301
 
298
302
  /** The heads a run's events say it pushed — one per branch, the last event's
299
303
  * sha winning, in first-seen order — or nothing when it pushed none. */
300
304
  export function pushedHeadsOf(events: readonly RunEvent[]): PushedHead[] | undefined {
301
- const byRef = new Map<string, string>();
302
- for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, e.sha);
305
+ const byRef = new Map<string, { sha: string; by: "push" | "salvage" }>();
306
+ for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, { sha: e.sha, by: e.by });
303
307
  if (byRef.size === 0) return undefined;
304
- return [...byRef].map(([ref, sha]) => ({ ref, sha }));
308
+ return [...byRef].map(([ref, pushed]) => ({ ref, ...pushed }));
305
309
  }
306
310
 
307
311
  /** The lease a run's events say its harness started — the first `lease`
@@ -440,7 +444,7 @@ export interface RunOperatorDecision {
440
444
  mode: "shadow" | "on";
441
445
  outcome: "binds" | "question" | "refusal" | "non_decision";
442
446
  reason: string;
443
- binds?: { line: string; reason: string }[];
447
+ binds?: { line: string; reason: string; model?: string; repo?: string; confirmed?: true }[];
444
448
  question?: string;
445
449
  /** A question's proposed line — what the next turn's "yes" binds. */
446
450
  proposal?: string;
@@ -468,7 +472,17 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
468
472
  mode: e.mode,
469
473
  outcome: e.outcome,
470
474
  reason: e.reason,
471
- ...(e.binds ? { binds: e.binds.map((b) => ({ line: b.line, reason: b.reason })) } : {}),
475
+ ...(e.binds
476
+ ? {
477
+ binds: e.binds.map((b) => ({
478
+ line: b.line,
479
+ reason: b.reason,
480
+ ...(b.model !== undefined ? { model: b.model } : {}),
481
+ ...(b.repo !== undefined ? { repo: b.repo } : {}),
482
+ ...(b.confirmed ? { confirmed: true as const } : {}),
483
+ })),
484
+ }
485
+ : {}),
472
486
  ...(e.question !== undefined ? { question: e.question } : {}),
473
487
  ...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
474
488
  ...(e.request !== undefined ? { request: e.request } : {}),
@@ -1019,8 +1033,14 @@ export function isRunRecord(v: unknown): v is RunRecord {
1019
1033
  Array.isArray(r.pushed) &&
1020
1034
  r.pushed.every((h) => {
1021
1035
  if (typeof h !== "object" || h === null) return false;
1022
- const { ref, sha } = h as Record<string, unknown>;
1023
- return typeof ref === "string" && ref.length > 0 && typeof sha === "string" && /^[0-9a-f]{7,40}$/.test(sha);
1036
+ const { ref, sha, by } = h as Record<string, unknown>;
1037
+ return (
1038
+ typeof ref === "string" &&
1039
+ ref.length > 0 &&
1040
+ typeof sha === "string" &&
1041
+ /^[0-9a-f]{7,40}$/.test(sha) &&
1042
+ (by === undefined || by === "push" || by === "salvage")
1043
+ );
1024
1044
  })
1025
1045
  )
1026
1046
  )