@coreplane/switchboard 1.236.0 → 1.237.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 (121) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +175 -62
  2. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +36 -10
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/authorize.ts +25 -6
  7. package/dist/assets/src/core/authz/policy.ts +9 -0
  8. package/dist/assets/src/core/authz/types.ts +15 -1
  9. package/dist/assets/src/core/coordinator/driver.ts +5 -1
  10. package/dist/assets/src/core/runEvents.ts +10 -6
  11. package/dist/assets/src/core/ship/coordinator.ts +23 -1
  12. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  13. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  14. package/dist/assets/src/execution/residentHead.ts +34 -8
  15. package/dist/assets/src/execution/residentRebind.ts +88 -31
  16. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  17. package/dist/assets/web/dist/.vite/manifest.json +408 -408
  18. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  19. package/dist/assets/web/dist/assets/{CostsPage-BfJsol9y.js → CostsPage-DKk9OjB0.js} +1 -1
  20. package/dist/assets/web/dist/assets/{DeliveryPage-CvInfu7y.js → DeliveryPage-Kz6js49i.js} +1 -1
  21. package/dist/assets/web/dist/assets/{NotFoundPage-d37EP8hS.js → NotFoundPage-BXLrQQzy.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ResidentDetailPage-DQSD0SZD.js → ResidentDetailPage-Dc23v_8v.js} +1 -1
  23. package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
  24. package/dist/assets/web/dist/assets/{RunFoldRow-Bngn4GjF.js → RunFoldRow-mkUj7WEN.js} +1 -1
  25. package/dist/assets/web/dist/assets/{RunRoutePage-a-tMrirB.js → RunRoutePage-CQI17__g.js} +3 -3
  26. package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
  27. package/dist/assets/web/dist/assets/{RunsTabs-CyCdOhJ2.js → RunsTabs-XpCqeWu4.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ScheduledPage-ByCmyrDs.js → ScheduledPage-CdCVyNbh.js} +1 -1
  29. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
  30. package/dist/assets/web/dist/assets/{StatusDot-D-BPSPpw.js → StatusDot-DmNHX6am.js} +1 -1
  31. package/dist/assets/web/dist/assets/{Tooltip-DPDx5tXT.js → Tooltip-qVnJTgSt.js} +1 -1
  32. package/dist/assets/web/dist/assets/{UnitRoutePage-0eiekTHQ.js → UnitRoutePage-BcTeH7m2.js} +1 -1
  33. package/dist/assets/web/dist/assets/{angular-html-Ni1Xil2G.js → angular-html-DYtV9uIN.js} +1 -1
  34. package/dist/assets/web/dist/assets/{angular-ts-oCgo6p05.js → angular-ts-DF2P25Zp.js} +1 -1
  35. package/dist/assets/web/dist/assets/{apl-CiWYl0gh.js → apl-Ff96wxko.js} +1 -1
  36. package/dist/assets/web/dist/assets/{astro-B-VeKZ_J.js → astro-LbBnZna-.js} +1 -1
  37. package/dist/assets/web/dist/assets/{blade-BIWEISKd.js → blade-DltuxeBW.js} +1 -1
  38. package/dist/assets/web/dist/assets/{c-DHxA5Byp.js → c-DBg7yuPx.js} +1 -1
  39. package/dist/assets/web/dist/assets/{chapel-D4qGLw-Q.js → chapel-C83hUAds.js} +1 -1
  40. package/dist/assets/web/dist/assets/{cobol-BznX5Q-u.js → cobol-DB0yV_2D.js} +1 -1
  41. package/dist/assets/web/dist/assets/{coffee-CVr8Idlj.js → coffee-Bpih6Kzl.js} +1 -1
  42. package/dist/assets/web/dist/assets/{cpp-wrk6NfW4.js → cpp-CU00ZeRn.js} +1 -1
  43. package/dist/assets/web/dist/assets/{crystal-CGYWMeH1.js → crystal-f8ImJkux.js} +1 -1
  44. package/dist/assets/web/dist/assets/{css-BgGytMsy.js → css-4VrC4ISo.js} +1 -1
  45. package/dist/assets/web/dist/assets/{dist-DSkVB16z.js → dist-TAGawIfM.js} +2 -2
  46. package/dist/assets/web/dist/assets/{durationTone-DYogEPEk.js → durationTone-CYMux25J.js} +1 -1
  47. package/dist/assets/web/dist/assets/{edge-CdR6AU9v.js → edge-BwFDzfS_.js} +1 -1
  48. package/dist/assets/web/dist/assets/{elixir-CQRb3PZx.js → elixir-wwIYIpJm.js} +1 -1
  49. package/dist/assets/web/dist/assets/{elm-CpuA6W8B.js → elm-DyDuM3v3.js} +1 -1
  50. package/dist/assets/web/dist/assets/{erb-CvlrblqH.js → erb-DFJBEVrh.js} +1 -1
  51. package/dist/assets/web/dist/assets/{git-rebase-COnovCVc.js → git-rebase-CuDv-44X.js} +1 -1
  52. package/dist/assets/web/dist/assets/{glimmer-js-C-DS4g_J.js → glimmer-js-ZFnrm4J3.js} +1 -1
  53. package/dist/assets/web/dist/assets/{glimmer-ts-DTkorWRI.js → glimmer-ts-XY4Yqshp.js} +1 -1
  54. package/dist/assets/web/dist/assets/{glsl-CH51-JW7.js → glsl-xnR8efx0.js} +1 -1
  55. package/dist/assets/web/dist/assets/{graphql-BO-XymNv.js → graphql-D-gO5tnP.js} +1 -1
  56. package/dist/assets/web/dist/assets/{hack-C-N0oicf.js → hack-CSxbW4ax.js} +1 -1
  57. package/dist/assets/web/dist/assets/{haml-BfmdtWoP.js → haml-D4ar42QT.js} +1 -1
  58. package/dist/assets/web/dist/assets/{handlebars-BqhO_te-.js → handlebars-CMLbDE7o.js} +1 -1
  59. package/dist/assets/web/dist/assets/{html-DUEZlTKV.js → html-CVekU4Fs.js} +1 -1
  60. package/dist/assets/web/dist/assets/{html-derivative-DOUJTS8A.js → html-derivative-St3HHw_N.js} +1 -1
  61. package/dist/assets/web/dist/assets/{http-j7UH0Cqv.js → http-Mon0DWHS.js} +1 -1
  62. package/dist/assets/web/dist/assets/{hurl-D6ZaBLJk.js → hurl-Cbl5IvBs.js} +1 -1
  63. package/dist/assets/web/dist/assets/{indexRow-BjXBzI5Z.js → indexRow-FLJu5mR9.js} +1 -1
  64. package/dist/assets/web/dist/assets/{java-dugWx3wi.js → java-Bprck1l-.js} +1 -1
  65. package/dist/assets/web/dist/assets/{javascript-CK4GC4JO.js → javascript-C73G1-UR.js} +1 -1
  66. package/dist/assets/web/dist/assets/{jinja-DrLKIyKG.js → jinja-C4DuRxYJ.js} +1 -1
  67. package/dist/assets/web/dist/assets/{jison-CS1Th-TO.js → jison-B2U03GEC.js} +1 -1
  68. package/dist/assets/web/dist/assets/{json-CkBqdVys.js → json-CvTZUDzx.js} +1 -1
  69. package/dist/assets/web/dist/assets/{jsx-CCxdg7n3.js → jsx-DMWKCzwo.js} +1 -1
  70. package/dist/assets/web/dist/assets/{julia-BalKW2hA.js → julia-Czn7bV_3.js} +1 -1
  71. package/dist/assets/web/dist/assets/{just-BKw1G40D.js → just-BBxpNfvC.js} +1 -1
  72. package/dist/assets/web/dist/assets/{latex-Dfad6ZN-.js → latex-Bw9yg5mr.js} +1 -1
  73. package/dist/assets/web/dist/assets/{liquid-f_CMGBvd.js → liquid-BRCjaydE.js} +1 -1
  74. package/dist/assets/web/dist/assets/{lua-BcMK_uKM.js → lua-DUu2KIC0.js} +1 -1
  75. package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
  76. package/dist/assets/web/dist/assets/{main-DR8iKpNt.js → main-mUd_TJjH.js} +2 -2
  77. package/dist/assets/web/dist/assets/{marko-B0oNLx2i.js → marko-CZJZIlSQ.js} +1 -1
  78. package/dist/assets/web/dist/assets/{mdc-Bc_MmIyq.js → mdc-BmClq6fd.js} +1 -1
  79. package/dist/assets/web/dist/assets/{nginx-Cmxv8RIt.js → nginx-Bmt3xSEm.js} +1 -1
  80. package/dist/assets/web/dist/assets/{nim-BtgRfaY0.js → nim-Da-i0utK.js} +1 -1
  81. package/dist/assets/web/dist/assets/{org-Dl9EzlHh.js → org-BvAre2qP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{perl-BmF7x7BB.js → perl-v9WwFFtB.js} +1 -1
  83. package/dist/assets/web/dist/assets/{php-rH6CVptQ.js → php-DTU4Zyze.js} +1 -1
  84. package/dist/assets/web/dist/assets/{pug-CIN5Ccck.js → pug-DhTwEqTO.js} +1 -1
  85. package/dist/assets/web/dist/assets/{qml-BpLS3RFr.js → qml-BUD9AC_c.js} +1 -1
  86. package/dist/assets/web/dist/assets/{r-BUMF3B-H.js → r-CAnu56n7.js} +1 -1
  87. package/dist/assets/web/dist/assets/{razor-B7sTBPdT.js → razor-Dn0P4gAq.js} +1 -1
  88. package/dist/assets/web/dist/assets/{regexp-h2SvwrsJ.js → regexp-CZyBRaVW.js} +1 -1
  89. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  90. package/dist/assets/web/dist/assets/{rst-DLOWEsD2.js → rst-Ckyd5UsY.js} +1 -1
  91. package/dist/assets/web/dist/assets/{ruby-Cl_-I4k3.js → ruby-BBQ62fsx.js} +1 -1
  92. package/dist/assets/web/dist/assets/{sas-Cm4M-BvV.js → sas-CW35hMxG.js} +1 -1
  93. package/dist/assets/web/dist/assets/{scss-CEFCEQUF.js → scss-CpV8pF4J.js} +1 -1
  94. package/dist/assets/web/dist/assets/{shellscript-CE6zb5eS.js → shellscript-CRJvsmE1.js} +1 -1
  95. package/dist/assets/web/dist/assets/{shellsession-B4fDVzrM.js → shellsession-CyriUZxf.js} +1 -1
  96. package/dist/assets/web/dist/assets/{soy-DwXCMpjE.js → soy-D5QzYUeg.js} +1 -1
  97. package/dist/assets/web/dist/assets/{sql-DrnAKnyD.js → sql-CUBf_Mse.js} +1 -1
  98. package/dist/assets/web/dist/assets/{stata-BzlwPWD6.js → stata-B-4n4ITz.js} +1 -1
  99. package/dist/assets/web/dist/assets/{surrealql-CeUieZXd.js → surrealql-RuWZBLtr.js} +1 -1
  100. package/dist/assets/web/dist/assets/{svelte-BERIpIKR.js → svelte-CO5YzCsK.js} +1 -1
  101. package/dist/assets/web/dist/assets/{templ-6FSuUDwg.js → templ-DbkWVaBF.js} +1 -1
  102. package/dist/assets/web/dist/assets/{tex-SWCyMwNX.js → tex-IuxoEcId.js} +1 -1
  103. package/dist/assets/web/dist/assets/{ts-tags-VWj7gWgY.js → ts-tags-g6dGQPjd.js} +1 -1
  104. package/dist/assets/web/dist/assets/{tsx-B6dzfL8L.js → tsx-WnXwiyH2.js} +1 -1
  105. package/dist/assets/web/dist/assets/{twig-BDXsr482.js → twig-Lx1G7PnY.js} +1 -1
  106. package/dist/assets/web/dist/assets/{typescript-s100Dt8y.js → typescript-BQd7biLF.js} +1 -1
  107. package/dist/assets/web/dist/assets/{typst-WfyEAUdt.js → typst-D2lXPHK7.js} +1 -1
  108. package/dist/assets/web/dist/assets/{vue-CkXOOJ-6.js → vue-4wVQD9y9.js} +1 -1
  109. package/dist/assets/web/dist/assets/{vue-html-CEpzdcau.js → vue-html-DZPaRte4.js} +1 -1
  110. package/dist/assets/web/dist/assets/{vue-vine-D9dsfiFA.js → vue-vine-DbmZCmu-.js} +1 -1
  111. package/dist/assets/web/dist/assets/{xml-iBDofbJq.js → xml-DV16szyo.js} +1 -1
  112. package/dist/assets/web/dist/assets/{xsl-CqbJF4hD.js → xsl-eo5-1sk0.js} +1 -1
  113. package/dist/assets/web/dist/assets/{yaml-D9i6OxYe.js → yaml-BXdX8HZf.js} +1 -1
  114. package/dist/cli.js +960 -520
  115. package/package.json +1 -1
  116. package/dist/assets/web/dist/assets/AppShell-DZdz_sl7.js +0 -1
  117. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +0 -1
  118. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +0 -1
  119. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +0 -1
  120. package/dist/assets/web/dist/assets/main-B77vAZVg.css +0 -1
  121. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +0 -1
@@ -47,6 +47,16 @@ export interface Actor {
47
47
  readonly onBehalfOf?: Actor;
48
48
  /** Where a chat actor is speaking from — context, never authority. */
49
49
  readonly origin?: { readonly channelId: string; readonly threadKey: string };
50
+ /**
51
+ * The ids that mean "me" for this actor (record 0042): its own `id` first,
52
+ * then the person it is linked to — a dashboard session whose Access email
53
+ * names one Slack user carries that `slack:U…` id here. Read by `is-self`,
54
+ * `acts-as-person`, the `me` tier and the "mine" filters; NEVER by a grant
55
+ * check — the link is identity, not authority. Absent → `[id]`.
56
+ */
57
+ readonly self?: readonly string[];
58
+ /** The linked person, for display and the audit line (`asUser`); absent when unlinked. */
59
+ readonly asUser?: { readonly id: string; readonly name?: string };
50
60
  }
51
61
 
52
62
  /** `<group>:<read|write|exec>` plus the non-command actions. A plain
@@ -103,8 +113,12 @@ export type Condition =
103
113
  * resource's channel is `public` (a run's stamped `channelVisibility`;
104
114
  * `unknown` is never public). One definition for both evaluators. */
105
115
  | { readonly kind: "member-of" }
106
- /** resource.userId === actor.id (or the on-behalf-of principal's id). */
116
+ /** resource.userId is one of the principal's `self` ids (its own id, or the person it is linked to). */
107
117
  | { readonly kind: "is-self" }
118
+ /** The principal's `self` names a chat identity (`slack:U…`): every chat
119
+ * actor, and a dashboard session linked to its person (record 0042) — never
120
+ * an unlinked browser session or a credential. */
121
+ | { readonly kind: "acts-as-person" }
108
122
  /** actor.grants.repos contains the resource's repo (or is "all"). */
109
123
  | { readonly kind: "owner-of" }
110
124
  /** actor.grants.channels === "all". */
@@ -314,7 +314,11 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
314
314
  }
315
315
 
316
316
  function mergeReturn(step: string, a: BotAnswer): StepReturn {
317
- const { ok, outcome, sha, reason, at } = a.body;
317
+ const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
318
+ // The door found the pull request already merged after the approval: the
319
+ // merge commit and the time ride the answer, and the unit ends `by: other`.
320
+ if (ok === true && outcome === "merged" && by === "other" && typeof sha === "string" && typeof mergedAt === "string")
321
+ return { type: "merge", step, outcome: "merged", by: "other", sha, mergedAt, at };
318
322
  if (ok === true && outcome === "merged" && typeof sha === "string")
319
323
  return { type: "merge", step, outcome: "merged", sha, at };
320
324
  if (ok === true && (outcome === "pending" || outcome === "refused") && typeof reason === "string")
@@ -76,13 +76,17 @@ export type RunNoteKind =
76
76
  * wait had refused; on pi the container is provisioned before pi starts, so
77
77
  * the note is a record fact from before the loop's deletion. */
78
78
  | "fleet_busy"
79
- /** The container the run's pi ran in was replaced under the live run
79
+ /** The container the run's process ran in was replaced under the live run
80
80
  * (docs/reference/specs/harness-pi.md item 16; the resident's roll,
81
- * resident-repos.md item 65): the harness settled the call in flight with
82
- * the restart note, the summary names both containers, and the run ends
83
- * `interrupted` for a restart from its request. On a record from before the
84
- * native loop's deletion the note says that loop's settlement instead: the
85
- * executor waited for the wake and the run went on. */
81
+ * resident-repos.md item 65). The harness's note is the verdict: the call
82
+ * in flight settled with the restart note, the summary naming both
83
+ * containers. The run loop then decides between two outcomes (harness.md
84
+ * item 6): a relaunch in the replacement, said in a `resumed` note, or —
85
+ * the relaunch refused by name, the ceiling among them — a second note of
86
+ * this kind saying why and that the run closes `interrupted` for a restart
87
+ * from its request. On a record from before the native loop's deletion the
88
+ * note says that loop's settlement instead: the executor waited for the
89
+ * wake and the run went on. */
86
90
  | "sandbox_restarted"
87
91
  | "stop_requested"
88
92
  | "stopped"
@@ -145,6 +145,14 @@ export function unitBranch(planId: string, slug: string): string {
145
145
  return `plan/${planId}/${slug}`;
146
146
  }
147
147
 
148
+ /** Whether `ref` is a unit branch of ship's own — `plan/<plan-id>/<unit-slug>`.
149
+ * A thread keeps the binding its last run opened a pull request on, so after
150
+ * a plan's unit it sits at that unit branch; a fresh task there must not take
151
+ * it as the base (the pull request would target the earlier unit). */
152
+ export function isUnitBranch(ref: string): boolean {
153
+ return /^plan\/[^/]+\/[^/]+$/.test(ref);
154
+ }
155
+
148
156
  /** The plan and unit a head branch names, or undefined for any other branch — the merge grant's line. */
149
157
  export function parsePlanBranch(branch: string): { planId: string; unitSlug: string } | undefined {
150
158
  const m = PLAN_BRANCH.exec(branch);
@@ -496,6 +504,9 @@ export type StepReturn =
496
504
  | { type: "read-record"; step: string; run: ChildFacts; at: number }
497
505
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
498
506
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
507
+ // The door found the pull request already merged after the approval — auto-merge
508
+ // fired, or a person merged — so the runner merged nothing (`by: other`).
509
+ | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
499
510
  | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
500
511
  | { type: "sleep"; step: string };
501
512
 
@@ -1260,7 +1271,18 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1260
1271
  case "merge": {
1261
1272
  const r = ret as Extract<StepReturn, { type: "merge" }>;
1262
1273
  if (r.outcome === "merged")
1263
- return end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
1274
+ // Found already merged at the door — auto-merge or a person, after the
1275
+ // approval: the unit is done, the runner merged nothing (`by: other`).
1276
+ return "by" in r
1277
+ ? end(clocked, {
1278
+ kind: "merged",
1279
+ by: "other",
1280
+ pr: p.pr,
1281
+ sha: r.sha,
1282
+ mergedAt: r.mergedAt,
1283
+ reviewRounds: s.reviewRounds,
1284
+ })
1285
+ : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
1264
1286
  if (r.outcome === "refused")
1265
1287
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
1266
1288
  const waited = r.at - p.since;
@@ -299,6 +299,14 @@ export function parseDepsStoreListing(stdout: string): DepsStoreListing {
299
299
  * attaches. Raise with the instance, never by feel. */
300
300
  export const DEPS_STORE_MAX_UNREFERENCED = 1;
301
301
 
302
+ /** How many spares the attach's disk-pressure path keeps: none. A spare is a
303
+ * warm cache for a lockfile key no live tree references, worth minutes on
304
+ * the next attach at that key; a tree refused for want of space is a run
305
+ * falling to a cold sandbox now. Under pressure the spare goes first, before
306
+ * any idle tree (docs/reference/specs/resident-repos.md item 55), and its
307
+ * entry backup with it — the next attach at that key restores or installs. */
308
+ export const DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE = 0;
309
+
302
310
  /** Eviction candidates coldest first. Never a protected key (the checkout's,
303
311
  * a live binding's, an install in flight). Debris — an incomplete entry
304
312
  * with nothing in flight for it — is always first: it is half an install
@@ -318,6 +326,9 @@ export interface DepsEvictionPlan {
318
326
  remove: string[];
319
327
  /** Keys that stay, for the log. */
320
328
  keep: string[];
329
+ /** The entries `remove` names (debris first, then the coldest spares), with
330
+ * their measured sizes — what a pressure eviction gives back. */
331
+ evicted: DepsStoreEntry[];
321
332
  }
322
333
 
323
334
  export function planDepsEviction(input: {
@@ -336,7 +347,8 @@ export function planDepsEviction(input: {
336
347
  const evictSpares = spares.slice(0, Math.max(0, spares.length - max));
337
348
  const remove = [...debris, ...evictSpares].map((e) => depsEntryPath(e.key, storeDir));
338
349
  remove.push(...input.leftovers);
339
- const removed = new Set([...debris, ...evictSpares].map((e) => e.key));
350
+ const evicted = [...debris, ...evictSpares];
351
+ const removed = new Set(evicted.map((e) => e.key));
340
352
  const keep = input.entries.filter((e) => !removed.has(e.key)).map((e) => e.key);
341
- return { remove, keep };
353
+ return { remove, keep, evicted };
342
354
  }
@@ -151,9 +151,19 @@ export { parseDfKiB };
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
153
  /** The refresh cycle snapshots mirror + checkout to R2 through the Sandbox
154
- * SDK (`createBackup`); whether it stages a tarball on local disk is
155
- * SDK-internal, so the budget holds room for one compressed copy of what it
156
- * archives — the same ratio `instanceSizing.test.ts` sizes the instance with. */
154
+ * SDK (`createBackup`), which stages each archive on the container's own disk
155
+ * (`/var/backups/<id>.sqsh`, lz4 squashfs) before the upload and removes it
156
+ * after, the pair concurrently — so the budget holds room for one compressed
157
+ * copy of what the cycle archives, at the ratio `instanceSizing.test.ts` sizes
158
+ * the instance with. Since item 61 PR B the checkout archive EXCLUDES
159
+ * node_modules (`CHECKOUT_SNAPSHOT_EXCLUDES`): the deps are not in the
160
+ * cycle's staging. The deps-store entry has its own archive, taken once per
161
+ * lockfile key right after the entry is committed, and that one IS staged
162
+ * from the deps — the reserve counts it only while such a backup is in
163
+ * flight (`depsBackupInFlight`), never as a standing charge. Before this,
164
+ * the standing term multiplied the deps too: on a resident whose deps are
165
+ * 6.9 GiB of an 8.3 GiB archived set, five gigabytes were held for an archive
166
+ * of about one, and every attach was refused with 6.7 GiB free. */
157
167
  export const SNAPSHOT_STAGING_RATIO = 0.6;
158
168
 
159
169
  /** The fixed floor under the staging term: at least 1 GiB, or 5 % of the disk
@@ -173,13 +183,26 @@ export interface DiskReserve {
173
183
  totalKiB: number;
174
184
  }
175
185
 
186
+ /** What the reserve knows beyond the sample: whether a deps-store entry
187
+ * backup is being taken right now (item 61), whose archive is staged from
188
+ * the deps and so needs their share of the ratio while it runs. */
189
+ export interface DiskReserveInput {
190
+ depsBackupInFlight?: boolean;
191
+ }
192
+
176
193
  /** `staging + floor`. Staging is computed from the MEASURED mirror and
177
- * checkout (deps + rest); an unmeasured part counts as 0 there — the floor
178
- * still stands, and `projectThreadCostKiB` refuses to project from missing
179
- * parts, so an unmeasured resident never admits on a guess. */
180
- export function diskReserveKiB(sample: Pick<DiskSample, "totalKiB" | "parts">): DiskReserve {
181
- const archived = (sample.parts.mirror ?? 0) + (sample.parts.deps ?? 0) + (sample.parts.checkout ?? 0);
182
- const stagingKiB = Math.round(archived * SNAPSHOT_STAGING_RATIO);
194
+ * checkout (the checkout's own bytes: history + tree + build output, never
195
+ * the deps, which the checkout archive excludes), plus the deps while a
196
+ * deps-store entry backup is in flight; an unmeasured part counts as 0 there
197
+ * — the floor still stands, and `projectThreadCostKiB` refuses to project
198
+ * from missing parts, so an unmeasured resident never admits on a guess. */
199
+ export function diskReserveKiB(
200
+ sample: Pick<DiskSample, "totalKiB" | "parts">,
201
+ input: DiskReserveInput = {},
202
+ ): DiskReserve {
203
+ const staged = (sample.parts.mirror ?? 0) + (sample.parts.checkout ?? 0);
204
+ const depsStaged = input.depsBackupInFlight ? (sample.parts.deps ?? 0) : 0;
205
+ const stagingKiB = Math.round((staged + depsStaged) * SNAPSHOT_STAGING_RATIO);
183
206
  const floorKiB = Math.max(DISK_FLOOR_MIN_KIB, Math.round(sample.totalKiB * DISK_FLOOR_FRACTION));
184
207
  return { stagingKiB, floorKiB, totalKiB: stagingKiB + floorKiB };
185
208
  }
@@ -261,11 +284,14 @@ export function checkDiskAdmission(input: {
261
284
  committedKiB?: number;
262
285
  diskBudgetMb?: number;
263
286
  kind: ThreadCostKind;
287
+ /** A deps-store entry backup is in flight: its archive is staged from the
288
+ * deps, so the reserve holds their share for as long as it runs. */
289
+ depsBackupInFlight?: boolean;
264
290
  }): AdmissionVerdict {
265
291
  const rawFree = Math.max(0, (input.freeKiB ?? input.sample.freeKiB) - (input.committedKiB ?? 0));
266
292
  const live = { ...input.sample, freeKiB: rawFree, usedKiB: input.sample.totalKiB - rawFree };
267
293
  const { capacityKiB, freeKiB, capped } = effectiveFreeKiB(live, input.diskBudgetMb);
268
- const reserve = diskReserveKiB(input.sample);
294
+ const reserve = diskReserveKiB(input.sample, { depsBackupInFlight: input.depsBackupInFlight });
269
295
  const projectedKiB = projectThreadCostKiB(input.sample.parts, input.kind);
270
296
  const headroomKiB = freeKiB - reserve.totalKiB - (projectedKiB ?? 0);
271
297
  const math: AdmissionMath = { projectedKiB, kind: input.kind, freeKiB, capacityKiB, capped, reserve, headroomKiB };
@@ -401,6 +427,9 @@ export function diskPressureReason(input: {
401
427
  verdict: Extract<AdmissionVerdict, { fits: false }>;
402
428
  evicted: ReadonlyArray<{ freedKiB: number | null }>;
403
429
  kept: ReadonlyArray<{ why: DiskKeepWhy }>;
430
+ /** Deps-store spares removed first under the pressure (item 55), with the
431
+ * bytes each gave back. */
432
+ spares?: ReadonlyArray<{ freedKiB: number }>;
404
433
  }): string {
405
434
  const { math } = input.verdict;
406
435
  const need =
@@ -412,10 +441,15 @@ export function diskPressureReason(input: {
412
441
  `${DISK_PRESSURE_REASON}: need ${need}, but ${formatGiB(math.freeKiB)} free${cap} minus the ${formatGiB(math.reserve.totalKiB)} reserve ` +
413
442
  `(snapshot staging ${formatGiB(math.reserve.stagingKiB)} + floor ${formatGiB(math.reserve.floorKiB)}) leaves ${formatGiB(Math.max(0, math.freeKiB - math.reserve.totalKiB))} — short by ${formatGiB(input.verdict.shortfallKiB)}`,
414
443
  ];
444
+ const spares = input.spares ?? [];
445
+ if (spares.length > 0) {
446
+ const freed = spares.reduce((a, e) => a + e.freedKiB, 0);
447
+ parts.push(`evicted ${spares.length} deps-store spare(s) (${formatGiB(freed)} back)`);
448
+ }
415
449
  if (input.evicted.length > 0) {
416
450
  const freed = input.evicted.reduce((a, e) => a + (e.freedKiB ?? 0), 0);
417
451
  parts.push(`evicted ${input.evicted.length} idle tree(s) (${formatGiB(freed)} back)`);
418
- } else parts.push("evicted nothing");
452
+ } else parts.push(spares.length > 0 ? "evicted no idle tree" : "evicted nothing");
419
453
  if (input.kept.length > 0) {
420
454
  const counts = new Map<DiskKeepWhy, number>();
421
455
  for (const k of input.kept) counts.set(k.why, (counts.get(k.why) ?? 0) + 1);
@@ -42,19 +42,45 @@ export function wantShaForBinding(input: {
42
42
  return input.wantSha;
43
43
  }
44
44
 
45
- /** Whether the attach must fetch the mirror before cloning the thread tree.
46
- * - the ref is not in the mirror → fetch (the only pre-existing rule);
45
+ /** Why the attach fetches the mirror before cloning the thread tree. The
46
+ * Worker treats the three differently when the fetch fails: a `missing-ref`
47
+ * or `stale-tip` fetch that fails fails the attach, as it always did; a
48
+ * `returnable-ref` fetch only verifies a ref the mirror holds, so its
49
+ * failure is one log line and the attach goes on with the mirror's ref. */
50
+ export type FetchReason = "missing-ref" | "stale-tip" | "returnable-ref";
51
+
52
+ /** Whether — and why — the attach must fetch the mirror before cloning the
53
+ * thread tree; null when the mirror is good enough as it stands.
54
+ * - the ref is not in the mirror → `missing-ref` (the oldest rule);
47
55
  * - a `wantSha` was named and the mirror's tip of the ref is not that
48
- * commit → fetch;
49
- * - otherwise the mirror is good enough as it stands.
56
+ * commit → `stale-tip`;
57
+ * - the bound ref is one the binding could return from — a branch a rebind
58
+ * moved it onto, or one the thread's own runs pushed (`canReturnToDefault`,
59
+ * resident-repos.md item 16) → `returnable-ref`: such a branch dies when
60
+ * its pull request merges, and the deletion reaches the mirror only through
61
+ * a `fetch --prune`. Trusting the mirror because it still holds the ref
62
+ * would provision a tree at the deleted branch's stale tip until the
63
+ * refresh cycle's prune caught up — whether the thread returned to the
64
+ * default would depend on where in the cycle its follow-up landed. So the
65
+ * ref is verified against the origin at every attach, and the return is
66
+ * decided at the first attach after the deletion. The default cannot be
67
+ * lost and a ref a person named that the thread never pushed has no way
68
+ * back, so neither pays this fetch: the refresh cycle is their freshness.
69
+ * - otherwise null.
50
70
  * A fetch that STILL leaves the tip elsewhere (a push racing this attach, or
51
71
  * a force-push) is not this function's concern: the attach proceeds on the
52
72
  * fetched tip and reports it, and the reviewed-head guard decides what a
53
73
  * review of it may do. */
54
- export function mirrorNeedsFetch(input: { refExists: boolean; mirrorSha?: string; wantSha: string | null }): boolean {
55
- if (!input.refExists) return true;
56
- if (input.wantSha === null) return false;
57
- return input.mirrorSha !== input.wantSha;
74
+ export function mirrorFetchReason(input: {
75
+ refExists: boolean;
76
+ mirrorSha?: string;
77
+ wantSha: string | null;
78
+ returnable: boolean;
79
+ }): FetchReason | null {
80
+ if (!input.refExists) return "missing-ref";
81
+ if (input.wantSha !== null && input.mirrorSha !== input.wantSha) return "stale-tip";
82
+ if (input.returnable) return "returnable-ref";
83
+ return null;
58
84
  }
59
85
 
60
86
  /** What the attach checks out, once the mirror is as fresh as it will get. */
@@ -35,15 +35,21 @@
35
35
  * Every refusal is named in the attach answer so the bot can say why the
36
36
  * follow-up runs where it does.
37
37
  *
38
- * The second movement: a rebound binding names a branch that can die — the
39
- * pull request merges and the branch is deleted. A binding left on it would
40
- * fail every later attach (`unknown-ref`) for the thread's whole life. So a
41
- * binding a rebind moved, whose branch the mirror no longer holds after a
42
- * fetch, goes back to the default it was bound to (`canReturnToDefault`,
43
- * `returnToDefault`); the attach provisions the tree there, clean, and the
44
- * thread is default-bound again, so a later own pull request may move it once
45
- * more. A ref a person named that vanished keeps the `unknown-ref` refusal:
46
- * that branch is the person's to sort out. */
38
+ * The second movement: a binding can sit on a branch that dies — the pull
39
+ * request merges and the branch is deleted. A binding left on it would fail
40
+ * every later attach (`unknown-ref`) for the thread's whole life. So a
41
+ * binding whose ref the mirror no longer holds after a fetch goes back to the
42
+ * default (`canReturnToDefault`, `returnToDefault`) when the gone ref is
43
+ * EITHER the branch a rebind moved it onto OR one of the thread's own
44
+ * branches — a branch this thread's runs pushed (`ownBranches`), whatever
45
+ * `boundBy` says: a ship unit's coding child binds its unit branch by name,
46
+ * pushes it and opens the pull request, and the unit's merge deletes it. The
47
+ * attach provisions the tree at the default, clean, and the thread is
48
+ * default-bound again (`boundBy: default` — the default was chosen for want
49
+ * of the branch it was on), so a later own pull request may move it once
50
+ * more. A ref a person named that the thread never pushed keeps the
51
+ * `unknown-ref` refusal: a person's branch that is gone is not this thread's
52
+ * finished work, and the resident never swaps it for the default unasked. */
47
53
 
48
54
  /** The pull request the thread's own run opened, and its head branch — the
49
55
  * reason a caller's refHint is that branch. */
@@ -136,9 +142,15 @@ export function rememberOwnBranches(
136
142
  return [...kept, ...added].slice(-OWN_BRANCHES_MAX);
137
143
  }
138
144
 
145
+ /** The binding's memory of `ref` as a branch the thread's own runs pushed —
146
+ * the pull request it heads and when it was told — or none. */
147
+ export function ownBranchOf(binding: { ownBranches?: readonly OwnBranch[] }, ref: string): OwnBranch | undefined {
148
+ return (binding.ownBranches ?? []).find((b) => b.ref === ref);
149
+ }
150
+
139
151
  /** Whether the thread's own runs pushed `ref`, as the binding remembers it. */
140
152
  export function isOwnBranch(binding: { ownBranches?: readonly OwnBranch[] }, ref: string): boolean {
141
- return (binding.ownBranches ?? []).some((b) => b.ref === ref);
153
+ return ownBranchOf(binding, ref) !== undefined;
142
154
  }
143
155
 
144
156
  /** How a binding's ref was chosen: the repo default for want of a named
@@ -171,8 +183,12 @@ export interface Rebound {
171
183
  returnedAt?: string;
172
184
  }
173
185
 
174
- /** The move back, in the attach answer: from the branch that is gone, to the
175
- * default, for the pull request whose branch it was, when. */
186
+ /** The move back (the second movement), on the binding and in the attach
187
+ * answer: from the branch that is gone, to the default, for the pull request
188
+ * whose branch it was — the rebind's, or the one the binding remembers the
189
+ * branch heading — when. On the binding it is the LAST move back: a rebind's
190
+ * own record keeps its `returnedAt` beside it, and a binding never rebound
191
+ * (its unit branch bound by name and pushed) has only this. */
176
192
  export interface Returned {
177
193
  from: string;
178
194
  to: string;
@@ -199,6 +215,8 @@ export interface RebindableBinding {
199
215
  evicted?: boolean;
200
216
  boundBy?: BoundBy;
201
217
  rebound?: Rebound;
218
+ /** The binding's last move back to the default (the second movement). */
219
+ returned?: Returned;
202
220
  /** The branches the thread's own runs pushed, handed over at each release. */
203
221
  ownBranches?: OwnBranch[];
204
222
  }
@@ -320,29 +338,68 @@ export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, t
320
338
  return { kind: "rebind" };
321
339
  }
322
340
 
323
- /** Whether a binding whose ref the mirror no longer holds goes back to the
324
- * default branch (the second movement): only a binding a rebind moved onto
325
- * its own pull request's branch — bound by default in the first place, still
326
- * on that branch, the move not yet returned. Anything else keeps the
327
- * attach's `unknown-ref` refusal: a ref a person named is that person's to
328
- * sort out, and a binding on the default cannot lose its ref. */
329
- export function canReturnToDefault(
330
- binding: { ref: string; boundBy?: BoundBy; rebound?: Rebound },
341
+ /** What the second movement reads off a binding. */
342
+ export type ReturnableBinding = Pick<RebindableBinding, "ref" | "boundBy" | "rebound" | "ownBranches">;
343
+
344
+ /** The way back a binding whose ref is gone may take, if any — the one place
345
+ * the second movement is decided, so the predicate and the move cannot
346
+ * disagree: `rebind` — a rebind moved the binding onto the branch (bound by
347
+ * default in the first place, still on that branch, the move not yet
348
+ * returned); `own` — the branch is one this thread's own runs pushed, as the
349
+ * binding remembers it, whatever `boundBy` says. A binding on the default
350
+ * cannot lose its ref, and a ref a person named that the thread never pushed
351
+ * has no way back: that branch is the person's to sort out. */
352
+ function wayBack(
353
+ binding: ReturnableBinding,
331
354
  defaultRef: string,
332
- ): boolean {
355
+ ): { kind: "rebind"; rebound: Rebound } | { kind: "own"; branch: OwnBranch } | undefined {
356
+ if (binding.ref === defaultRef) return undefined;
333
357
  const r = binding.rebound;
334
- if (r === undefined || r.returnedAt !== undefined || binding.ref !== r.to) return false;
335
- return binding.ref !== defaultRef && boundByOf(binding, defaultRef) === "default";
358
+ if (
359
+ r !== undefined &&
360
+ r.returnedAt === undefined &&
361
+ binding.ref === r.to &&
362
+ boundByOf(binding, defaultRef) === "default"
363
+ ) {
364
+ return { kind: "rebind", rebound: r };
365
+ }
366
+ const branch = ownBranchOf(binding, binding.ref);
367
+ return branch === undefined ? undefined : { kind: "own", branch };
368
+ }
369
+
370
+ /** Whether a binding whose ref the mirror no longer holds goes back to the
371
+ * default branch (the second movement): a binding a rebind moved onto its
372
+ * own pull request's branch, or one on a branch this thread itself pushed.
373
+ * Anything else keeps the attach's `unknown-ref` refusal. */
374
+ export function canReturnToDefault(binding: ReturnableBinding, defaultRef: string): boolean {
375
+ return wayBack(binding, defaultRef) !== undefined;
336
376
  }
337
377
 
338
- /** The move back: the binding's ref becomes the default and the move that
339
- * brought it here is stamped returned, so the thread may move again. Only
340
- * ever applied to a binding `canReturnToDefault` admitted. */
341
- export function returnToDefault<B extends { ref: string; rebound?: Rebound }>(
342
- binding: B & { rebound: Rebound },
378
+ /** The move back: the binding's ref becomes the default, the binding reads as
379
+ * bound by default (the default was chosen for want of the branch it was on,
380
+ * so its next own pull request may move it — a binding that named its unit
381
+ * branch included), a rebind's move is stamped returned, and the last move
382
+ * back is recorded. The pull request named is the rebind's, else the one the
383
+ * binding remembers the gone branch heading. Undefined for a binding
384
+ * `canReturnToDefault` does not admit: nothing to write. */
385
+ export function returnToDefault<B extends ReturnableBinding>(
386
+ binding: B,
343
387
  defaultRef: string,
344
388
  at: string,
345
- ): { binding: B; returned: Returned } {
346
- const returned: Returned = { from: binding.ref, to: defaultRef, pr: binding.rebound.pr, at };
347
- return { binding: { ...binding, ref: defaultRef, rebound: { ...binding.rebound, returnedAt: at } }, returned };
389
+ ): { binding: B; returned: Returned } | undefined {
390
+ const way = wayBack(binding, defaultRef);
391
+ if (way === undefined) return undefined;
392
+ const pr = way.kind === "rebind" ? way.rebound.pr : way.branch.pr;
393
+ const returned: Returned = { from: binding.ref, to: defaultRef, pr, at };
394
+ const rebound = way.kind === "rebind" ? { ...way.rebound, returnedAt: at } : binding.rebound;
395
+ return {
396
+ binding: {
397
+ ...binding,
398
+ ref: defaultRef,
399
+ boundBy: "default",
400
+ ...(rebound !== undefined ? { rebound } : {}),
401
+ returned,
402
+ },
403
+ returned,
404
+ };
348
405
  }
@@ -76,3 +76,36 @@ export function recycledMidCommandMessage(elapsedMs: number, msg: string, certai
76
76
  `check /workspace before continuing: it is empty if the container was replaced (re-clone), intact if only its runtime restarted (${msg})`
77
77
  );
78
78
  }
79
+
80
+ /** The redirects a detached job's wrapper must carry so the command that
81
+ * forked it owns none of its descriptors — the same string the harness seam
82
+ * uses for its own start (`DETACHED_STDIO` in src/core/harness/container.ts;
83
+ * a test holds the two equal). Without them the runtime waits for an end of
84
+ * output the detached child never gives (item 24). */
85
+ export const DETACH_REDIRECTS = "</dev/null >/dev/null 2>&1";
86
+
87
+ /** The way a command detaches a job that must outlive it, as the exit-124
88
+ * hint and the docs spell it: the job's own output into a file, the
89
+ * wrapper's stdio to /dev/null. */
90
+ export const DETACH_HINT = `setsid -f sh -c '<command> > /tmp/job.log 2>&1' ${DETACH_REDIRECTS}`;
91
+
92
+ /** How long past the process's exit the Durable Object waits for its output
93
+ * stream to end before it answers with the exit code alone. The runtime
94
+ * reports the stream's end only when every holder of the command's stdout
95
+ * and stderr is gone; a detached child that inherited them holds it open
96
+ * for its own lifetime. Twenty seconds covers coreutils `timeout -k 10`'s
97
+ * SIGKILL follow-up and the runtime's teardown, and stays inside the
98
+ * executor's per-send margin (EXEC_CALL_MARGIN_MS, 30 s) so this answer
99
+ * reaches the bot before it gives the command up. */
100
+ export const OUTPUT_AFTER_EXIT_MS = 20_000;
101
+
102
+ /** The stderr line an exec answers with when its process exited but its
103
+ * output never ended: what happened, why the output is missing, and how to
104
+ * detach a job so it does not happen again. */
105
+ export function heldOutputNote(exitCode: number): string {
106
+ return (
107
+ `the command exited (code ${exitCode}) but its output could not be collected: a process it started still holds its stdout or stderr open ` +
108
+ `(a background job started without redirecting the wrapper's own stdio); its output is lost to this call, the job itself is still running — ` +
109
+ `detach with \`${DETACH_HINT}\` so the command's descriptors close when it does`
110
+ );
111
+ }