@coreplane/switchboard 1.240.0 → 1.242.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 (146) hide show
  1. package/dist/assets/config/config.example.yaml +40 -8
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +198 -15
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  5. package/dist/assets/deploy/secrets.manifest.json +6 -0
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/project.json +2 -2
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +58 -22
  11. package/dist/assets/src/core/authz/actor.ts +17 -9
  12. package/dist/assets/src/core/authz/authorize.ts +17 -4
  13. package/dist/assets/src/core/authz/grants.ts +20 -9
  14. package/dist/assets/src/core/authz/types.ts +12 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +40 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +17 -1
  17. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  18. package/dist/assets/src/core/costs.ts +22 -9
  19. package/dist/assets/src/core/harness/scope.ts +21 -0
  20. package/dist/assets/src/core/runEvents.ts +20 -1
  21. package/dist/assets/src/core/runLedger/types.ts +2 -0
  22. package/dist/assets/src/core/runRecord.ts +4 -0
  23. package/dist/assets/src/core/ship/contract.ts +4 -2
  24. package/dist/assets/src/core/ship/coordinator.ts +22 -7
  25. package/dist/assets/src/core/trace/attrs.ts +1 -1
  26. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  27. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  28. package/dist/assets/src/execution/residentHead.ts +23 -10
  29. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  30. package/dist/assets/src/execution/residentRefresh.ts +15 -1
  31. package/dist/assets/src/execution/residentSteps.ts +1 -0
  32. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  33. package/dist/assets/src/execution/seedPlan.ts +17 -0
  34. package/dist/assets/web/dist/.vite/manifest.json +435 -439
  35. package/dist/assets/web/dist/assets/AppShell-IDMGG6yi.js +1 -0
  36. package/dist/assets/web/dist/assets/{CostsPage-BD3w5j_S.js → CostsPage-CtmKhhOF.js} +2 -2
  37. package/dist/assets/web/dist/assets/{DeliveryPage-BtvzKA1m.js → DeliveryPage-n1tz5I_v.js} +1 -1
  38. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +2 -0
  39. package/dist/assets/web/dist/assets/{NotFoundPage-DwOX1HaZ.js → NotFoundPage-C97EhJcQ.js} +1 -1
  40. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-CUAOh5d9.js → ResidentDetailPage-DTMBgnIW.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B3KUlYy2.js → ResidentsIndexPage-CaDvXhzJ.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +6 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-BnkvCBrM.js → RunsTabs-BUfdk0lH.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-C1y9jgAk.js → ScheduledPage-DXD2gLJk.js} +1 -1
  48. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-CiByEtoR.js → StatusDot-ELoXHlFt.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DyRxk8A8.js → Tooltip-BoeFwYP2.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DAQ_1T1v.js → angular-html-oBNfPJR0.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-D4kxRVp1.js → angular-ts-BvNwsyWA.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Dy07l64y.js → apl-CNUdRlYf.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-DZIbTlc7.js → astro-Zb0NriSe.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-CmSXWX3l.js → blade-qPRVheqq.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-ggzVdJLK.js → c-D8Awx4YO.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-Bo1wktsG.js → chapel-Bt72Mhsx.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DJUmtKM2.js → cobol-BOBacexg.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-BZWOVz4C.js → coffee-E4u0liHW.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-Dtk39yMw.js → cpp-q2sLNlul.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-CGf_-Zoj.js → crystal-DyWqUnlb.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-CX2fcL4u.js → css-CQY0hFsD.js} +1 -1
  64. package/dist/assets/web/dist/assets/{dist-D8TaC961.js → dist-BU5UivXC.js} +2 -2
  65. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  66. package/dist/assets/web/dist/assets/{edge-BYS8FtgQ.js → edge-C1MwhJkX.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-fhUpi3nO.js → elixir-Bb3YbHfn.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-DAQNiwsN.js → elm-DAN9IGQw.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-CWzEys6U.js → erb-BEB8Xlsj.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-9rY3p-Rw.js → git-rebase-tqpjRfxO.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-tY7hy0nE.js → glimmer-js-Ccbo65zR.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CTrLv5JF.js → glimmer-ts-B6WBVMpE.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-BRJbiDfq.js → glsl-tRec3Fcu.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-B-MtVLh2.js → graphql-P8kbxT4F.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-BtIkOypQ.js → hack-H9Zhkagy.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-CoOH6gfa.js → haml-DtEnpn7Z.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-DgrRu-GP.js → handlebars-DkgPfoAz.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-DZaaI7ca.js → html-D30RXpIs.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DgHvT-qZ.js → html-derivative-DwozLrEx.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CvAd51yz.js → http-DXuzBAPm.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-CXGqAfzx.js → hurl-d1UUJIt_.js} +1 -1
  82. package/dist/assets/web/dist/assets/{indexRow-Cse3JGb0.js → indexRow-BD1VT8o8.js} +1 -1
  83. package/dist/assets/web/dist/assets/{java-CIQStIXF.js → java-DL0gWf34.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-WbH8s7E3.js → javascript-Cl7vavnS.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-DzyWEVKC.js → jinja-CfQOWMX9.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-CrT-zCa-.js → jison-KdYirlqm.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-lHjpjXMQ.js → json-C9cDQ-Qj.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-WTwYCHW6.js → jsx-CRx5NItd.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-Bqpk9H7E.js → julia-CmsQsZQl.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-LdXk1r3m.js → just-CsM3Q8TE.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-h_y2A5KD.js → latex-BXCh5YRX.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CY7bF2_p.js → liquid-BF2vwK8p.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-oQ7XVZOY.js → lua-DcMBATrl.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-B2fX10aW.css +1 -0
  95. package/dist/assets/web/dist/assets/{main-CGO6fQ8k.js → main-D4EA1g6n.js} +2 -2
  96. package/dist/assets/web/dist/assets/{marko-zv_qZ4QV.js → marko-88MndKvG.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-Dl4kL_YK.js → mdc-DJ4kVAd8.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-CAHCZj4F.js → nginx-CP6mRgtV.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-BOtmwCJH.js → nim-QQfj3fpF.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-DspQk3HQ.js → org-Bke3eBzc.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-CAXmvVl0.js → perl-Bkh0N7Kz.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-CMUx4VyM.js → php-Dnv1Piya.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-B55xVJTv.js → pug-D6peFFI7.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-DsNKCCRk.js → qml-D8PEs-C-.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-DbhP0VvO.js → r-B1EL9b_j.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-BCpE6P-p.js → razor-BsMTIh2b.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-BllsjiN4.js → regexp-HhvC8spD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CO5Rpq9V.js → rst-D5paAxpg.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-C2A4OgQq.js → ruby-BrQwhLrl.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-Butzoyql.js → sas-afot2B1o.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-PEUwJBpf.js → scss-Efm-mwuG.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-3Yhq0khE.js → shellscript-BK0Vv5fT.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-04Hkb7m3.js → shellsession-BGqlMC7N.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-BnId73cP.js → soy-M0b4UwGM.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-BKwdc0ch.js → sql-sxe6IE9j.js} +1 -1
  116. package/dist/assets/web/dist/assets/sseReplay-g7ml86LM.js +9 -0
  117. package/dist/assets/web/dist/assets/{stata-BPPtNkay.js → stata-nPF_ddLP.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-CDPChcYP.js → surrealql-D_GrC6u7.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-B15DIQ-z.js → svelte-DwL1AtNP.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-D-rp_AiB.js → templ-7s7LTDkc.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BnVDCD7p.js → tex-Bx-5fMxe.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-cpujdyAR.js → ts-tags-DUMJnke_.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-D1vO4L72.js → tsx-BN8biPDe.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-oQGnQGRc.js → twig-8nIu84TN.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-CKSFpXiH.js → typescript-BooSPq_S.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-DKHFo8BF.js → typst-CTBiBsem.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-BstokpiS.js → vue-C6Ft4Lea.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DpCp0_Sz.js → vue-html-Ba36dD5D.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-y5d5wUb1.js → vue-vine-NkFexVo2.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-Br2hx5Ax.js → xml-C_THnHXZ.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-Bc8Cobap.js → xsl-D8G5xqjY.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-LOITy_I1.js → yaml-jAMIJzge.js} +1 -1
  133. package/dist/cli.js +4632 -817
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +0 -1
  136. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +0 -2
  137. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +0 -1
  138. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +0 -6
  139. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +0 -1
  140. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +0 -1
  141. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  142. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +0 -1
  143. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +0 -1
  144. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  145. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +0 -1
  146. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +0 -9
@@ -57,7 +57,7 @@ import {
57
57
  type UnitPipelineInput,
58
58
  type UnitPipelineState,
59
59
  } from "../ship/coordinator.js";
60
- import { isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
60
+ import { checksSettledEventType, isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
61
61
 
62
62
  const MIN = 60_000;
63
63
 
@@ -429,6 +429,22 @@ async function perform(
429
429
  case "sleep":
430
430
  await step.sleep(action.step, action.ms);
431
431
  return { type: "sleep", step: action.step };
432
+ case "wait-checks": {
433
+ // The intake's checks-settled event at the approved head (http-ingress.md
434
+ // item 12), with the machine's bounded timeout as the fallback: either
435
+ // way the machine re-asks the merge door, which is the guard.
436
+ let outcome: "event" | "timeout";
437
+ try {
438
+ await step.waitForEvent(action.step, {
439
+ type: checksSettledEventType(action.headSha),
440
+ timeout: action.timeoutMs,
441
+ });
442
+ outcome = "event";
443
+ } catch {
444
+ outcome = "timeout";
445
+ }
446
+ return { type: "wait-checks", step: action.step, outcome };
447
+ }
432
448
  case "merge":
433
449
  return mergeReturn(
434
450
  action.step,
@@ -131,6 +131,78 @@ export function parseInstanceStatusPath(pathname: string): string | undefined {
131
131
  return INSTANCE_ID_PATTERN.test(id) ? id : undefined;
132
132
  }
133
133
 
134
+ /** `POST /admin/coordinator/instances/<id>/events` — the shim's event relay
135
+ * (docs/reference/specs/http-ingress.md item 12): the bot holds no Workflow
136
+ * binding, so the check-run intake sends `checks-settled-<head>` through this
137
+ * route and the shim's own `SHIP_COORDINATOR.get(id).sendEvent` delivers it. */
138
+ export const COORDINATOR_INSTANCE_EVENTS_SUFFIX = "/events";
139
+
140
+ /** The instance id an event path names, or undefined for any other path. */
141
+ export function parseInstanceEventPath(pathname: string): string | undefined {
142
+ if (!pathname.startsWith(COORDINATOR_INSTANCE_STATUS_PREFIX)) return undefined;
143
+ if (!pathname.endsWith(COORDINATOR_INSTANCE_EVENTS_SUFFIX)) return undefined;
144
+ const id = pathname.slice(
145
+ COORDINATOR_INSTANCE_STATUS_PREFIX.length,
146
+ pathname.length - COORDINATOR_INSTANCE_EVENTS_SUFFIX.length,
147
+ );
148
+ return INSTANCE_ID_PATTERN.test(id) ? id : undefined;
149
+ }
150
+
151
+ export type ParsedSendEvent = { ok: true; type: string; payload: unknown } | { ok: false; reason: string };
152
+
153
+ /** The Workflow event alphabet — what the engine's `sendEvent` accepts as a type. */
154
+ const EVENT_TYPE_PATTERN = /^[A-Za-z0-9_-]{1,100}$/;
155
+
156
+ /** The body: `{ type, payload? }` — the typed event the instance's `waitForEvent` waits on. */
157
+ export function parseSendEventRequest(text: string): ParsedSendEvent {
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(text);
161
+ } catch {
162
+ return { ok: false, reason: "body is not valid JSON" };
163
+ }
164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
165
+ return { ok: false, reason: "body must be a JSON object" };
166
+ const b = parsed as Record<string, unknown>;
167
+ if (typeof b.type !== "string" || !EVENT_TYPE_PATTERN.test(b.type))
168
+ return { ok: false, reason: "`type` must be a Workflow event type: letters, digits, `_` and `-`, at most 100" };
169
+ return { ok: true, type: b.type, payload: b.payload };
170
+ }
171
+
172
+ /** How the send ended on the shim: delivered, no such instance (the engine's
173
+ * own `instance.not_found`), or the engine refusing by reason (an instance
174
+ * that already ended is a refusal, not an absence). */
175
+ export type SendEventOutcome =
176
+ { kind: "sent"; id: string } | { kind: "absent"; id: string } | { kind: "failed"; id: string; reason: string };
177
+
178
+ export function sendEventResponse(outcome: SendEventOutcome): { status: number; body: Record<string, unknown> } {
179
+ switch (outcome.kind) {
180
+ case "sent":
181
+ return { status: 200, body: { ok: true, id: outcome.id, sent: true } };
182
+ case "absent":
183
+ return { status: 404, body: { ok: false, error: "no_instance", id: outcome.id } };
184
+ case "failed":
185
+ return { status: 502, body: { ok: false, error: "send_failed", id: outcome.id, message: outcome.reason } };
186
+ }
187
+ }
188
+
189
+ /** The send answer as the bot reads it back: sent, absent, or unanswered by reason. */
190
+ export type SendEventAnswer = { kind: "sent" } | { kind: "absent" } | { kind: "unanswered"; reason: string };
191
+
192
+ export function readSendEventAnswer(status: number, text: string): SendEventAnswer {
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(text);
196
+ } catch {
197
+ parsed = undefined;
198
+ }
199
+ const body = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
200
+ if (status === 200 && body?.ok === true && body.sent === true) return { kind: "sent" };
201
+ if (status === 404 && body?.error === "no_instance") return { kind: "absent" };
202
+ const detail = typeof body?.error === "string" ? body.error : text.slice(0, 200);
203
+ return { kind: "unanswered", reason: `HTTP ${status} — ${detail}` };
204
+ }
205
+
134
206
  /** How the status read ended on the shim: the platform's status word, no such
135
207
  * instance, or the engine failing by reason. */
136
208
  export type InstanceStatusOutcome =
@@ -72,6 +72,9 @@ export interface CostsConfig {
72
72
  snapshot: {
73
73
  /** Hours between two reads of the billing sources. */
74
74
  everyHours: number;
75
+ /** The platform-namespaced channel (`slack:C…`) told when takes keep failing and when they
76
+ * land again (`ALERT_AFTER_FAILURES` in a row); absent → the status alone says so. */
77
+ alertChannel?: string;
75
78
  };
76
79
  }
77
80
 
@@ -82,13 +85,15 @@ const DEFAULT_ANTHROPIC_ADMIN_ENV = "ANTHROPIC_ADMIN_KEY";
82
85
  * default — both sources bucket by UTC day, and the page is read about as often. */
83
86
  export const COSTS_SNAPSHOT_EVERY_HOURS = Object.freeze({ default: 24, min: 1, max: 168 });
84
87
 
85
- /** `costs.snapshot`: absent → the default interval; a value outside the bounds or not a whole number throws by name. */
88
+ /** `costs.snapshot`: absent → the default interval and no alert channel; a value outside the
89
+ * bounds or not a whole number throws by name; `alertChannel`, when given, is a platform-namespaced id. */
86
90
  function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
87
91
  if (raw === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
88
92
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
89
93
  throw new Error("costs.snapshot must be a mapping");
90
- const every = (raw as Record<string, unknown>).everyHours;
91
- if (every === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
94
+ const r = raw as Record<string, unknown>;
95
+ // `undefined` alone defaults: a bare `everyHours:` key (null) is a malformed value, refused below by name.
96
+ const every = r.everyHours === undefined ? COSTS_SNAPSHOT_EVERY_HOURS.default : r.everyHours;
92
97
  if (
93
98
  typeof every !== "number" ||
94
99
  !Number.isInteger(every) ||
@@ -98,7 +103,10 @@ function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
98
103
  throw new Error(
99
104
  `costs.snapshot.everyHours must be a whole number of hours between ${COSTS_SNAPSHOT_EVERY_HOURS.min} and ${COSTS_SNAPSHOT_EVERY_HOURS.max}`,
100
105
  );
101
- return { everyHours: every };
106
+ if (r.alertChannel === undefined) return { everyHours: every };
107
+ if (typeof r.alertChannel !== "string" || !/^[a-z]+:.+$/.test(r.alertChannel))
108
+ throw new Error("costs.snapshot.alertChannel must be a platform-namespaced channel id (`slack:C…`)");
109
+ return { everyHours: every, alertChannel: r.alertChannel };
102
110
  }
103
111
 
104
112
  function labelMap(raw: unknown, what: string): Record<string, string> {
@@ -762,10 +770,15 @@ export function anthropicTokensCostUsd(modelId: string, t: AnthropicTokens): num
762
770
  // ---- range ----------------------------------------------------------------------------
763
771
 
764
772
  const DEFAULT_DAYS = 30;
765
- /** The widest range the page offers — and the window a costs snapshot is read for. */
766
- export const MAX_DAYS = 90;
767
-
768
- /** `?days=N` → a UTC date range ending today. Garbage → default; clamped 1..90. */
773
+ /** The widest range the page offers — and the window a costs snapshot is read for.
774
+ * Cloudflare's analytics on a Workers account answer no range wider than 4w4d
775
+ * (32 days) and hold no data older than that (`cannot request a time range
776
+ * wider than 4w4d`, `cannot request data older than 4w4d` — measured live), so
777
+ * a wider window is a refused read, not more history: 31 UTC days is the most
778
+ * a take can ask for. */
779
+ export const MAX_DAYS = 31;
780
+
781
+ /** `?days=N` → a UTC date range ending today. Garbage → default; clamped 1..31. */
769
782
  export function resolveRange(daysParam: string | null, now: Date = new Date(systemClock())): DateRange {
770
783
  const parsed = daysParam === null ? NaN : Number(daysParam);
771
784
  const days = Number.isInteger(parsed) ? Math.min(MAX_DAYS, Math.max(1, parsed)) : DEFAULT_DAYS;
@@ -1070,7 +1083,7 @@ export class AnthropicCostReportSource implements LlmCostSource {
1070
1083
  if (range.from >= endExclusive) return { rows, closedThrough };
1071
1084
  let page: string | null = null;
1072
1085
  for (let i = 0; ; i++) {
1073
- // Like the non-USD check: refuse rather than mis-sum. A ≤90-day range at
1086
+ // Like the non-USD check: refuse rather than mis-sum. A ≤31-day range at
1074
1087
  // limit=31 is at most 3 pages, so hitting the cap means the API changed.
1075
1088
  if (i >= MAX_COST_PAGES)
1076
1089
  throw new Error(
@@ -0,0 +1,21 @@
1
+ // Where a preset's harness word is set (docs/reference/specs/harness.md item
2
+ // 8), in a module with no imports of its own: the run record (`run_meta.harnessScope`)
3
+ // and the timeline fold read it, and both are compiled into the Workers' and
4
+ // the dashboard's programs, where the roster's neighbours — the harness
5
+ // objects, their Node-only process code — must not follow. The roster
6
+ // re-exports these names, so every reader inside the bot can still take them
7
+ // from there.
8
+
9
+ /** The scopes a word is set at, most specific first — the order the resolution
10
+ * walks the layers: the requester's own scope, the channel's, then the
11
+ * deployment's top-level `harness` block, which is the defaults layer under
12
+ * its one spelling. What `run_meta.harnessScope` and the config block name, so
13
+ * a reader of a run on OpenCode never guesses whose word put it there. */
14
+ export const HARNESS_SCOPES = ["user", "channel", "defaults"] as const;
15
+ export type HarnessScope = (typeof HARNESS_SCOPES)[number];
16
+
17
+ /** Whether a value is one of the scopes a word is set at: the timeline's test
18
+ * for the field a record carries. */
19
+ export function isHarnessScope(value: unknown): value is HarnessScope {
20
+ return typeof value === "string" && (HARNESS_SCOPES as readonly string[]).includes(value);
21
+ }
@@ -2,6 +2,7 @@
2
2
  // the node-free contract the memory Worker and web app compile with their own
3
3
  // tsconfigs — importing prDescription.ts would drag zod into those graphs.
4
4
  import type { PrDescription, RenderedTourStep } from "./prDescriptionTypes.js";
5
+ import type { HarnessScope } from "./harness/scope.js";
5
6
 
6
7
  /** The `pr_description` review artifact minus the event envelope
7
8
  * (docs/reference/specs/reading-diff.md item 7). */
@@ -97,6 +98,11 @@ export type RunNoteKind =
97
98
  /** The PR head moved while a review ran and the same run is re-reviewing at
98
99
  * the new head (agent-review.md item 12). Published by the dispatcher. */
99
100
  | "head_moved"
101
+ /** The run loop threw and the run finishes `failed`: the summary is the
102
+ * error's message, redacted and capped, so the run page says why a failed
103
+ * run failed even when the reply is never delivered (run-history.md).
104
+ * Published by the run loop's catch, before the finish. */
105
+ | "run_failed"
100
106
  /** An MCP server configured for this agent did not answer discovery
101
107
  * (docs/reference/specs/mcp-tools.md item 8); the run proceeds without its tools. One
102
108
  * note per server, published by the dispatcher before the first turn. */
@@ -187,6 +193,11 @@ export type RunNoteKind =
187
193
  * item 7): the summary names the tool and the rule; the model read the same
188
194
  * reason as the tool's result. Published by the bot's authorize route. */
189
195
  | "tool_refused"
196
+ /** A ship coding child's budget ended with work still in the tree: the run
197
+ * loop committed and pushed it to the unit's branch (or says plainly that
198
+ * there was nothing to push), so a re-issue starts from the partial work
199
+ * (docs/reference/specs/agent-ship.md item 8). */
200
+ | "budget_salvage"
190
201
  /** The native loop's stuck-loop guard: the same tool call failed identically
191
202
  * six times in a row and the run was forced into its write-up. Written by
192
203
  * no loop since that loop's deletion; a record from before it may carry it. */
@@ -205,6 +216,7 @@ export const RUN_NOTE_KINDS = [
205
216
  "stopped",
206
217
  "spans_dropped",
207
218
  "head_moved",
219
+ "run_failed",
208
220
  "mcp_unavailable",
209
221
  "follow_up",
210
222
  "resumed",
@@ -220,6 +232,7 @@ export const RUN_NOTE_KINDS = [
220
232
  "harness_error",
221
233
  "policy_refusal",
222
234
  "tool_refused",
235
+ "budget_salvage",
223
236
  "stuck_loop",
224
237
  ] as const satisfies readonly RunNoteKind[];
225
238
  type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
@@ -519,9 +532,15 @@ export type RunEvent =
519
532
  /** The request's trace id (docs/reference/specs/tracing.md), once the root exists. */
520
533
  traceId?: string;
521
534
  /** The harness the run is driven by (`Harness.name`; docs/reference/specs/harness.md
522
- * item 8): `pi` today. Absent on a command run, which starts no process,
535
+ * items 8 and 10): the word the scopes resolved for the preset, `pi`
536
+ * when none named it. Absent on a command run, which starts no process,
523
537
  * and on a record written before the seam existed. Additive. */
524
538
  harness?: string;
539
+ /** Whose word put the run on that harness (item 10): the requester's own
540
+ * scope, the channel's, or the deployment's top-level block. Absent when
541
+ * no scope named the preset — the roster's default — and on records
542
+ * written before the word was a scope setting. */
543
+ harnessScope?: HarnessScope;
525
544
  effort?: string;
526
545
  repo?: string;
527
546
  ref?: string;
@@ -56,6 +56,8 @@ export interface LiveRunMeta {
56
56
  /** The bound credential behind the person (authorization.md item 15): a
57
57
  * resume or restart dispatches under ITS grants again, never the person's. */
58
58
  authenticatedAs?: string;
59
+ /** The app that relayed the request for the person (authorization.md item 14): a resume or restart keeps app ∩ person at the gates. */
60
+ postedBy?: string;
59
61
  effort?: string;
60
62
  ref?: string;
61
63
  headSha?: string;
@@ -109,6 +109,9 @@ export interface RunRecord {
109
109
  receivedAt?: number;
110
110
  sealedAt?: number;
111
111
  replyOk?: boolean;
112
+ /** Why a `replyOk: false` reply was not delivered (e.g. "no channel to deliver
113
+ * to", run-history.md item 38). Absent when the reply was delivered or none was made. */
114
+ replyNote?: string;
112
115
  stepCount?: number;
113
116
  schema?: number;
114
117
  status: RunStatus;
@@ -783,6 +786,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
783
786
  if (r[key] !== undefined && !isFiniteNumber(r[key])) return false;
784
787
  }
785
788
  if (r.replyOk !== undefined && typeof r.replyOk !== "boolean") return false;
789
+ if (r.replyNote !== undefined && typeof r.replyNote !== "string") return false;
786
790
  for (const key of ["stepCount", "schema"] as const) {
787
791
  if (r[key] !== undefined && (!isFiniteNumber(r[key]) || !Number.isInteger(r[key]) || (r[key] as number) < 0))
788
792
  return false;
@@ -428,8 +428,10 @@ function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
428
428
  return (
429
429
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
430
430
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
431
- `Right before the push, fetch ${onto} again and rebase once more if it moved during verify, ` +
432
- `so the pull request is not born conflicting.`
431
+ `Push the branch as soon as the change exists and its cheapest proving checks pass — before the project's ` +
432
+ `full verification, which runs after that push with any fix as a further commit; an unpushed tree does not ` +
433
+ `survive the run's end. Right before each push, fetch ${onto} again and rebase once more if it moved while ` +
434
+ `you worked, so the pull request is not born conflicting.`
433
435
  );
434
436
  }
435
437
 
@@ -448,6 +448,8 @@ export type CoordinatorAction =
448
448
  recover?: { runId: string };
449
449
  }
450
450
  | { type: "merge"; step: string; prNumber: number; headSha: string }
451
+ /** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
452
+ | { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
451
453
  | { type: "sleep"; step: string; ms: number }
452
454
  | { type: "end"; step: string; ending: UnitEnding };
453
455
 
@@ -508,6 +510,7 @@ export type StepReturn =
508
510
  // fired, or a person merged — so the runner merged nothing (`by: other`).
509
511
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
510
512
  | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
513
+ | { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
511
514
  | { type: "sleep"; step: string };
512
515
 
513
516
  /** How one unit's pipeline ended — the truthful vocabulary the ship pipeline
@@ -628,7 +631,7 @@ type Phase =
628
631
  dead?: "failed" | "interrupted";
629
632
  }
630
633
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
631
- | { at: "merge-sleep"; pr: PrRef; headSha: string; n: number; since: number }
634
+ | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number }
632
635
  | { at: "ended" };
633
636
 
634
637
  export interface UnitPipelineState {
@@ -668,9 +671,16 @@ export const WAIT_MARGIN_MS = 5 * MIN;
668
671
  * margin and the merge poll are the same number), and it keeps a round to a
669
672
  * few steps: a coding child's 45 minutes are ten waits and ten reads. */
670
673
  export const WAIT_CHUNK_MS = 5 * MIN;
671
- /** How often the runner asks for the merge while the guards are still pending, and for how long at most. */
672
- export const MERGE_POLL_MS = 5 * MIN;
674
+ /** How long the merge step waits for the guards at most. While checks are
675
+ * pending the machine waits on the intake's `checks-settled-<head>` event
676
+ * (http-ingress.md item 12) — one bounded wait per ask, the remainder of this
677
+ * cap, as the fallback when the event never arrives. */
673
678
  export const MERGE_WAIT_MAX_MS = 60 * MIN;
679
+ /** One merge wait's fallback timeout: the old poll's cadence. The event wakes
680
+ * the machine at once when the intake delivers it; without one (the webhook
681
+ * not configured, a delivery lost) the door is still re-asked every chunk, so
682
+ * a merge is never slower than the poll it replaced. */
683
+ export const MERGE_WAIT_CHUNK_MS = 5 * MIN;
674
684
  /** A `busy` without the live run's id: nothing to wait on, so a short sleep before the spawn is asked again. */
675
685
  export const BUSY_RETRY_MS = 2 * MIN;
676
686
 
@@ -816,8 +826,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
816
826
  };
817
827
  case "merge":
818
828
  return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
819
- case "merge-sleep":
820
- return { type: "sleep", step: `${unit}/merge/sleep/${p.n}`, ms: MERGE_POLL_MS };
829
+ case "merge-wait":
830
+ return {
831
+ type: "wait-checks",
832
+ step: `${unit}/merge/wait/${p.n}`,
833
+ headSha: p.headSha,
834
+ timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, MERGE_WAIT_MAX_MS - (s.clock - p.since))),
835
+ };
821
836
  case "ended":
822
837
  return { type: "end", step: `${unit}/end`, ending: s.ending! };
823
838
  }
@@ -1349,11 +1364,11 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1349
1364
  reviewRounds: s.reviewRounds,
1350
1365
  });
1351
1366
  return {
1352
- state: { ...clocked, phase: { at: "merge-sleep", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since } },
1367
+ state: { ...clocked, phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since } },
1353
1368
  notes: [],
1354
1369
  };
1355
1370
  }
1356
- case "merge-sleep":
1371
+ case "merge-wait":
1357
1372
  return {
1358
1373
  state: { ...s, phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since } },
1359
1374
  notes: [],
@@ -8,7 +8,7 @@
8
8
  * field. */
9
9
 
10
10
  export type Backend = "local" | "resident" | "sandbox" | "e2b";
11
- export type Channel = "slack" | "http" | "mcp" | "cli";
11
+ export type Channel = "slack" | "http" | "mcp" | "cli" | "web";
12
12
 
13
13
  /** Every attribute key any span may carry, with its value domain. */
14
14
  export interface AttrDomain {
@@ -14,8 +14,8 @@ export interface PurgeableBinding {
14
14
  export type PurgeDecision = { ok: true; purge: string[]; keptLive: string[] } | { ok: false; error: string };
15
15
 
16
16
  /** Thread-key namespaces real channels mint (docs/reference/specs/http-ingress.md item 1,
17
- * slack, mcp, the CLI). A purge is for synthetic keys only. */
18
- const PRODUCTION_NAMESPACES = new Set(["slack", "http", "mcp", "cli"]);
17
+ * slack, mcp, the CLI, the web chat). A purge is for synthetic keys only. */
18
+ const PRODUCTION_NAMESPACES = new Set(["slack", "http", "mcp", "cli", "web"]);
19
19
 
20
20
  /** A prefix must be a whole namespace (`load:`) or longer (`load:r1:`), never
21
21
  * empty, never a bare partial namespace, never a production namespace. */
@@ -9,8 +9,10 @@
9
9
  * cycle, and these say the snapshot (or the container that would restore it)
10
10
  * cannot be used — never a provision failure, which would loop against the
11
11
  * same broken build. `runtime-unreachable` is item 64's last rung: a recreated
12
- * container that did not answer either. */
13
- export const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable)/;
12
+ * container that did not answer either; `infra-streak` is item 67's: a
13
+ * recreated container whose cycles kept failing in the resident's own steps. */
14
+ export const REHYDRATION_FAILURE_RE =
15
+ /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable|infra-streak)/;
14
16
 
15
17
  /** How many auto-rebuilds one resident gets inside one window. A rebuild is a
16
18
  * clone, an install and a build (minutes to half an hour) that holds the cap
@@ -67,10 +67,9 @@ export type FetchReason = "missing-ref" | "stale-tip" | "returnable-ref";
67
67
  * lost and a ref a person named that the thread never pushed has no way
68
68
  * back, so neither pays this fetch: the refresh cycle is their freshness.
69
69
  * - otherwise null.
70
- * A fetch that STILL leaves the tip elsewhere (a push racing this attach, or
71
- * a force-push) is not this function's concern: the attach proceeds on the
72
- * fetched tip and reports it, and the reviewed-head guard decides what a
73
- * review of it may do. */
70
+ * A fetch that STILL leaves the tip elsewhere (a push racing this attach, a
71
+ * force-push, or a fetch that failed) is `attachTarget`'s concern: the attach
72
+ * is refused `stale-tip` rather than run at a commit nobody asked for. */
74
73
  export function mirrorFetchReason(input: {
75
74
  refExists: boolean;
76
75
  mirrorSha?: string;
@@ -85,8 +84,16 @@ export function mirrorFetchReason(input: {
85
84
 
86
85
  /** What the attach checks out, once the mirror is as fresh as it will get. */
87
86
  export type AttachTarget =
88
- /** The bound ref is in the mirror: clone its tip, as always. */
87
+ /** The bound ref is in the mirror, and its tip is the commit the caller
88
+ * named (or the caller named none): clone its tip, as always. */
89
89
  | { kind: "ref" }
90
+ /** The bound ref is in the mirror but its tip is NOT the commit the caller
91
+ * named, even after the fetch — the mirror is behind it (the fetch failed
92
+ * or was skipped) or ahead of it (a push raced the attach). Refused: a run
93
+ * executes at the sha it asked for, or not on this resident; the caller
94
+ * falls back cold at the requested commit. `tip` is null when the tip
95
+ * could not be read — never assumed fresh. */
96
+ | { kind: "stale-tip"; tip: string | null; want: string }
90
97
  /** The ref is gone but the commit the caller expects is in the mirror — a
91
98
  * merged PR's branch was deleted while `refs/pull/N/head` (a `--mirror`
92
99
  * clone carries every ref) still holds its head: check that commit out,
@@ -96,16 +103,22 @@ export type AttachTarget =
96
103
  /** Neither: the attach is refused as `unknown-ref`. */
97
104
  | { kind: "unknown-ref" };
98
105
 
99
- /** The attach target after the fetch (item 51). The ref wins whenever it
100
- * exists — a detached tree is only for a ref that is gone; a caller that
101
- * named no commit, or whose commit the mirror does not hold either, gets the
102
- * refusal it always got. */
106
+ /** The attach target after the fetch (item 51). A ref that exists is cloned
107
+ * at its tip when the caller named no commit or the tip is that commit;
108
+ * a tip that is any other commit is `stale-tip`, refused. A detached tree is
109
+ * only for a ref that is gone; a caller that named no commit, or whose commit
110
+ * the mirror does not hold either, gets the refusal it always got. */
103
111
  export function attachTarget(input: {
104
112
  refExists: boolean;
105
113
  wantSha: string | null;
106
114
  commitInMirror: boolean;
115
+ /** The ref's tip in the mirror after the fetch; null or absent when it could not be read. */
116
+ tipSha?: string | null;
107
117
  }): AttachTarget {
108
- if (input.refExists) return { kind: "ref" };
118
+ if (input.refExists) {
119
+ if (input.wantSha === null || input.tipSha === input.wantSha) return { kind: "ref" };
120
+ return { kind: "stale-tip", tip: input.tipSha ?? null, want: input.wantSha };
121
+ }
109
122
  if (input.wantSha !== null && input.commitInMirror) return { kind: "sha", sha: input.wantSha };
110
123
  return { kind: "unknown-ref" };
111
124
  }
@@ -0,0 +1,89 @@
1
+ // A refresh cycle that keeps failing in the resident's OWN steps heals itself
2
+ // (docs/reference/specs/resident-repos.md item 67). Pure: the Worker's
3
+ // `refreshFailed` bumps the row and acts on the rung; the cycle's gate asks
4
+ // `parksOnRepeat` before it counts a degraded reason toward the park streak.
5
+ //
6
+ // The split this module draws: a step that runs the repository's own command
7
+ // (the onboard-time command table — install, build, test) failing is evidence
8
+ // about the repository, and the right response is to park and wait for the
9
+ // head to be fixed, which is what the park streak does. Every other step is
10
+ // the resident's machinery — git against the mirror, the probes, the markers,
11
+ // the restores — and a failure there that repeats is the container or the
12
+ // disk gone wrong, which a rebuild does fix. Item 64 already climbs this
13
+ // ladder for one signature (the control port that never answers); this is
14
+ // the same ladder for every other resident-step failure.
15
+
16
+ import { STALE_SWEEP_SUFFIX } from "./residentSteps.js";
17
+
18
+ /** The steps that run the repository's own commands. */
19
+ export const REPO_COMMAND_STEPS: ReadonlySet<string> = new Set(["deps-install", "build", "test"]);
20
+
21
+ /** Whether a failed step ran the repository's command (evidence about the
22
+ * repo) rather than the resident's own machinery. The stale-process sweep
23
+ * that precedes a repo step is ours. */
24
+ export function isRepoCommandStep(step: string): boolean {
25
+ if (step.endsWith(STALE_SWEEP_SUFFIX)) return false;
26
+ return REPO_COMMAND_STEPS.has(step);
27
+ }
28
+
29
+ /** The step named by a `<step>-failed:` reason (`classifyRefreshFailure`'s
30
+ * plain-failure shape), or null for every other reason. */
31
+ export function stepOfFailedReason(reason: string): string | null {
32
+ const m = /^([a-z][a-z0-9-]*)-failed:/.exec(reason);
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ /** Whether a repeated degraded reason should PARK the resident (the 6-hour
37
+ * cadence, `DEGRADED_PARK_AFTER_CYCLES`): only evidence about the repository
38
+ * or about GitHub — a repo-command step's failure, or a reason that names no
39
+ * step at all (`github-unreachable`). A resident-step failure never parks:
40
+ * the ladder below is its escalation, and parking would only slow it. */
41
+ export function parksOnRepeat(reason: string): boolean {
42
+ const step = stepOfFailedReason(reason);
43
+ return step === null || isRepoCommandStep(step);
44
+ }
45
+
46
+ /** The ladder's rungs: consecutive cycles failed in resident steps. */
47
+ export const INFRA_STREAK_RECREATE_AT = 3;
48
+ export const INFRA_STREAK_DOWN_AT = 5;
49
+
50
+ export type InfraStreakRung = "count" | "recreate" | "down";
51
+
52
+ /** Count 1–2: record and let the next cycle try. Count 3: destroy the
53
+ * container, snapshots kept — the next cycle restores from the snapshot onto
54
+ * a fresh disk (item 64's rung 3). Count 4: the recreated container's one
55
+ * cycle of its own. Count ≥ 5: a fresh container failed the same way; `down`
56
+ * with a rehydration-flavored reason, so item 36's transition rebuild is the
57
+ * exit. */
58
+ export function infraStreakRung(count: number): InfraStreakRung {
59
+ if (!Number.isFinite(count) || count < 1) {
60
+ throw new RangeError(`infra-streak rung needs a count of at least 1, got ${count}`);
61
+ }
62
+ if (count >= INFRA_STREAK_DOWN_AT) return "down";
63
+ if (count === INFRA_STREAK_RECREATE_AT) return "recreate";
64
+ return "count";
65
+ }
66
+
67
+ /** The persisted row (`resident:infraStreak`): the last failing step, the
68
+ * consecutive count and the span. */
69
+ export interface InfraStreakRow {
70
+ step: string;
71
+ count: number;
72
+ firstAt: string;
73
+ lastAt: string;
74
+ }
75
+
76
+ const INFRA_STREAK_ACTION: Readonly<Record<Exclude<InfraStreakRung, "count">, string>> = {
77
+ recreate: "the container was destroyed, snapshots kept; the next cycle restores from the snapshot",
78
+ down: "a recreated container failed the same way; rebuilding",
79
+ };
80
+
81
+ /** The reason a rung records: `infra-streak:` so the gate, the serviceability
82
+ * check and item 36's eligibility all read it as the resident's, never the repo's. */
83
+ export function infraStreakReason(row: InfraStreakRow, rung: Exclude<InfraStreakRung, "count">): string {
84
+ return `infra-streak: ${row.count} consecutive cycles failed in the resident's own steps (last: ${row.step}, since ${row.firstAt}) — ${INFRA_STREAK_ACTION[rung]}`;
85
+ }
86
+
87
+ export function isInfraStreakReason(reason: string): boolean {
88
+ return /^infra-streak: /.test(reason);
89
+ }
@@ -132,6 +132,10 @@ export interface RefreshFailure {
132
132
  * with the attempt count (the ladder below decides what the resident does
133
133
  * about it); real failures keep `<step>-failed:`. */
134
134
  reason: string;
135
+ /** The step that failed, as the caller named it (`refresh` for a failure
136
+ * between steps): item 67 reads whether it ran the repository's own command
137
+ * or the resident's machinery. */
138
+ step: string;
135
139
  interrupted: boolean;
136
140
  diskFull: boolean;
137
141
  runtimeUnreachable: boolean;
@@ -234,6 +238,7 @@ export function classifyRefreshFailure(input: {
234
238
  const { step, message } = input;
235
239
  if (input.runtimeUnreachable) {
236
240
  return {
241
+ step,
237
242
  interrupted: false,
238
243
  diskFull: false,
239
244
  runtimeUnreachable: true,
@@ -242,6 +247,7 @@ export function classifyRefreshFailure(input: {
242
247
  }
243
248
  if (isDiskFullMessage(message)) {
244
249
  return {
250
+ step,
245
251
  interrupted: false,
246
252
  diskFull: true,
247
253
  runtimeUnreachable: false,
@@ -251,6 +257,7 @@ export function classifyRefreshFailure(input: {
251
257
  const timedOut = /\(timed out\)/.test(message);
252
258
  if (!timedOut && (INTERRUPTION_SIGNATURE.test(message) || RUNTIME_REPLACEMENT_WORDING.test(message))) {
253
259
  return {
260
+ step,
254
261
  interrupted: true,
255
262
  diskFull: false,
256
263
  runtimeUnreachable: false,
@@ -259,13 +266,20 @@ export function classifyRefreshFailure(input: {
259
266
  }
260
267
  if (input.freeKiB !== undefined && input.freeKiB !== null && input.freeKiB < DISK_FULL_FREE_KIB) {
261
268
  return {
269
+ step,
262
270
  interrupted: false,
263
271
  diskFull: true,
264
272
  runtimeUnreachable: false,
265
273
  reason: diskFullReason({ step, message, freeKiB: input.freeKiB }),
266
274
  };
267
275
  }
268
- return { interrupted: false, diskFull: false, runtimeUnreachable: false, reason: `${step}-failed: ${message}` };
276
+ return {
277
+ step,
278
+ interrupted: false,
279
+ diskFull: false,
280
+ runtimeUnreachable: false,
281
+ reason: `${step}-failed: ${message}`,
282
+ };
269
283
  }
270
284
 
271
285
  // -- the runtime that never answers --------------------------------------------
@@ -49,6 +49,7 @@ export const RESIDENT_STEP_LABELS = {
49
49
  "worktree-clean": "cleaning the worktree",
50
50
  "clean-workspace": "cleaning the workspace",
51
51
  "clean-before-restore": "cleaning before the restore",
52
+ "break-mirror": "removing the mirror's objects (fault injection)",
52
53
  "unmount-restores": "unmounting earlier restores",
53
54
  "mirror-restore-extract": "extracting the mirror snapshot",
54
55
  "checkout-restore-extract": "extracting the checkout snapshot",