@coreplane/switchboard 1.245.0 → 1.247.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 (153) hide show
  1. package/dist/assets/config/config.example.yaml +1 -1
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +14 -4
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +10 -11
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +17 -1
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +5 -3
  7. package/dist/assets/project.json +10 -2
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +32 -8
  10. package/dist/assets/src/core/budgets.ts +37 -7
  11. package/dist/assets/src/core/coordinator/contract.ts +29 -4
  12. package/dist/assets/src/core/coordinator/driver.ts +7 -1
  13. package/dist/assets/src/core/reviewVerdict.ts +138 -6
  14. package/dist/assets/src/core/runEvents.ts +34 -2
  15. package/dist/assets/src/core/runRecord.ts +38 -16
  16. package/dist/assets/src/core/ship/contract.ts +7 -0
  17. package/dist/assets/src/core/ship/coordinator.ts +24 -3
  18. package/dist/assets/src/core/ship/renewal.ts +10 -3
  19. package/dist/assets/src/core/trace/attrs.ts +9 -0
  20. package/dist/assets/src/execution/bashTimeout.ts +46 -2
  21. package/dist/assets/src/execution/residentRefresh.ts +9 -5
  22. package/dist/assets/src/execution/sandboxErrors.ts +9 -4
  23. package/dist/assets/web/dist/.vite/manifest.json +474 -485
  24. package/dist/assets/web/dist/assets/AppShell-DAtHiuI6.js +1 -0
  25. package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +2 -0
  26. package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +1 -0
  27. package/dist/assets/web/dist/assets/HomePage-DYxC0izY.js +2 -0
  28. package/dist/assets/web/dist/assets/InputMenu-C6aPpe30.js +1 -0
  29. package/dist/assets/web/dist/assets/NotFoundPage-DtE-GgTk.js +1 -0
  30. package/dist/assets/web/dist/assets/PendingTurnRow-BuRre8it.js +1 -0
  31. package/dist/assets/web/dist/assets/ResidentDetailPage-DLIpWYOc.js +1 -0
  32. package/dist/assets/web/dist/assets/ResidentsIndexPage-6LipuDjR.js +1 -0
  33. package/dist/assets/web/dist/assets/RunFoldRow-V-iSy64e.js +1 -0
  34. package/dist/assets/web/dist/assets/RunRoutePage-DUalB1u2.js +9 -0
  35. package/dist/assets/web/dist/assets/RunsIndexPage-B9Ba1KdD.js +1 -0
  36. package/dist/assets/web/dist/assets/RunsTabs-YSbUu5py.js +1 -0
  37. package/dist/assets/web/dist/assets/ScheduledPage-KdjLtD_7.js +1 -0
  38. package/dist/assets/web/dist/assets/SettingSelect-BIzAsLk1.js +1 -0
  39. package/dist/assets/web/dist/assets/SettingsPage-IT5l_NaL.js +1 -0
  40. package/dist/assets/web/dist/assets/StatusDot-DBHAl4Il.js +1 -0
  41. package/dist/assets/web/dist/assets/Tooltip-_LEjptLV.js +1 -0
  42. package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +1 -0
  43. package/dist/assets/web/dist/assets/{angular-html-qXhb9ti8.js → angular-html-BjeQZdCq.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-DzvdyoUZ.js → angular-ts-ZGdORrL2.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-Ck7Ru2Sm.js → apl-DhvV_X93.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-CwehvReb.js → astro-DiRSE4Ug.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-WSyVbSFK.js → blade-D8Fr5Wvq.js} +1 -1
  48. package/dist/assets/web/dist/assets/{seed-DFfT5S_N.js → browser-B6T5hjfR.js} +2 -2
  49. package/dist/assets/web/dist/assets/{c-DMHI1wDz.js → c-BwbC64D4.js} +1 -1
  50. package/dist/assets/web/dist/assets/{chapel-BF8AYK8_.js → chapel-DgmqRK97.js} +1 -1
  51. package/dist/assets/web/dist/assets/{cobol-UAfImuWu.js → cobol-DJod2RNK.js} +1 -1
  52. package/dist/assets/web/dist/assets/{coffee-D54hi96d.js → coffee-D1Y2CgBY.js} +1 -1
  53. package/dist/assets/web/dist/assets/{cpp-BRWfLgBc.js → cpp-CsO97YOM.js} +1 -1
  54. package/dist/assets/web/dist/assets/{crystal-CSqC5deT.js → crystal-CJ7m2tOc.js} +1 -1
  55. package/dist/assets/web/dist/assets/{css-D4ZuRFGk.js → css-B2M-NKoj.js} +1 -1
  56. package/dist/assets/web/dist/assets/{dist-73ZMn_Mg.js → dist-BcYPGOBL.js} +2 -2
  57. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +1 -0
  58. package/dist/assets/web/dist/assets/{edge-DDPtNbZc.js → edge-CFVW-m9B.js} +1 -1
  59. package/dist/assets/web/dist/assets/{elixir-BTQkQfsJ.js → elixir-B2mtRgCr.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elm-B_PkLzGZ.js → elm-2teVAwCi.js} +1 -1
  61. package/dist/assets/web/dist/assets/{erb-DfjIMVM_.js → erb-LAbeNqen.js} +1 -1
  62. package/dist/assets/web/dist/assets/eventSource-Bj_dZf6M.js +1 -0
  63. package/dist/assets/web/dist/assets/{git-rebase-DP8wmn7K.js → git-rebase-DnhzH_sp.js} +1 -1
  64. package/dist/assets/web/dist/assets/{glimmer-js-G12VK2gK.js → glimmer-js-DPKjXf1-.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glimmer-ts-D3XiSI8E.js → glimmer-ts-CABXnt5z.js} +1 -1
  66. package/dist/assets/web/dist/assets/{glsl-DOKAhMj-.js → glsl-Dz4-3-gJ.js} +1 -1
  67. package/dist/assets/web/dist/assets/{graphql-CJB_fmON.js → graphql-ByWUD1DL.js} +1 -1
  68. package/dist/assets/web/dist/assets/{hack-BHqtRWap.js → hack-CdmOK2K-.js} +1 -1
  69. package/dist/assets/web/dist/assets/{haml-DwotyarS.js → haml-DzegMpd2.js} +1 -1
  70. package/dist/assets/web/dist/assets/{handlebars-B6fDaA3U.js → handlebars-B5NeiP0e.js} +1 -1
  71. package/dist/assets/web/dist/assets/{html-s52taj36.js → html-Zg_NHLv9.js} +1 -1
  72. package/dist/assets/web/dist/assets/{html-derivative-C5bpQmmI.js → html-derivative-DZf9JNzh.js} +1 -1
  73. package/dist/assets/web/dist/assets/{http-99zC935r.js → http-CVFrQZeN.js} +1 -1
  74. package/dist/assets/web/dist/assets/{hurl-Bgk-4-0x.js → hurl-L5brWAJe.js} +1 -1
  75. package/dist/assets/web/dist/assets/{java-CuV_c5I0.js → java-hFphyZ4R.js} +1 -1
  76. package/dist/assets/web/dist/assets/{javascript-CZlOvSMQ.js → javascript-CAJO1WKX.js} +1 -1
  77. package/dist/assets/web/dist/assets/{jinja-BuXeZ4Tu.js → jinja-N4A4srXI.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jison-DIuP0hkG.js → jison-D3BZa_iU.js} +1 -1
  79. package/dist/assets/web/dist/assets/{json-CZ74vfoD.js → json-DxD1Qh8W.js} +1 -1
  80. package/dist/assets/web/dist/assets/{jsx-BNjRs8Vj.js → jsx-CHhag69S.js} +1 -1
  81. package/dist/assets/web/dist/assets/{julia-OCE-fFOg.js → julia-CrhkZ6Tt.js} +1 -1
  82. package/dist/assets/web/dist/assets/{just-CtvD87WF.js → just-D0LFOuhq.js} +1 -1
  83. package/dist/assets/web/dist/assets/{latex-C3kf86OR.js → latex-BHAlwCXK.js} +1 -1
  84. package/dist/assets/web/dist/assets/{liquid-B1WI--Kp.js → liquid-CkaLfCWh.js} +1 -1
  85. package/dist/assets/web/dist/assets/{lua-DtoT0gKg.js → lua-B1h4aAzP.js} +1 -1
  86. package/dist/assets/web/dist/assets/main-DUfSE0dj.js +28 -0
  87. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +1 -0
  88. package/dist/assets/web/dist/assets/{marko-BE2MC6Yt.js → marko-D6CG4hvX.js} +1 -1
  89. package/dist/assets/web/dist/assets/{mdc-BwO4weXJ.js → mdc-CVQzimAU.js} +1 -1
  90. package/dist/assets/web/dist/assets/{nginx-BTuOgGVC.js → nginx-h-9Ir54h.js} +1 -1
  91. package/dist/assets/web/dist/assets/{nim-CiIUM-oV.js → nim-DHVI30sp.js} +1 -1
  92. package/dist/assets/web/dist/assets/{org-L78DEWB-.js → org-AasAXfMF.js} +1 -1
  93. package/dist/assets/web/dist/assets/{perl-KqHOm4Ft.js → perl-BVi5aTEy.js} +1 -1
  94. package/dist/assets/web/dist/assets/{php-C41XbEkn.js → php-qOkCeUKr.js} +1 -1
  95. package/dist/assets/web/dist/assets/{pug-B12-4i-k.js → pug-8TnUGvEX.js} +1 -1
  96. package/dist/assets/web/dist/assets/{qml--fw2Up5c.js → qml-CpJbhoxn.js} +1 -1
  97. package/dist/assets/web/dist/assets/{r-Db1JBXrC.js → r-JkUNcAJW.js} +1 -1
  98. package/dist/assets/web/dist/assets/{razor-BYpVBgbu.js → razor-CtU4ws5a.js} +1 -1
  99. package/dist/assets/web/dist/assets/{regexp-BMqGLSPc.js → regexp-J2zA6ceN.js} +1 -1
  100. package/dist/assets/web/dist/assets/{rst-B63n7-Sa.js → rst-CMCvf-yZ.js} +1 -1
  101. package/dist/assets/web/dist/assets/{ruby-D3VI8UYv.js → ruby-ChLBY_iR.js} +1 -1
  102. package/dist/assets/web/dist/assets/{sas-DxRf8ZAR.js → sas-cwn4x2VY.js} +1 -1
  103. package/dist/assets/web/dist/assets/{scss-DRRA6d6A.js → scss-jSrJlzLI.js} +1 -1
  104. package/dist/assets/web/dist/assets/{shellscript-CoOBe3_y.js → shellscript-D5nAd0gs.js} +1 -1
  105. package/dist/assets/web/dist/assets/{shellsession-DOV1HYxf.js → shellsession-BZY4-Tdh.js} +1 -1
  106. package/dist/assets/web/dist/assets/{soy-Acpmpp1W.js → soy-COppPOKr.js} +1 -1
  107. package/dist/assets/web/dist/assets/{sql-DeT9N-fA.js → sql-Blc_6dOA.js} +1 -1
  108. package/dist/assets/web/dist/assets/{sseReplay-DPXVxhL5.js → sseReplay-DXC7kGbN.js} +3 -3
  109. package/dist/assets/web/dist/assets/{stata-CbxXAMO4.js → stata-DRv3OY-h.js} +1 -1
  110. package/dist/assets/web/dist/assets/{surrealql-BdKbi4hc.js → surrealql-qMv_eeJ6.js} +1 -1
  111. package/dist/assets/web/dist/assets/{svelte-tAcjDM7R.js → svelte-d1Edb6Rz.js} +1 -1
  112. package/dist/assets/web/dist/assets/{templ-CObINNJj.js → templ-CUMSIOXG.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tex-BYwpZT1J.js → tex-BGZQA-Ei.js} +1 -1
  114. package/dist/assets/web/dist/assets/{ts-tags-BciREL5R.js → ts-tags-Z9ulxVsq.js} +1 -1
  115. package/dist/assets/web/dist/assets/{tsx-CRiv3Pr3.js → tsx-oI4bI3Vf.js} +1 -1
  116. package/dist/assets/web/dist/assets/{twig-DuZZ1Ej0.js → twig-D_RXwBIX.js} +1 -1
  117. package/dist/assets/web/dist/assets/{typescript-jkHz_5HZ.js → typescript-BgqQDYoN.js} +1 -1
  118. package/dist/assets/web/dist/assets/{typst-DOac4Gal.js → typst-5MW-KRDd.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-CgOX5wXA.js → vue-CZhHHsPm.js} +1 -1
  120. package/dist/assets/web/dist/assets/{vue-html-Duu99VNw.js → vue-html-DlYy2d8v.js} +1 -1
  121. package/dist/assets/web/dist/assets/{vue-vine-Bmj_wvRJ.js → vue-vine-CJDnx-qn.js} +1 -1
  122. package/dist/assets/web/dist/assets/wallClock-DmaC1sKA.js +1 -0
  123. package/dist/assets/web/dist/assets/{xml-BtUIVeWH.js → xml-BRBDk65Q.js} +1 -1
  124. package/dist/assets/web/dist/assets/{xsl-wX71n1E5.js → xsl-BlCykPxo.js} +1 -1
  125. package/dist/assets/web/dist/assets/{yaml-CdLdXvmz.js → yaml-BqW48szf.js} +1 -1
  126. package/dist/cli.js +2856 -1224
  127. package/package.json +1 -1
  128. package/dist/assets/web/dist/assets/AppShell-qqs_p96-.js +0 -1
  129. package/dist/assets/web/dist/assets/CostsPage-Dd9aXxxI.js +0 -2
  130. package/dist/assets/web/dist/assets/DeliveryPage-D90FTjYq.js +0 -1
  131. package/dist/assets/web/dist/assets/HomePage-VLTFopnl.js +0 -2
  132. package/dist/assets/web/dist/assets/InputMenu-lQP3ia9D.js +0 -1
  133. package/dist/assets/web/dist/assets/NotFoundPage-8ViskpN3.js +0 -1
  134. package/dist/assets/web/dist/assets/PendingTurnRow-D2gHef5v.js +0 -1
  135. package/dist/assets/web/dist/assets/ResidentDetailPage-nWLN8BOd.js +0 -1
  136. package/dist/assets/web/dist/assets/ResidentsIndexPage-D3Q48CJW.js +0 -1
  137. package/dist/assets/web/dist/assets/RunFoldRow-CHy_IfZG.js +0 -1
  138. package/dist/assets/web/dist/assets/RunRoutePage-C6kX5om4.js +0 -9
  139. package/dist/assets/web/dist/assets/RunsIndexPage-DjxGj1Q5.js +0 -1
  140. package/dist/assets/web/dist/assets/RunsTabs-WyeG5tKb.js +0 -1
  141. package/dist/assets/web/dist/assets/ScheduledPage-B8D5AU4d.js +0 -1
  142. package/dist/assets/web/dist/assets/SettingSelect-UTHsqUUS.js +0 -1
  143. package/dist/assets/web/dist/assets/SettingsPage-BoW7Zjzt.js +0 -1
  144. package/dist/assets/web/dist/assets/StatusDot-BGHureCJ.js +0 -1
  145. package/dist/assets/web/dist/assets/Tooltip-CiP8qPd7.js +0 -1
  146. package/dist/assets/web/dist/assets/UnitRoutePage-2zEeRBP1.js +0 -1
  147. package/dist/assets/web/dist/assets/durationTone-D51enaGR.js +0 -1
  148. package/dist/assets/web/dist/assets/eventSource-By0B_w4S.js +0 -1
  149. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +0 -1
  150. package/dist/assets/web/dist/assets/main-ClluP-Qf.js +0 -28
  151. package/dist/assets/web/dist/assets/main-CpkwzHX6.css +0 -1
  152. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +0 -1
  153. package/dist/assets/web/dist/assets/wallClock-DD_fPuSY.js +0 -1
@@ -247,14 +247,146 @@ export function formatDisposition(d: FindingDisposition): string {
247
247
  return `${d.findingId}: ${d.disposition}${d.note ? ` — ${d.note}` : ""}`;
248
248
  }
249
249
 
250
+ /** Where the posted body's file links point: the PR's repository and the
251
+ * head the review is pinned to (the post-step's `commitId`). */
252
+ export interface ReviewBodyTarget {
253
+ /** `owner/name` */
254
+ repo: string;
255
+ /** The pinned head, full sha. */
256
+ head: string;
257
+ }
258
+
259
+ /** The verdict word the callout carries, per verdict kind (or its absence). */
260
+ function verdictWord(verdict: ReviewVerdict | undefined): string {
261
+ if (!verdict) return "No verdict";
262
+ return verdict.verdict === "approve" ? "Approved" : "Changes requested";
263
+ }
264
+
265
+ /** GitHub's alert callout kind per verdict: an approve is a note, changes
266
+ * requested a warning, a missing verdict a caution. */
267
+ function calloutKind(verdict: ReviewVerdict | undefined): "NOTE" | "WARNING" | "CAUTION" {
268
+ if (!verdict) return "CAUTION";
269
+ return verdict.verdict === "approve" ? "NOTE" : "WARNING";
270
+ }
271
+
272
+ /** `2 findings: 1 minor, 1 nit` — counted on the ladder, most severe first;
273
+ * an empty array is `no findings`, no array `findings not itemized`. */
274
+ function findingCounts(findings: readonly Finding[] | undefined): string {
275
+ if (findings === undefined) return "findings not itemized";
276
+ if (findings.length === 0) return "no findings";
277
+ const by = FINDING_SEVERITIES.map((sev) => [sev, findings.filter((f) => f.severity === sev).length] as const)
278
+ .filter(([, n]) => n > 0)
279
+ .map(([sev, n]) => `${n} ${sev}`)
280
+ .join(", ");
281
+ return `${findings.length} finding${findings.length === 1 ? "" : "s"}: ${by}`;
282
+ }
283
+
284
+ /** A table cell: pipes escaped so the row holds, already one line. */
285
+ const cell = (text: string): string => oneLine(text).replace(/\|/g, "\\|");
286
+
287
+ /** Whether a finding's `file` is a path a blob URL can point at: no
288
+ * whitespace, no backtick, not a URL. */
289
+ const isRepoPath = (file: string): boolean => /^[^\s`]+$/.test(file) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(file);
290
+
291
+ /** The `Where` cell: the location in code, linked at the pinned head when the
292
+ * file is a path and a target is known. */
293
+ function whereCell(f: Finding, target: ReviewBodyTarget | undefined): string {
294
+ const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
295
+ const label = `\`${cell(location)}\``;
296
+ if (!target || !isRepoPath(f.file)) return label;
297
+ const path = encodeURI(f.file).replace(/#/g, "%23").replace(/\?/g, "%3F");
298
+ const url = `https://github.com/${target.repo}/blob/${target.head}/${path}${f.line !== undefined ? `#L${f.line}` : ""}`;
299
+ return `[${label}](${url})`;
300
+ }
301
+
302
+ /** The machine-readable marker the body ends with — the verdict, the head and
303
+ * the findings index as JSON inside an HTML comment, for a scanner that would
304
+ * otherwise parse the token line. `-->` can never occur inside it. */
305
+ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTarget | undefined): string {
306
+ const head = target?.head ?? verdict?.head;
307
+ const payload = {
308
+ verdict: verdict?.verdict ?? "none",
309
+ ...(head ? { head } : {}),
310
+ ...(verdict?.findings
311
+ ? {
312
+ findings: verdict.findings.map((f) => ({
313
+ id: f.id,
314
+ severity: f.severity,
315
+ file: f.file,
316
+ ...(f.line !== undefined ? { line: f.line } : {}),
317
+ })),
318
+ }
319
+ : {}),
320
+ };
321
+ return `<!-- switchboard:verdict ${JSON.stringify(payload).replace(/-->/g, "--\\u003e")} -->`;
322
+ }
323
+
250
324
  /**
251
- * Build the body posted to GitHub: the deterministic verdict line, the
252
- * compact findings list (when present), a blank line, then the model's
253
- * review text. Never starts with "LGTM" unless the verdict is `approve`.
325
+ * Build the body posted to GitHub, rendered from the typed verdict
326
+ * (docs/reference/specs/agent-review.md item 5b): the deterministic verdict
327
+ * line first (the auto-approve contract — never `LGTM:` unless the verdict is
328
+ * `approve`), a GitHub alert callout with the verdict word, the pinned head
329
+ * and the finding counts, the findings as a table (severity, id + title, the
330
+ * file linked at the head), the model's text folded under `Full review`, and
331
+ * the machine-readable marker last. The prose decides nothing above the fold.
254
332
  */
255
- export function buildReviewPostBody(answer: string, verdict: ReviewVerdict | undefined): string {
256
- const head = [verdictLine(verdict), ...(verdict?.findings ?? []).map((f) => `- ${formatFinding(f)}`)];
257
- return `${head.join("\n")}\n\n${answer.trim()}`;
333
+ export function buildReviewPostBody(
334
+ answer: string,
335
+ verdict: ReviewVerdict | undefined,
336
+ target?: ReviewBodyTarget,
337
+ ): string {
338
+ const facts = [
339
+ `**${verdictWord(verdict)}**`,
340
+ ...(target ? [`head \`${target.head.slice(0, 7)}\``] : []),
341
+ verdict ? findingCounts(verdict.findings) : "the run ended without a submit_verdict call",
342
+ ];
343
+ const parts: string[] = [verdictLine(verdict), `> [!${calloutKind(verdict)}]\n> ${facts.join(" · ")}`];
344
+ const findings = verdict?.findings ?? [];
345
+ if (findings.length > 0) {
346
+ parts.push(
347
+ [
348
+ "| Severity | Finding | Where |",
349
+ "| --- | --- | --- |",
350
+ ...findings.map((f) => `| ${f.severity} | **${cell(f.id)}** ${cell(f.title)} | ${whereCell(f, target)} |`),
351
+ ].join("\n"),
352
+ );
353
+ }
354
+ const prose = answer.trim();
355
+ if (prose) parts.push(`<details>\n<summary>Full review</summary>\n\n${prose}\n\n</details>`);
356
+ parts.push(verdictMarker(verdict, target));
357
+ return parts.join("\n\n");
358
+ }
359
+
360
+ /**
361
+ * The channel reply for a review run, rendered from the typed verdict
362
+ * (agent-review.md item 5b): the verdict line, one bullet per finding, then
363
+ * where the review was posted and the run link. The model's write-up rides
364
+ * along only when it landed nowhere else — no GitHub post (Slack-only, an
365
+ * opt-out, a guard refusal) or findings not itemized (a list that says
366
+ * nothing is no substitute) — so the review's text is always somewhere a
367
+ * person reads it. No verdict → the bare answer with the link, as before.
368
+ */
369
+ export function buildReviewChannelReply(input: {
370
+ answer: string;
371
+ verdict: ReviewVerdict | undefined;
372
+ /** The PR the post landed on, or undefined when nothing was posted. */
373
+ posted: { repo: string; number: number } | undefined;
374
+ liveUrl: string | undefined;
375
+ }): string {
376
+ const { answer, verdict, posted, liveUrl } = input;
377
+ const tail = [
378
+ ...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
379
+ ...(liveUrl ? [`[Live run](${liveUrl})`] : []),
380
+ ].join(" · ");
381
+ const blocks: string[] = [];
382
+ if (!verdict) blocks.push(answer);
383
+ else {
384
+ blocks.push([verdictLine(verdict), ...(verdict.findings ?? []).map((f) => `- ${formatFinding(f)}`)].join("\n"));
385
+ const compact = posted !== undefined && verdict.findings !== undefined;
386
+ if (!compact && answer.trim()) blocks.push(answer.trim());
387
+ }
388
+ if (tail) blocks.push(tail);
389
+ return blocks.join("\n\n");
258
390
  }
259
391
 
260
392
  // --- Dispositions (the coding side's answer to findings) -------------------
@@ -94,6 +94,10 @@ export type RunNoteKind =
94
94
  | "sandbox_restarted"
95
95
  | "stop_requested"
96
96
  | "stopped"
97
+ /** A stop the loop or turn had to ask pi for again reached it: the series a
98
+ * failed abort's write opened (its `harness_error` line) closes here with
99
+ * the count of re-asks (harness-pi item 16). Written by the pi harness. */
100
+ | "stop_landed"
97
101
  /** Setup spans the request's stream sink had to drop before this run was
98
102
  * bound (docs/reference/specs/tracing.md): `summary` says how many, `from`/`to` the
99
103
  * interval, which the partition reports as not recorded. */
@@ -142,6 +146,16 @@ export type RunNoteKind =
142
146
  * after the attach, before the first turn, so the run page explains a
143
147
  * sandbox run that shows resident steps. */
144
148
  | "cold_sandbox"
149
+ /** The run ledger would not track this run (docs/reference/specs/run-history.md
150
+ * item 54): its reservation met the row of a run this process was closing
151
+ * (a restart from its request, or the fresh turn for its follow-ups), waited
152
+ * for that finish, and the row still stood — the finish failed or was
153
+ * refused — so no handoff, resume or reclaim reaches this run, and its
154
+ * record reaches the store when it finishes. The summary names that run and
155
+ * how its finish ended; published by the dispatcher right after the
156
+ * reservation, before the attach, and carried on the card's label — the bot
157
+ * log's warning is not the only witness. */
158
+ | "ledger_untracked"
145
159
  /** The resident kept this thread's binding where it was instead of moving
146
160
  * it onto the branch the thread's own run opened a pull request on
147
161
  * (docs/reference/specs/resident-repos.md item 16): the summary names the
@@ -248,6 +262,7 @@ export const RUN_NOTE_KINDS = [
248
262
  "sandbox_restarted",
249
263
  "stop_requested",
250
264
  "stopped",
265
+ "stop_landed",
251
266
  "spans_dropped",
252
267
  "head_moved",
253
268
  "run_failed",
@@ -258,6 +273,7 @@ export const RUN_NOTE_KINDS = [
258
273
  "description_turn",
259
274
  "verdict_turn",
260
275
  "cold_sandbox",
276
+ "ledger_untracked",
261
277
  "rebind_refused",
262
278
  "work_left_behind",
263
279
  "workspace_torn_down",
@@ -373,6 +389,7 @@ export function isHeadMaterial(event: RunEvent): boolean {
373
389
  event.kind === "mcp_unavailable" ||
374
390
  event.kind === "spans_dropped" ||
375
391
  event.kind === "cold_sandbox" ||
392
+ event.kind === "ledger_untracked" ||
376
393
  event.kind === "rebind_refused"
377
394
  );
378
395
  case "span_start":
@@ -445,7 +462,7 @@ export type RouteInputValue = RouteInputLeafOrList | RouteInputObject3;
445
462
  * `pasted` — the typed line that followed a hand-back in its thread, with the
446
463
  * same receipt, ran. A routed read carries no outcome: it is not a decision
447
464
  * about a state change. */
448
- export type RouteOutcome = "hand_back" | "offered" | "confirmed" | "pasted";
465
+ export type RouteOutcome = "hand_back" | "offered" | "confirmed" | "pasted" | "refused";
449
466
 
450
467
  export type RunEvent =
451
468
  /** `callId` is the provider's tool_use id — the explicit pair key between a
@@ -794,7 +811,18 @@ export type RunEvent =
794
811
  * round and its fix round share an index. Published by the ship pipeline
795
812
  * straight to the registry (like `pr_opened`), never through the runner.
796
813
  * Additive: unknown → ignored. */
797
- | { type: "ship_round"; index: number; agent: string; outcome: ShipRoundOutcome; seq?: number; at?: number }
814
+ | {
815
+ type: "ship_round";
816
+ index: number;
817
+ agent: string;
818
+ outcome: ShipRoundOutcome;
819
+ /** The severity gate fired on this approve (agent-ship item 9): the level
820
+ * in force and the gated findings as `id (severity)` — a mismatch the
821
+ * child's own parser should have made impossible, kept visible. */
822
+ gate?: { level: string; findings: string[] };
823
+ seq?: number;
824
+ at?: number;
825
+ }
798
826
  /** The request router's decision (docs/reference/specs/routing-and-config.md
799
827
  * item 21): the preset a plain message was routed to, the one-line reason
800
828
  * the router gave (redacted, capped — the same text the card's `routed:`
@@ -836,6 +864,10 @@ export type RunEvent =
836
864
  input?: { readonly [key: string]: RouteInputValue };
837
865
  receipt?: string;
838
866
  outcome?: RouteOutcome;
867
+ /** The refusal's code (src/core/refusal.ts) when `outcome` is `refused`
868
+ * (record 0054): a refusal after a command was bound is a run
869
+ * record, and the door report counts it by cause and code. */
870
+ refusalCode?: string;
839
871
  handBackRunId?: string;
840
872
  seq?: number;
841
873
  at?: number;
@@ -291,6 +291,16 @@ export function pushedBranchesOf(events: readonly RunEvent[]): PushedBranch[] {
291
291
  return [...byRef].map(([ref, pr]) => ({ ref, pr }));
292
292
  }
293
293
 
294
+ /** A call a run's ending may have left running in its workspace (harness.md
295
+ * item 13): the id the record pairs by, the tool, and the line the
296
+ * `workspace_torn_down` note names it by — the `tool_call`'s own summary
297
+ * while the record holds it, else the tool and the id. */
298
+ export interface CallInFlight {
299
+ callId: string;
300
+ tool: string;
301
+ summary: string;
302
+ }
303
+
294
304
  /** The tool calls a run's ending may have left running in its workspace, in
295
305
  * the record's order — what the workspace's release reads to tear the
296
306
  * workspace down rather than wait for an idle the command would refuse, and
@@ -299,26 +309,38 @@ export function pushedBranchesOf(events: readonly RunEvent[]): PushedBranch[] {
299
309
  * settled, whatever the run's status, and stays cut whatever lands for it
300
310
  * later — an unmarked result (a post-turn's bridge reads the earlier
301
311
  * execution's aborted settle as its own), or the call announced again under
302
- * the same id; a call with no result at
303
- * all was left open by a run that failed, was interrupted or hard-stopped,
304
- * while on a run that completed or stopped softly it is a relayed tool that
305
- * ran in the bot or a result lost to a transport gap, nothing running. Calls
306
- * and results pair by id; a result for a call the record never opened counts
307
- * for nothing. */
308
- export function callsInFlight(
309
- events: readonly RunEvent[],
310
- status: RunStatus,
311
- ): Extract<RunEvent, { type: "tool_call" }>[] {
312
- const calls = new Map<
313
- string,
314
- { call: Extract<RunEvent, { type: "tool_call" }>; state: "open" | "cut" | "settled" }
315
- >();
312
+ * the same id; a call with no result at all was left open by a run that
313
+ * failed, was interrupted or hard-stopped, while on a run that completed or
314
+ * stopped softly it is a relayed tool that ran in the bot or a result lost to
315
+ * a transport gap, nothing running. Calls and results pair by id. The events
316
+ * are the registry's bounded backlog (`runRegistry/backlog.ts` drops the
317
+ * oldest past its count or byte bound), so on a long run the line of a
318
+ * command that hung early may be gone while its cut result — the ending's,
319
+ * landing last — survives: a cut result for a call the record no longer
320
+ * holds is the harness's own word that the call was ended and not settled,
321
+ * and counts by itself, named by its tool and id; an unmarked result for a
322
+ * call never opened counts for nothing. */
323
+ export function callsInFlight(events: readonly RunEvent[], status: RunStatus): CallInFlight[] {
324
+ const calls = new Map<string, { call: CallInFlight; state: "open" | "cut" | "settled" }>();
316
325
  for (const e of events) {
317
326
  if (e.type === "tool_call" && e.callId !== undefined) {
318
- if (calls.get(e.callId)?.state !== "cut") calls.set(e.callId, { call: e, state: "open" });
327
+ const seen = calls.get(e.callId);
328
+ if (seen?.state === "cut") {
329
+ // Announced again after its cut — or after an orphan cut, the line the
330
+ // backlog evicted now written again: the line is the record's, the
331
+ // state stays the cut's.
332
+ seen.call.tool = e.tool;
333
+ seen.call.summary = e.summary;
334
+ } else calls.set(e.callId, { call: { callId: e.callId, tool: e.tool, summary: e.summary }, state: "open" });
319
335
  } else if (e.type === "tool_result" && e.callId !== undefined) {
320
336
  const seen = calls.get(e.callId);
321
- if (seen !== undefined && seen.state !== "cut") seen.state = e.cut === true ? "cut" : "settled";
337
+ if (seen === undefined) {
338
+ if (e.cut === true)
339
+ calls.set(e.callId, {
340
+ call: { callId: e.callId, tool: e.tool, summary: `${e.tool} (call ${e.callId})` },
341
+ state: "cut",
342
+ });
343
+ } else if (seen.state !== "cut") seen.state = e.cut === true ? "cut" : "settled";
322
344
  }
323
345
  }
324
346
  const orderly = status === "completed" || status === "stopped_soft";
@@ -129,6 +129,13 @@ export const CONTRACT_SECTION_HEADINGS = {
129
129
  * repository's rules file (~15k characters) and a long unit both inside it. */
130
130
  export const DEFAULT_CONTRACT_MAX_CHARS = 48_000;
131
131
 
132
+ /** The most of a plan a reader hands the parsers: ten times the tool clip
133
+ * (`MAX_FILE_CHARS`, `src/execution/githubApi.ts`), which a plan that grows by
134
+ * amendment passes — the program plan crossed 400k characters and the seed
135
+ * then saw its first 25 units alone. A plan past this bound is refused by
136
+ * name, never parsed short. */
137
+ export const PLAN_MAX_CHARS = 2_000_000;
138
+
132
139
  const UNIT_HEADING = /^### (U\d+)\. (.+?)\s*$/;
133
140
  const ANY_HEADING = /^#{2,3} /;
134
141
  const BULLET = /^- \*\*([^*]+)\*\*:(?:\s?(.*))?$/;
@@ -600,8 +600,22 @@ export type UnitEnding =
600
600
  /** What a transition tells the driver beyond the next action: a round boundary
601
601
  * the card draws (`shipRoundHeader`) and the run stream records, and the end. */
602
602
  export type CoordinatorNote =
603
- | { type: "round"; index: number; agent: ChildPreset; outcome: ShipRoundOutcome }
603
+ | {
604
+ type: "round";
605
+ index: number;
606
+ agent: ChildPreset;
607
+ outcome: ShipRoundOutcome;
608
+ /** The severity gate fired on this approve (agent-ship item 9): the
609
+ * findings at or above the level in force, as `id (severity)`. The
610
+ * child's own verdict parser holds an approve to the same level
611
+ * (agent-review item 5a), so this should never be set — when it is, the
612
+ * verdict was parsed at another level (a lost directive, an older
613
+ * record, a harness around `submit_verdict`) and the row, the card and
614
+ * the log say so instead of routing silently into the findings step. */
615
+ gate?: { level: AddressSeverity; findings: string[] };
616
+ }
604
617
  | { type: "ended"; ending: UnitEnding };
618
+ type RoundNote = Extract<CoordinatorNote, { type: "round" }>;
605
619
 
606
620
  /** How the pipeline's budget went, in ms: the coding rounds' (round 0 and the
607
621
  * findings steps), the review rounds', and everything else (branching,
@@ -740,7 +754,7 @@ export const WAIT_MARGIN_MS = SHIP_WAIT.marginMinutes * MIN;
740
754
  * one chunk of the runner's time, not the child's whole budget. Five minutes
741
755
  * is the machine's one cadence for asking the bot what it cannot be told (the
742
756
  * margin and the merge poll are the same number), and it keeps a round to a
743
- * few steps: a coding child's 45 minutes are ten waits and ten reads. */
757
+ * few steps: a coding child's 90 minutes are eighteen waits and eighteen reads. */
744
758
  export const WAIT_CHUNK_MS = SHIP_WAIT.chunkMinutes * MIN;
745
759
  /** The merge wait is a round of its own: its minutes are carved from the
746
760
  * pipeline's remainder when the door is first asked (the merge wait's ask and
@@ -939,7 +953,10 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
939
953
  return { state: { ...s, phase: ENDED, ending }, notes: [...notes, { type: "ended", ending }] };
940
954
  }
941
955
 
942
- const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote => ({
956
+ /** A gated finding as the gate note names it: `F1 (minor)`. */
957
+ const gateLabel = (f: Finding): string => `${f.id} (${f.severity})`;
958
+
959
+ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
943
960
  type: "round",
944
961
  index: round.index,
945
962
  agent: presetOf(round.kind),
@@ -1114,6 +1131,10 @@ function settleReview(
1114
1131
  const level = next.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
1115
1132
  const gated = findingsAtOrAbove(verdict.findings, level);
1116
1133
  if (gated.length > 0) {
1134
+ // The gate fired — which the child's parser should have made impossible
1135
+ // (agent-review item 5a): the round note carries what it caught, so the
1136
+ // mismatch is seen and not just routed around.
1137
+ notes[0] = { ...roundNote(round, verdict.verdict), gate: { level, findings: gated.map(gateLabel) } };
1117
1138
  if (mode !== undefined)
1118
1139
  return end(
1119
1140
  next,
@@ -24,8 +24,12 @@ export interface ProgressInput {
24
24
  /** The heads the segment's coding run pushed (`RunRecord.pushed`). */
25
25
  pushed: readonly PushedHeadFact[];
26
26
  /** The head the segment started from — the previous segment's recorded sha.
27
- * Absent on a fresh branch, where any push is progress. */
27
+ * Absent on a fresh branch, where the base head stands in for it. */
28
28
  startHead?: string;
29
+ /** The base branch's head the unit's branch was created at. On a fresh
30
+ * branch (no `startHead`) a push of this head is the branch's creation,
31
+ * not the unit's progress; with neither known, any push is progress. */
32
+ baseHead?: string;
29
33
  /** When the segment's lease began; a push stamped before it is not this segment's. */
30
34
  leaseStartedAt?: number;
31
35
  /** The previous segment's handoff and this one's, when both exist. */
@@ -53,7 +57,10 @@ export function progressOf(input: ProgressInput): Progress {
53
57
  if (last !== undefined) {
54
58
  const newerThanLease =
55
59
  input.leaseStartedAt === undefined || last.at === undefined || last.at >= input.leaseStartedAt;
56
- const moved = input.startHead === undefined || !sameSha(last.sha, input.startHead);
60
+ // A fresh branch starts at the base head, so that head is the bar a push
61
+ // must move past when no segment recorded a start.
62
+ const startHead = input.startHead ?? input.baseHead;
63
+ const moved = startHead === undefined || !sameSha(last.sha, startHead);
57
64
  if (newerThanLease && moved) return { progressed: true, by: "push", sha: last.sha };
58
65
  }
59
66
  const pushWhy = `no head newer than the lease's start was pushed to \`${input.branch}\``;
@@ -95,7 +102,7 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
95
102
  const detail =
96
103
  input.grant.renewals === 0
97
104
  ? "the grant holds no renewals"
98
- : `the grant's ${input.grant.renewals} renewal${input.grant.renewals === 1 ? "" : "s"} are spent`;
105
+ : `the grant's ${input.grant.renewals} renewal${input.grant.renewals === 1 ? " is" : "s are"} spent`;
99
106
  return { renew: false, why: "grant_exhausted", detail, renewalsLeft };
100
107
  }
101
108
  const cap = input.grant.costCapUsd;
@@ -26,6 +26,11 @@ export interface AttrDomain {
26
26
  requester: "message" | "relay-footer" | "thread-parent" | "bot";
27
27
  // dispatch.* / run.* / post.*
28
28
  outcome: string;
29
+ /** The refusal's code (src/core/refusal.ts) — on the `dispatch.refuse` span
30
+ * and the request's root, so refusals are countable from the trace alone. */
31
+ refusal: string;
32
+ /** The refusal's cause, from the one code→cause table in src/core/refusal.ts. */
33
+ cause: "request" | "policy" | "system";
29
34
  count: number;
30
35
  backend: Backend;
31
36
  // run.command
@@ -114,6 +119,8 @@ export type SpanAttrs = { readonly [K in SpanAttrKey]?: AttrDomain[K] };
114
119
  const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
115
120
  "runId",
116
121
  "outcome",
122
+ "refusal",
123
+ "cause",
117
124
  "command",
118
125
  "route",
119
126
  "host",
@@ -163,6 +170,8 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
163
170
  dedupe: "string",
164
171
  requester: "string",
165
172
  outcome: "string",
173
+ refusal: "string",
174
+ cause: "string",
166
175
  count: "number",
167
176
  backend: "string",
168
177
  command: "string",
@@ -4,7 +4,7 @@
4
4
  // Deliberately free of node: imports so wrangler can bundle it into Workers.
5
5
  // The numbers are rows of src/core/budgets.ts (docs/decisions/0046); this file
6
6
  // keeps their names for the readers that learned them here.
7
- import { ALLOWANCES, BASH_COMMAND, MINUTE_MS } from "../core/budgets.js";
7
+ import { ALLOWANCES, ATTACH_REQUEST_MIN_MS, BASH_COMMAND, MINUTE_MS } from "../core/budgets.js";
8
8
 
9
9
  /** Default per-command budget when the caller passes no timeoutMs. */
10
10
  export const BASH_TIMEOUT_MS = BASH_COMMAND.defaultMinutes * MINUTE_MS;
@@ -50,8 +50,10 @@ export function clampBashTimeout(requested: unknown): number {
50
50
  export type RunBudget =
51
51
  { kind: "unchanged" } | { kind: "clipped"; timeoutMs: number; note: string } | { kind: "exhausted"; note: string };
52
52
 
53
+ /** Whole seconds for the notes below, never negative: the two clip notes phrase the reserve alike. */
54
+ const secs = (ms: number): number => Math.max(0, Math.round(ms / 1000));
55
+
53
56
  export function bashBudgetWithinRun(wantedMs: number, remainingMs: number): RunBudget {
54
- const secs = (ms: number) => Math.max(0, Math.round(ms / 1000));
55
57
  const cap = Math.trunc(remainingMs - RUN_DEADLINE_RESERVE_MS);
56
58
  if (cap < BASH_TIMEOUT_MIN_MS) {
57
59
  return {
@@ -69,6 +71,48 @@ export function bashBudgetWithinRun(wantedMs: number, remainingMs: number): RunB
69
71
  };
70
72
  }
71
73
 
74
+ /** The bound on an attach request an executor opens while its run has
75
+ * `remainingMs` of wall clock left (`ResidentExecutorOptions.remainingMs`):
76
+ * `bounded` by the attach's own default, `BASH_TIMEOUT_MS` — an attach may
77
+ * clone and install deps — clipped to what the run has left less the same
78
+ * write-up reserve a command keeps back, so an attach never holds a run past
79
+ * its lease and the model keeps its write-up; never raised above the default.
80
+ * Inside the reserve, or with less than an attach's floor past it
81
+ * (`ATTACH_REQUEST_MIN_MS`: a bound too short to clone would be cut and
82
+ * struck as a rollout), `exhausted`: the request is not opened at all — the
83
+ * resident would run the attach to its end server-side for a run that is
84
+ * ending, and the caller would get a deadline it could read as waitable — and
85
+ * the note names the run's clock and the bound that refused (the reserve, or
86
+ * the floor past it): it is the one sentence every reader carries, the
87
+ * executor's refusal and the relaunch's note alike. The command's own budget
88
+ * never bounds an attach: the attach is not the command, and the command's
89
+ * one-second floor is the model's to choose. */
90
+ export type AttachBound = { kind: "bounded"; timeoutMs: number } | { kind: "exhausted"; note: string };
91
+
92
+ export function attachBoundWithinRun(remainingMs: number): AttachBound {
93
+ const left = Math.trunc(remainingMs - RUN_DEADLINE_RESERVE_MS);
94
+ // The one sentence every reader of an exhausted bound carries — the
95
+ // executor's refusal, the relaunch's note — worded by the bound that refused.
96
+ if (left <= 0) {
97
+ return {
98
+ kind: "exhausted",
99
+ note:
100
+ `the run has ${secs(remainingMs)}s of wall clock left, inside the ${secs(RUN_DEADLINE_RESERVE_MS)}s ` +
101
+ "write-up reserve, so no attach was opened",
102
+ };
103
+ }
104
+ if (left < ATTACH_REQUEST_MIN_MS) {
105
+ return {
106
+ kind: "exhausted",
107
+ note:
108
+ `the run has ${secs(remainingMs)}s of wall clock left, only ${secs(left)}s past the ` +
109
+ `${secs(RUN_DEADLINE_RESERVE_MS)}s write-up reserve — under the ${secs(ATTACH_REQUEST_MIN_MS)}s an attach needs — ` +
110
+ "so no attach was opened",
111
+ };
112
+ }
113
+ return { kind: "bounded", timeoutMs: Math.min(BASH_TIMEOUT_MS, left) };
114
+ }
115
+
72
116
  /** The line a timed-out command shows the model: names the limit that fired
73
117
  * and the knob that raises it, so the model can self-correct (re-run with a
74
118
  * larger timeoutMs, split the command, or background it) instead of guessing
@@ -180,11 +180,15 @@ export function killStaleBuildProcessesCommand(user: string, dir: string): strin
180
180
  }
181
181
 
182
182
  /** Signature of a step killed from OUTSIDE its own budget: the exit status of
183
- * SIGTERM (128 + 15), bash's "Session terminated" on a killed login shell, or
184
- * a tool naming the signal. A bare "killed" is NOT enough — compilers and
185
- * OOM messages say it too — and a step the cycle itself timed out is a real
186
- * failure however it died. */
187
- const INTERRUPTION_SIGNATURE = /\bexit 143\b|Session terminated|SIGTERM|^restore-interrupted:/;
183
+ * SIGTERM (128 + 15) — as a shell reports it (`exit 143`) or as the platform
184
+ * words a container that died under a short exec ("Container exited with
185
+ * unexpected exit code: 143"; seen live on the wake path's ready-stamp probe
186
+ * when a stop landed on it) — bash's "Session terminated" on a killed login
187
+ * shell, or a tool naming the signal. Only 143: the platform's same wording
188
+ * with any other code (1, 137, 139) is a genuine crash and stays a failure.
189
+ * A bare "killed" is NOT enough — compilers and OOM messages say it too —
190
+ * and a step the cycle itself timed out is a real failure however it died. */
191
+ const INTERRUPTION_SIGNATURE = /\bexit 143\b|exit code: 143\b|Session terminated|SIGTERM|^restore-interrupted:/;
188
192
 
189
193
  /** The wordings git gives when the MIRROR itself is broken: its `origin`
190
194
  * remote gone from `config`, the repository not a repository, an object store
@@ -154,10 +154,15 @@ export const SANDBOX_STARTING_EXPLANATION =
154
154
  "the thread's sandbox container is starting (image pull, boot, runtime) — nothing ran yet; the request is re-sent once it is up";
155
155
 
156
156
  /** The executor waits at most this long for a container to start, whatever
157
- * the command's own budget: the platform's own start allowances (30 s for an
158
- * instance, 90 s for the port) plus a slow image pull fit inside it, and a
159
- * 60 s command is never killed by a two-minute start it did not cause. */
160
- export const SANDBOX_START_WAIT_MAX_MS = 5 * 60_000;
157
+ * the command's own budget: the platform's own start allowances (the
158
+ * instance grant, 90 s for the port) plus a slow image pull fit inside it,
159
+ * and a 60 s command is never killed by a two-minute start it did not cause.
160
+ * Ten minutes, not five: under a midday burst the platform admitted about
161
+ * seven starts at once and granted the rest 2.5 to 5 minutes later — two
162
+ * threads of twenty-four died at the five-minute mark with their container
163
+ * a minute away. A run that waits ten minutes for its container starts; one
164
+ * that dies at five does not, and nothing else in the run was in progress. */
165
+ export const SANDBOX_START_WAIT_MAX_MS = 10 * 60_000;
161
166
 
162
167
  /** Backoff between re-sends while a container starts: 5 s, 10 s, then 15 s.
163
168
  * A start takes tens of seconds, not minutes, so the poll is denser than the