toolroll 0.9.93 → 0.9.94

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 (328) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/activity-line.d.ts +7 -4
  3. package/dist/activity-line.js +9 -7
  4. package/dist/agent-fence.d.ts +4 -0
  5. package/dist/agent-fence.js +4 -1
  6. package/dist/approval-authority.d.ts +7 -1
  7. package/dist/approval-authority.js +11 -2
  8. package/dist/approval-password.d.ts +31 -0
  9. package/dist/approval-password.js +26 -0
  10. package/dist/assignment-ui.js +1 -1
  11. package/dist/assignment.js +5 -0
  12. package/dist/boot-identity.d.ts +2 -1
  13. package/dist/boot-identity.js +6 -4
  14. package/dist/bounded-spawn.d.ts +57 -0
  15. package/dist/bounded-spawn.js +220 -0
  16. package/dist/browser/terminal.js +42 -42
  17. package/dist/browser/workspace.css +1 -1
  18. package/dist/browser/workspace.js +32 -32
  19. package/dist/browser-shell.d.ts +1 -1
  20. package/dist/browser-shell.js +2 -2
  21. package/dist/browser-workspace.d.ts +45 -2
  22. package/dist/browser-workspace.js +3 -3
  23. package/dist/builder-restart.d.ts +21 -0
  24. package/dist/builder-restart.js +50 -0
  25. package/dist/builder.d.ts +4 -0
  26. package/dist/builder.js +53 -24
  27. package/dist/chat-acceptance.js +1 -1
  28. package/dist/chat-actions.d.ts +12 -2
  29. package/dist/chat-actions.js +25 -2
  30. package/dist/chat-channel.js +11 -0
  31. package/dist/chat-decide.d.ts +7 -2
  32. package/dist/chat-decide.js +37 -9
  33. package/dist/chat-quiet.js +3 -3
  34. package/dist/chat-voice.d.ts +1 -1
  35. package/dist/chat-voice.js +2 -2
  36. package/dist/check-facts.d.ts +3 -1
  37. package/dist/check-facts.js +57 -11
  38. package/dist/check-failure.d.ts +22 -3
  39. package/dist/check-failure.js +57 -13
  40. package/dist/check-levels-ui.d.ts +4 -0
  41. package/dist/check-levels-ui.js +10 -1
  42. package/dist/check-load.d.ts +85 -0
  43. package/dist/check-load.js +147 -0
  44. package/dist/check-only-standing.d.ts +36 -0
  45. package/dist/check-only-standing.js +39 -0
  46. package/dist/check-only.d.ts +15 -0
  47. package/dist/check-only.js +99 -0
  48. package/dist/check-report.d.ts +2 -0
  49. package/dist/check-report.js +1 -1
  50. package/dist/check-stamp.d.ts +46 -0
  51. package/dist/check-stamp.js +93 -0
  52. package/dist/checkout-cleanup.d.ts +36 -5
  53. package/dist/checkout-cleanup.js +166 -14
  54. package/dist/cli-help.d.ts +4 -1
  55. package/dist/cli-help.js +47 -30
  56. package/dist/cli.js +11 -1
  57. package/dist/code-extract.d.ts +44 -0
  58. package/dist/code-extract.js +88 -0
  59. package/dist/code-map-acts.d.ts +24 -0
  60. package/dist/code-map-acts.js +53 -0
  61. package/dist/code-map-admission.d.ts +52 -0
  62. package/dist/code-map-admission.js +295 -0
  63. package/dist/code-map-build.d.ts +63 -0
  64. package/dist/code-map-build.js +415 -0
  65. package/dist/code-map-cli.d.ts +153 -0
  66. package/dist/code-map-cli.js +103 -0
  67. package/dist/code-map-main.d.ts +1 -0
  68. package/dist/code-map-main.js +48 -0
  69. package/dist/code-map-read.d.ts +106 -0
  70. package/dist/code-map-read.js +171 -0
  71. package/dist/code-map-report.d.ts +37 -0
  72. package/dist/code-map-report.js +95 -0
  73. package/dist/code-map-shape.d.ts +172 -0
  74. package/dist/code-map-shape.js +327 -0
  75. package/dist/code-map-store.d.ts +136 -0
  76. package/dist/code-map-store.js +233 -0
  77. package/dist/coding-provider.d.ts +2 -0
  78. package/dist/coding-provider.js +3 -2
  79. package/dist/coding-update.js +3 -2
  80. package/dist/coding-workspace.js +4 -4
  81. package/dist/console-chrome.js +2 -2
  82. package/dist/containment.d.ts +5 -0
  83. package/dist/containment.js +14 -1
  84. package/dist/contracts/chat-actions.d.ts +9 -0
  85. package/dist/contracts/chat-actions.js +2 -0
  86. package/dist/contracts/cli.d.ts +52 -0
  87. package/dist/contracts/cli.js +4 -0
  88. package/dist/contracts/console-api.d.ts +10 -3
  89. package/dist/contracts/console-api.js +10 -3
  90. package/dist/contracts/gateway-tools.d.ts +17 -0
  91. package/dist/contracts/gateway-tools.js +3 -1
  92. package/dist/contracts/lead-tools.d.ts +61 -0
  93. package/dist/contracts/lead-tools.js +10 -1
  94. package/dist/contracts/project-knowledge.d.ts +38 -5
  95. package/dist/contracts/project-knowledge.js +11 -5
  96. package/dist/contracts/project-memory.d.ts +3 -1
  97. package/dist/contracts/project-memory.js +3 -2
  98. package/dist/contracts/storage.d.ts +14 -0
  99. package/dist/contracts/storage.js +3 -1
  100. package/dist/contracts/store-json.d.ts +2 -0
  101. package/dist/contracts/youiyoux-request.d.ts +1 -1
  102. package/dist/contracts/youiyoux-request.js +1 -1
  103. package/dist/desktop-access.js +6 -2
  104. package/dist/dispatch.js +18 -15
  105. package/dist/dispose.js +8 -2
  106. package/dist/easy-start.js +2 -3
  107. package/dist/evals.js +2 -1
  108. package/dist/fast-start.d.ts +136 -0
  109. package/dist/fast-start.js +431 -0
  110. package/dist/fast-terms.d.ts +9 -0
  111. package/dist/fast-terms.js +23 -0
  112. package/dist/fenced-check-coverage.d.ts +62 -0
  113. package/dist/fenced-check-coverage.js +54 -0
  114. package/dist/first-run.js +1 -1
  115. package/dist/flaky-ci.d.ts +31 -0
  116. package/dist/flaky-ci.js +149 -0
  117. package/dist/flaky-cli.d.ts +60 -0
  118. package/dist/flaky-cli.js +60 -0
  119. package/dist/flaky.d.ts +75 -9
  120. package/dist/flaky.js +255 -0
  121. package/dist/flow-card-task.js +3 -0
  122. package/dist/flow-triggers.d.ts +1 -1
  123. package/dist/flow-triggers.js +1 -1
  124. package/dist/github-import.d.ts +62 -0
  125. package/dist/github-import.js +146 -0
  126. package/dist/guides.js +15 -10
  127. package/dist/invoke.d.ts +5 -1
  128. package/dist/invoke.js +49 -14
  129. package/dist/job-object-helper.ps1 +10 -3
  130. package/dist/keymap.d.ts +41 -0
  131. package/dist/keymap.js +51 -0
  132. package/dist/knowledge-cli.js +1 -1
  133. package/dist/knowledge-ui.d.ts +21 -4
  134. package/dist/knowledge-ui.js +75 -7
  135. package/dist/laptop-background.d.ts +16 -0
  136. package/dist/laptop-background.js +108 -2
  137. package/dist/lead-context.js +1 -1
  138. package/dist/lead-contract.d.ts +1 -1
  139. package/dist/lead-contract.js +2 -2
  140. package/dist/lead-progress.js +3 -0
  141. package/dist/lead-status.d.ts +2 -0
  142. package/dist/lead-status.js +6 -2
  143. package/dist/lead-tools.js +51 -2
  144. package/dist/lead.js +1 -1
  145. package/dist/lesson-notes.d.ts +81 -0
  146. package/dist/lesson-notes.js +336 -0
  147. package/dist/limits-ui.js +1 -1
  148. package/dist/local-owner.d.ts +1 -1
  149. package/dist/local-owner.js +11 -0
  150. package/dist/machine-check.d.ts +16 -0
  151. package/dist/machine-check.js +26 -0
  152. package/dist/machine-identity.d.ts +75 -0
  153. package/dist/machine-identity.js +170 -0
  154. package/dist/mcp-core.js +11 -1
  155. package/dist/memory-cli.d.ts +300 -1
  156. package/dist/memory-cli.js +62 -9
  157. package/dist/memory-pass.d.ts +16 -0
  158. package/dist/memory-pass.js +67 -3
  159. package/dist/memory-tidy.d.ts +181 -0
  160. package/dist/memory-tidy.js +523 -0
  161. package/dist/memory-use.d.ts +35 -0
  162. package/dist/memory-use.js +109 -0
  163. package/dist/merge-checklist.d.ts +55 -0
  164. package/dist/merge-checklist.js +182 -0
  165. package/dist/needs-you.d.ts +27 -8
  166. package/dist/needs-you.js +96 -39
  167. package/dist/nightly-github.d.ts +2 -0
  168. package/dist/nightly-github.js +4 -4
  169. package/dist/operate-remote-arguments.js +6 -1
  170. package/dist/operate-remote.js +3 -1
  171. package/dist/operate.d.ts +6 -1
  172. package/dist/operate.js +402 -52
  173. package/dist/phase-routing.js +2 -2
  174. package/dist/plain-schema.d.ts +23 -0
  175. package/dist/plain-schema.js +94 -0
  176. package/dist/plain-words.d.ts +20 -0
  177. package/dist/plain-words.js +66 -0
  178. package/dist/planner.js +2 -1
  179. package/dist/process-liveness.d.ts +2 -0
  180. package/dist/process-liveness.js +61 -17
  181. package/dist/project-delete-ui.d.ts +7 -2
  182. package/dist/project-delete-ui.js +12 -1
  183. package/dist/project-delete.js +8 -1
  184. package/dist/project-knowledge.d.ts +18 -11
  185. package/dist/project-knowledge.js +39 -9
  186. package/dist/project-learning.d.ts +2 -1
  187. package/dist/project-learning.js +15 -45
  188. package/dist/project-memory.d.ts +5 -0
  189. package/dist/project-memory.js +22 -2
  190. package/dist/proof.js +30 -5
  191. package/dist/provider-auth.js +1 -1
  192. package/dist/publish.js +9 -1
  193. package/dist/pull-request-flow.js +5 -0
  194. package/dist/push.d.ts +18 -2
  195. package/dist/push.js +40 -16
  196. package/dist/release-adapters.d.ts +9 -0
  197. package/dist/release-adapters.js +76 -0
  198. package/dist/release-auto.js +13 -13
  199. package/dist/release-card.d.ts +1 -2
  200. package/dist/release-card.js +11 -0
  201. package/dist/release-carry.d.ts +7 -2
  202. package/dist/release-carry.js +39 -9
  203. package/dist/release-cli.d.ts +4 -0
  204. package/dist/release-cli.js +29 -22
  205. package/dist/release-coverage.js +1 -1
  206. package/dist/release-next.d.ts +48 -0
  207. package/dist/release-next.js +161 -0
  208. package/dist/release.d.ts +20 -1
  209. package/dist/release.js +76 -8
  210. package/dist/remote-destination.d.ts +36 -0
  211. package/dist/remote-destination.js +217 -0
  212. package/dist/remote-main.d.ts +48 -0
  213. package/dist/remote-main.js +84 -5
  214. package/dist/repository-context.d.ts +3 -2
  215. package/dist/repository-context.js +112 -70
  216. package/dist/restart-certification.d.ts +1 -0
  217. package/dist/restart-certification.js +8 -6
  218. package/dist/result-acts.d.ts +27 -19
  219. package/dist/result-acts.js +36 -25
  220. package/dist/result-follow-ups.d.ts +2 -2
  221. package/dist/result-follow-ups.js +18 -4
  222. package/dist/result-review.d.ts +1 -1
  223. package/dist/route-change-check.d.ts +4 -0
  224. package/dist/route-change-check.js +13 -0
  225. package/dist/route-change.d.ts +22 -0
  226. package/dist/route-change.js +89 -0
  227. package/dist/route-hooks.js +3 -2
  228. package/dist/route-results.d.ts +6 -1
  229. package/dist/route-results.js +14 -5
  230. package/dist/run-closeout.d.ts +1 -1
  231. package/dist/run-closeout.js +7 -5
  232. package/dist/runner-process.js +3 -2
  233. package/dist/runner.js +2 -1
  234. package/dist/scope.d.ts +6 -1
  235. package/dist/scope.js +22 -9
  236. package/dist/serve.d.ts +1 -1
  237. package/dist/serve.js +24 -18
  238. package/dist/server/chat.js +14 -12
  239. package/dist/server/chrome.d.ts +1 -1
  240. package/dist/server/chrome.js +26 -35
  241. package/dist/server/console.css +23 -0
  242. package/dist/server/guards.js +3 -0
  243. package/dist/server/home.d.ts +6 -0
  244. package/dist/server/home.js +21 -13
  245. package/dist/server/http.js +1 -1
  246. package/dist/server/kit.js +1 -1
  247. package/dist/server/merge-ready-view.d.ts +10 -0
  248. package/dist/server/merge-ready-view.js +30 -0
  249. package/dist/server/pages-assets.d.ts +1 -0
  250. package/dist/server/pages-assets.js +8 -5
  251. package/dist/server/pages.js +103 -82
  252. package/dist/server/render-chat.js +2 -2
  253. package/dist/server/render-code-map.d.ts +30 -0
  254. package/dist/server/render-code-map.js +137 -0
  255. package/dist/server/render-flaky.d.ts +20 -0
  256. package/dist/server/render-flaky.js +59 -0
  257. package/dist/server/render-history.js +1 -1
  258. package/dist/server/render-new-task.d.ts +26 -1
  259. package/dist/server/render-new-task.js +30 -5
  260. package/dist/server/render-pages.d.ts +1 -175
  261. package/dist/server/render-pages.js +38 -313
  262. package/dist/server/render-projects.d.ts +2 -1
  263. package/dist/server/render-projects.js +9 -7
  264. package/dist/server/render-release.d.ts +2 -0
  265. package/dist/server/render-release.js +3 -3
  266. package/dist/server/render-results.d.ts +2 -0
  267. package/dist/server/render-results.js +67 -67
  268. package/dist/server/render-tasks.d.ts +13 -2
  269. package/dist/server/render-tasks.js +153 -136
  270. package/dist/server/retired-routes.d.ts +17 -0
  271. package/dist/server/retired-routes.js +53 -0
  272. package/dist/server/route-table.d.ts +3 -0
  273. package/dist/server/route-table.js +45 -12
  274. package/dist/server/settings.js +129 -22
  275. package/dist/server/task-views.js +61 -26
  276. package/dist/server/tasks.d.ts +2 -6
  277. package/dist/server/tasks.js +174 -321
  278. package/dist/server/welcome.js +1 -1
  279. package/dist/settings-groups-ui.js +1 -1
  280. package/dist/setup-guide.d.ts +2 -2
  281. package/dist/stop-proof.js +6 -4
  282. package/dist/storage-sweep-process.js +1 -1
  283. package/dist/storage-sweep.d.ts +5 -3
  284. package/dist/storage-sweep.js +30 -9
  285. package/dist/storage-ui.js +28 -17
  286. package/dist/storage.d.ts +27 -2
  287. package/dist/storage.js +39 -2
  288. package/dist/store.d.ts +80 -5
  289. package/dist/store.js +296 -87
  290. package/dist/surface.js +45 -4
  291. package/dist/sweep-quarantine.d.ts +1 -0
  292. package/dist/sweep-quarantine.js +6 -4
  293. package/dist/tamper-scan.d.ts +4 -0
  294. package/dist/tamper-scan.js +90 -0
  295. package/dist/task-aliases.d.ts +5 -0
  296. package/dist/task-aliases.js +9 -0
  297. package/dist/task-control.js +4 -1
  298. package/dist/task-outcome-cli.d.ts +2 -0
  299. package/dist/task-outcome-cli.js +17 -17
  300. package/dist/task-sizing.d.ts +5 -0
  301. package/dist/task-sizing.js +2 -0
  302. package/dist/task-source.d.ts +7 -0
  303. package/dist/task-source.js +14 -0
  304. package/dist/task-status.d.ts +9 -0
  305. package/dist/task-status.js +40 -49
  306. package/dist/teardown-git.d.ts +16 -0
  307. package/dist/teardown-git.js +63 -5
  308. package/dist/telegram-progress.js +5 -3
  309. package/dist/telegram-status.js +6 -4
  310. package/dist/test-infra.d.ts +8 -0
  311. package/dist/test-infra.js +18 -0
  312. package/dist/tidy.js +1 -1
  313. package/dist/toolroll-update.js +17 -5
  314. package/dist/tui-contract.d.ts +24 -0
  315. package/dist/tui-source.js +67 -8
  316. package/dist/tui.js +51 -51
  317. package/dist/vanished-retry.d.ts +30 -0
  318. package/dist/vanished-retry.js +174 -0
  319. package/dist/work-index.d.ts +2 -0
  320. package/dist/work-index.js +76 -62
  321. package/dist/work-summary.js +2 -0
  322. package/dist/workspace-ui.d.ts +4 -2
  323. package/dist/workspace-ui.js +28 -22
  324. package/dist/worktree.d.ts +12 -3
  325. package/dist/worktree.js +68 -13
  326. package/package.json +1 -1
  327. package/dist/server/render-next.d.ts +0 -63
  328. package/dist/server/render-next.js +0 -109
@@ -131,6 +131,8 @@ export type AutoObservers = {
131
131
  /** Whether main's protection holds for the account that merges (readMainProtection); null when unreadable. */
132
132
  upToDateRequired: () => Promise<boolean | null>;
133
133
  evidenceRoot: string;
134
+ /** The cases that ran in `sha`'s macOS CI (fenced-check-coverage.ts); null when they can't be read. Absent: never read. */
135
+ macosCases?: (sha: string) => Promise<ReadonlyMap<string, boolean> | null>;
134
136
  };
135
137
  /**
136
138
  * Every check run GitHub holds for one commit — every attempt, not only the latest (`filter=all`; GitHub's default
@@ -169,6 +171,13 @@ export declare function readMergeBase(run: Exec, gh: string, repo: string, base:
169
171
  * never from a ref in a checkout a build can write.
170
172
  */
171
173
  export declare function readMainHead(run: Exec, gh: string, repo: string, branch?: string): Promise<string>;
174
+ /** GitHub's own "Update branch", with its expected head: a push that got there first refuses. */
175
+ export declare function updatePullRequestBranch(run: Exec, gh: string, repo: string, pr: number, expectedHead: string): Promise<{
176
+ ok: true;
177
+ } | {
178
+ ok: false;
179
+ why: string;
180
+ }>;
172
181
  /** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
173
182
  export declare function readContains(run: Exec, gh: string, repo: string, sha: string, ancestor: string): Promise<boolean>;
174
183
  /**
@@ -24,6 +24,10 @@ import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, open
24
24
  import { acquireDeploymentLock } from "./deploy-lock.js";
25
25
  import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
26
26
  import { recheckReleaseTask, releaseCheckStanding } from "./check-failure.js";
27
+ import { CHECK_REPORT_CAPTURE, readSealedJson } from "./check-report.js";
28
+ import { readCheckReport } from "./contracts/release-check.js";
29
+ import { macosCases, skipCoverage, skippedCases, uncoveredWords } from "./fenced-check-coverage.js";
30
+ import { runCheckStamp } from "./store.js";
27
31
  import { noteCiRunning, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
28
32
  import { autoPublishHeld, holdAutoPublish, liftCiHold, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
29
33
  import { proveCarry } from "./release-carry.js";
@@ -436,6 +440,7 @@ export function releaseAdapters(options) {
436
440
  };
437
441
  const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(reads, ciGh, releaseRepo().slug, base, sha),
438
442
  upToDateRequired: () => readUpToDateRequired(reads, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
443
+ macosCases: sha => macosCasesOf(run, ciGh, releaseRepo().slug, sha),
439
444
  // A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
440
445
  // executor protects that reporting channel, production releases must take the person's approval road.
441
446
  reportsProtected: false },
@@ -660,6 +665,7 @@ export function releaseAdapters(options) {
660
665
  async complete(repo, sha) { return readSuitesComplete(reads, ciGh, repo, sha); },
661
666
  async mainHead(repo) { return readMainHead(reads, ciGh, repo); },
662
667
  async contains(repo, sha, ancestor) { return readContains(reads, ciGh, repo, sha, ancestor); },
668
+ async updateBranch(repo, pr, expectedHead) { return updatePullRequestBranch(reads, ciGh, repo, pr, expectedHead); },
663
669
  async upToDateRequired(repo) { return readMainProtection(reads, ciGh, repo); },
664
670
  async merge(repo, pr, head, message) {
665
671
  // The squash commit's message lists every commit that landed after the approval (release-once.md). Never asked
@@ -989,6 +995,33 @@ nightlyReads) {
989
995
  return answer.action === "rerun" ? { action: "rerun", why: answer.why } : answer.action === "pending" ? { action: "pending" } : { action: "stop", class: answer.class, why: answer.why };
990
996
  });
991
997
  },
998
+ async fencedCoverage(_taskId, run, candidate) {
999
+ // Today's road, read first and answered at once: a check that ran unsandboxed skips nothing for this reason. A run
1000
+ // from before stamps (a release resumed across an update) is judged by the level its dispatch recorded.
1001
+ const asked = withStore(store => {
1002
+ if (store.runCheckAccess(run) === "unsandboxed")
1003
+ return null;
1004
+ const reading = runCheckStamp(store, run);
1005
+ if (reading.state !== "current")
1006
+ return { refused: "how its check ran wasn't recorded, so what it skipped can't be judged" };
1007
+ if (reading.stamp.level === "unsandboxed")
1008
+ return null;
1009
+ const sealed = readSealedJson(store, auto.evidenceRoot, run, CHECK_REPORT_CAPTURE);
1010
+ const report = sealed === null ? null : readCheckReport(sealed.value);
1011
+ return report?.ok === true ? { skipped: skippedCases(report.value) } : { refused: "its report can't be read, so what it skipped can't be judged" };
1012
+ });
1013
+ if (asked === null)
1014
+ return { ok: true, covered: 0 };
1015
+ if ("refused" in asked)
1016
+ return { ok: false, why: asked.refused };
1017
+ if (asked.skipped.length === 0)
1018
+ return { ok: true, covered: 0 };
1019
+ const macos = await auto.macosCases?.(candidate) ?? null;
1020
+ if (macos === null)
1021
+ return { ok: false, why: "this commit's macOS CI reports couldn't be read, so what the sandboxed check skipped isn't covered" };
1022
+ const coverage = skipCoverage(asked.skipped, macos);
1023
+ return coverage.uncovered.length > 0 ? { ok: false, why: uncoveredWords(coverage.uncovered) } : { ok: true, covered: coverage.covered.length };
1024
+ },
992
1025
  async standing(taskId, run) {
993
1026
  return withStore(store => {
994
1027
  const standing = releaseCheckStanding(store, auto.evidenceRoot, { taskId, run });
@@ -1317,6 +1350,13 @@ export async function readMainHead(run, gh, repo, branch = "main") {
1317
1350
  throw new Error(`GitHub named no commit for ${branch}`);
1318
1351
  return match[1];
1319
1352
  }
1353
+ /** GitHub's own "Update branch", with its expected head: a push that got there first refuses. */
1354
+ export async function updatePullRequestBranch(run, gh, repo, pr, expectedHead) {
1355
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(pr) || pr < 1 || !/^[0-9a-f]{40}$/.test(expectedHead))
1356
+ return { ok: false, why: "not a pull request GitHub can be asked to update" };
1357
+ const ran = await run(gh, ["api", "--hostname", "github.com", "--method", "PUT", `repos/${repo}/pulls/${pr}/update-branch`, "-f", `expected_head_sha=${expectedHead}`], { cwd: "/", env: ciEnv() });
1358
+ return ran.code === 0 ? { ok: true } : { ok: false, why: (ran.stderr.trim().split("\n")[0] ?? "").slice(0, 200) || `gh exited ${ran.code}` };
1359
+ }
1320
1360
  /** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
1321
1361
  export async function readContains(run, gh, repo, sha, ancestor) {
1322
1362
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(ancestor))
@@ -1530,3 +1570,39 @@ export function releaseProgressRecorder(open, done) {
1530
1570
  }
1531
1571
  };
1532
1572
  }
1573
+ /** The macOS CI's cases on exactly `sha`: the newest pull-request run of ci.yml there, each shard's newest attempt. Null
1574
+ * when they can't all be read. The folder is this function's own, removed before it returns. */
1575
+ async function macosCasesOf(run, gh, slug, sha) {
1576
+ const folder = mkdtempSync(join(tmpdir(), "so-macos-ci-"));
1577
+ try {
1578
+ const listed = await run(gh, ["api", `repos/${slug}/actions/runs?head_sha=${sha}&event=pull_request&per_page=100`], { timeoutMs: 60_000 });
1579
+ if (listed.code !== 0)
1580
+ return null;
1581
+ const runs = JSON.parse(listed.stdout).workflow_runs ?? [];
1582
+ const newest = runs.filter(one => typeof one.id === "number" && one.head_sha === sha && (one.path === ".github/workflows/ci.yml" || String(one.path).startsWith(".github/workflows/ci.yml@"))).map(one => one.id).sort((a, b) => b - a)[0];
1583
+ if (newest === undefined)
1584
+ return null;
1585
+ const got = await run(gh, ["run", "download", String(newest), "--repo", slug, "--pattern", "macos-shard-*", "--dir", folder], { timeoutMs: 180_000 });
1586
+ if (got.code !== 0)
1587
+ return null;
1588
+ const shards = new Map();
1589
+ for (const name of readdirSync(folder)) {
1590
+ const match = /^macos-(shard-\d+-\d+)-a([1-9]\d*)$/.exec(name);
1591
+ if (match === null)
1592
+ return null;
1593
+ const was = shards.get(match[1]);
1594
+ if (was === undefined || Number(match[2]) > was.attempt)
1595
+ shards.set(match[1], { attempt: Number(match[2]), name });
1596
+ }
1597
+ if (shards.size === 0)
1598
+ return null;
1599
+ const reports = [...shards.values()].map(one => JSON.parse(readFileSync(join(folder, one.name, "shard-report.json"), "utf8")));
1600
+ return macosCases(reports, `/Users/runner/work/${slug.split("/")[1]}/${slug.split("/")[1]}`);
1601
+ }
1602
+ catch {
1603
+ return null;
1604
+ }
1605
+ finally {
1606
+ rmSync(folder, { recursive: true, force: true });
1607
+ }
1608
+ }
@@ -19,7 +19,7 @@ import { sealedRun } from "./release-recheck.js";
19
19
  import { runCheckLevel } from "./check-levels.js";
20
20
  import { readVerifiedCommit, verifiedBlobs, verifiedTreeDiff, verifiedTreeEntries } from "./commit-extract.js";
21
21
  import { EVAL_HOOK_FILES, evalHookProblem, LEARNING_HOOK_FILES, learningHookProblem, storeRewriteProblem } from "./eval-hooks.js";
22
- import { mightName, testInfrastructure } from "./test-infra.js";
22
+ import { isTestPath, mightName, testImportProblem, testInfrastructure } from "./test-infra.js";
23
23
  import { chatControlHref } from "./chat-controls.js";
24
24
  import { INFRA_RERUNS_PER_CANDIDATE, REQUIRED_PR_CHECKS, REQUIRED_PR_CHECKS_WORKFLOW, prChecksVerdict } from "./release.js";
25
25
  import { releaseCheckStanding } from "./check-failure.js";
@@ -359,18 +359,18 @@ function changeProblem(repo, base, candidate) {
359
359
  // Taking a test away is never automatic, wherever it lives.
360
360
  if (change.kind === "deleted" && TEST_FILE.test(rulePath(change.path)))
361
361
  return `${change.path} was deleted (removing a test needs a person)`;
362
- if (change.before !== null && TEST_FILE.test(rulePath(change.path))) {
363
- // Counts cannot prove that an assertion survived: replacing it, commenting it out, or skipping its case can
364
- // leave the counts unchanged. Only append-only additions preserve the existing code without interpretation.
365
- // Both copies come from verified trees; no report or candidate-provided file list decides this permission.
366
- const was = verifiedBlobs(gitDir, before.value.tree, [change.path]);
367
- const now = verifiedBlobs(gitDir, after.value.tree, [change.path]);
368
- if (!was.ok || !now.ok)
369
- return `${change.path}'s existing test code couldn't be read (a person decides)`;
370
- const original = was.value.get(change.path)?.bytes, revised = now.value.get(change.path)?.bytes;
371
- if (original === undefined || revised === undefined || !revised.subarray(0, original.length).equals(original)) {
372
- return `${change.path} changes existing test code (removing or rewriting tests needs a person)`;
373
- }
362
+ // Counts cannot prove that an assertion survived, and an appended line can disarm what it follows (`vi.mock` hoists,
363
+ // a top-level hook reaches every case): a test file that was there stays exactly as it was (H4).
364
+ if (change.before !== null && TEST_FILE.test(rulePath(change.path)))
365
+ return `${change.path} changes existing test code (new tests go in a new file; rewriting tests needs a person)`;
366
+ // A new test may not reach into another: it would run that file's code, and its mocks, here. From the verified tree.
367
+ // Any test extension: `src/` passes autoPathProblem whatever the file is called, so `.test.ts` alone would miss `.test.tsx`.
368
+ if (change.kind === "added" && isTestPath(rulePath(change.path))) {
369
+ const read = verifiedBlobs(gitDir, after.value.tree, [change.path]);
370
+ const bytes = read.ok ? read.value.get(change.path)?.bytes : undefined;
371
+ const why = bytes === undefined ? `${change.path}'s test code couldn't be read` : testImportProblem(change.path, bytes.toString("utf8"));
372
+ if (why !== null)
373
+ return `${why} (a person decides)`;
374
374
  }
375
375
  const why = autoPathProblem(change.path);
376
376
  if (why !== null)
@@ -1,6 +1,6 @@
1
1
  import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
2
2
  import type { Scope } from "./scope.js";
3
- import type { Store } from "./store.js";
3
+ import { type Store } from "./store.js";
4
4
  /** One signed term as the card says it. `key` names the scope field or the release term's path. */
5
5
  export type TermLine = {
6
6
  key: string;
@@ -151,7 +151,6 @@ export type ReleaseCard = {
151
151
  };
152
152
  /** The card digest: everything the person is shown that can change under them. */
153
153
  export declare function cardDigestOf(scopeDigest: string, formDigest: string, changes: Pick<ChangeList, "baseline" | "digest">): string;
154
- /** The release card for a deploy gate, against the live records now. */
155
154
  export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
156
155
  profile: (profile: Scope["profile"]) => string | null;
157
156
  }, read?: PullRequestReader): ReleaseCard;
@@ -20,6 +20,7 @@ import { createHash } from "node:crypto";
20
20
  import { lstatSync, realpathSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { checkAccessWords } from "./check-access.js";
23
+ import { ranWords } from "./check-stamp.js";
23
24
  import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
24
25
  import { verifiedRange } from "./commit-extract.js";
25
26
  import { releaseCheckPurposeSchema, releaseTermsSchema } from "./contracts/release-terms.js";
@@ -29,6 +30,7 @@ import { routeFromJson } from "./phase-routing.js";
29
30
  import { projectName } from "./project.js";
30
31
  import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
31
32
  import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
33
+ import { taskCheckStamp } from "./store.js";
32
34
  import { nightlyReleaseLines } from "./nightly-github.js";
33
35
  const NONE = "none";
34
36
  /** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
@@ -382,6 +384,13 @@ function changeWords(changes, terms, cap) {
382
384
  return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
383
385
  }
384
386
  /** The release card for a deploy gate, against the live records now. */
387
+ /** How this task's last check ran, when one did and it wasn't plainly sandboxed: the sealed level above is what will run;
388
+ * this is what did. Nothing for a release no check has run for. */
389
+ function lastCheckWords(store, repo, taskId) {
390
+ const ran = repo === null ? null : taskCheckStamp(store, repo, taskId);
391
+ const words = ran === null ? null : ranWords(ran.reading);
392
+ return words === null ? [] : [`Last check: ${words}.`];
393
+ }
385
394
  export function releaseCardOf(store, scope, formDigest, words, read) {
386
395
  const terms = scope.releaseTerms ?? null;
387
396
  // Either sealed form (Codex round 2, finding 1): the identity both share, for the head lines and the change list.
@@ -400,6 +409,8 @@ export function releaseCardOf(store, scope, formDigest, words, read) {
400
409
  : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
401
410
  // What an unsandboxed check means, before consent (Codex round 2, finding 1).
402
411
  ...(identity === null || releaseAccessWords(identity.access) === null ? [] : [releaseAccessWords(identity.access)]),
412
+ // A check that already ran for this task, from its stamp (H2): never read as sandboxed when it can't be shown.
413
+ ...lastCheckWords(store, identity?.repo ?? null, scope.taskId),
403
414
  // Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
404
415
  ...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
405
416
  // Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
@@ -44,8 +44,13 @@ export declare function stricterOf(a: ReleasePolicy | null, b: ReleasePolicy | n
44
44
  * the rules don't rely on that).
45
45
  */
46
46
  export declare function rulePath(path: string): string;
47
- /** Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. */
48
- export declare function changeRefusal(change: TreeChange, policy: ReleasePolicy): string | null;
47
+ /**
48
+ * Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
49
+ * paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
50
+ * files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
51
+ * appended line can disarm what it follows.
52
+ */
53
+ export declare function changeRefusal(change: TreeChange, policy: ReleasePolicy, fresh?: ReadonlySet<string>): string | null;
49
54
  /**
50
55
  * A release scope's terms, from the plane's own records: the project's repository, the approved commit and its
51
56
  * verified tree, the effective policy, the live release check grant and the check access (only the fence), and the
@@ -26,14 +26,17 @@ import { join, sep } from "node:path";
26
26
  import { execFileSync } from "node:child_process";
27
27
  import { checkLaunchRoot, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
28
28
  import { touchesProtected } from "./approval-policy.js";
29
- import { extractCommit, readVerifiedCommit, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
29
+ import { extractCommit, readVerifiedCommit, verifiedBlobs, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
30
+ import { testImportProblem } from "./test-infra.js";
30
31
  import { projectGitDir } from "./check-report.js";
32
+ import { checkAccessOf } from "./check-access.js";
33
+ import { stampRefusal } from "./check-stamp.js";
31
34
  import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./contracts/release-terms.js";
32
35
  import { run } from "./exec.js";
33
36
  import { proxyEnv, startEgressProxy } from "./egress.js";
34
37
  import { liveReleaseOf } from "./scope.js";
35
38
  import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
36
- import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
39
+ import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, taskCheckStamp, } from "./store.js";
37
40
  // ------------------------------------------------------------------ the rules
38
41
  /** The journey scripts: tests, by Alex's decision (they carry). */
39
42
  export const JOURNEY_SCRIPTS = Object.freeze(["scripts/app-e2e.mjs", "scripts/flows-e2e.mjs"]);
@@ -111,8 +114,15 @@ export function stricterOf(a, b) {
111
114
  export function rulePath(path) {
112
115
  return path.toLowerCase().split("/").map(part => part.replace(/[. ]+$/, "")).filter(part => part !== "" && part !== ".").join("/");
113
116
  }
114
- /** Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. */
115
- export function changeRefusal(change, policy) {
117
+ /** Tests by the rules' classes: what the existing-test rule holds still. */
118
+ const carriedTest = (normal) => CARRY_RULES.classes.some(one => (one.name === "unit tests" || one.name === "journey scripts") && one.matches(normal));
119
+ /**
120
+ * Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
121
+ * paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
122
+ * files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
123
+ * appended line can disarm what it follows.
124
+ */
125
+ export function changeRefusal(change, policy, fresh = new Set()) {
116
126
  const path = change.path;
117
127
  const normal = rulePath(path);
118
128
  if (change.kind === "deleted")
@@ -133,6 +143,8 @@ export function changeRefusal(change, policy) {
133
143
  return `${path} is protected in this project`;
134
144
  if (!CARRY_RULES.classes.some(one => one.matches(normal)))
135
145
  return `${path} isn't a test, a doc or the changelog`;
146
+ if (change.kind === "modified" && carriedTest(normal) && !fresh.has(path))
147
+ return `${path} changes an existing test, and new tests go in a new file`;
136
148
  return null;
137
149
  }
138
150
  // ------------------------------------------------------------------ release terms
@@ -211,6 +223,11 @@ export function preflightCarry(store, input) {
211
223
  if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
212
224
  return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
213
225
  }
226
+ // A carry leans on the check the release already had: it must be shown to have stayed within what was approved (H2).
227
+ const ran = taskCheckStamp(store, a.repo, a.tipTask);
228
+ const unshown = ran === null ? null : stampRefusal(ran.reading, checkAccessOf(a.accessDigest));
229
+ if (unshown !== null)
230
+ return refuse("rules", `This release's approval can't carry: ${unshown}.`);
214
231
  if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
215
232
  return refuse("ancestry", `${input.to} isn't a full commit id.`);
216
233
  if (input.to === a.tipSha)
@@ -234,6 +251,10 @@ export function preflightCarry(store, input) {
234
251
  treeOf.set(commit.id, commit.tree);
235
252
  const commits = [];
236
253
  let churn = 0;
254
+ const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
255
+ if (!whole.ok)
256
+ return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
257
+ const fresh = new Set(whole.value.filter(one => one.kind === "added").map(one => one.path));
237
258
  for (const commit of ordered) {
238
259
  const parentTree = treeOf.get(commit.parents[0]);
239
260
  if (parentTree === undefined)
@@ -242,21 +263,30 @@ export function preflightCarry(store, input) {
242
263
  if (!diff.ok)
243
264
  return refuse("shape", `${commit.id.slice(0, 7)} couldn't be read: ${diff.message}`);
244
265
  for (const change of diff.value) {
245
- const why = changeRefusal(change, policy);
266
+ const why = changeRefusal(change, policy, fresh);
246
267
  if (why !== null)
247
268
  return refuse("rules", `${commit.id.slice(0, 7)}: ${why}.`, change.path);
248
269
  }
249
270
  churn += diff.value.reduce((n, one) => n + one.added + one.deleted, 0);
250
271
  commits.push({ sha: commit.id, files: diff.value.map(one => ({ path: one.path, added: one.added, deleted: one.deleted })) });
251
272
  }
252
- const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
253
- if (!whole.ok)
254
- return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
255
273
  for (const change of whole.value) {
256
- const why = changeRefusal(change, policy);
274
+ const why = changeRefusal(change, policy, fresh);
257
275
  if (why !== null)
258
276
  return refuse("rules", `Since the approval: ${why}.`, change.path);
259
277
  }
278
+ // No new test may reach into another (H4). Read from the verified tree, never from a checkout.
279
+ // By the rules' own classifier, so everything that carries as a test is read (a new journey script too).
280
+ const newTests = whole.value.filter(one => one.kind === "added" && carriedTest(rulePath(one.path))).map(one => one.path);
281
+ const added = newTests.length === 0 ? null : verifiedBlobs(gitDir, chain[0].tree, newTests);
282
+ if (added !== null && !added.ok)
283
+ return refuse("shape", `The new tests couldn't be read: ${added.message}`);
284
+ for (const path of newTests) {
285
+ const bytes = added !== null && added.ok ? added.value.get(path)?.bytes : undefined;
286
+ const why = bytes === undefined ? `${path} couldn't be read` : testImportProblem(path, bytes.toString("utf8"));
287
+ if (why !== null)
288
+ return refuse("rules", `Since the approval: ${why}.`, path);
289
+ }
260
290
  const changedLines = whole.value.reduce((n, one) => n + one.added + one.deleted, 0);
261
291
  if (changedLines > policy.limits.lines)
262
292
  return refuse("limits", `${changedLines} lines changed since the approval; at most ${policy.limits.lines} carry.`);
@@ -8,6 +8,7 @@
8
8
  import { type ReleaseAdapters } from "./release.js";
9
9
  import { type GateReleaseWorld } from "./release-gate.js";
10
10
  import { type RescueWorld } from "./deploy-rescue.js";
11
+ import { type RunLockSeams, type SettleWorld } from "./release-next.js";
11
12
  export declare const RELEASE_HELP: string;
12
13
  /** A runner lease id (claim.ts mints randomUUID()). */
13
14
  export declare const LEASE_SHAPED: RegExp;
@@ -23,6 +24,9 @@ export type ReleaseCliDeps = {
23
24
  gateWorld?: GateReleaseWorld;
24
25
  /** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
25
26
  rescueWorld?: RescueWorld;
27
+ /** What a stopped deployment left, settled before the release goes on (release-next.ts). Tests that inject adapters skip it. */
28
+ settle?: SettleWorld;
29
+ runLock?: RunLockSeams;
26
30
  /** The plane's database for the authority commands (default: this installation's). */
27
31
  database?: string;
28
32
  now?: () => Date;
@@ -16,6 +16,7 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
17
  import { journals, releaseGate } from "./release-gate.js";
18
18
  import { rescueDeployment } from "./deploy-rescue.js";
19
+ import { settleDeployment, takeReleaseRun } from "./release-next.js";
19
20
  import { databasePath, elapsedSeconds, latestReleaseStepTask, openStore, releaseProgressFor, revokeRelease } from "./store.js";
20
21
  import { dur } from "./design/kit.js";
21
22
  import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
@@ -34,7 +35,10 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
34
35
  squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
35
36
  publication; Homebrew waits for its checks to register and all pass before merging.
36
37
 
37
- Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
38
+ Run the same command to go on after any stop. It reads where the release got to and does the one thing that's next:
39
+ finishes a deployment forward, puts one back, lifts a pause left before a swap, waits for another run of the release
40
+ (naming its computer and process), or waits for your approval. It says what it did. A branch behind main is brought up
41
+ to date for you; that new commit needs its own approval, CI and check.
38
42
 
39
43
  A branch whose name is one of this command's own words (approve, status, nightly, carry…) is named with --branch <name>
40
44
  or after --: \`toolroll release --branch status\`.
@@ -47,19 +51,6 @@ toolroll release approve [<branch>] [--repo <path>] — approve the release wait
47
51
  toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
48
52
  step's state and how long it took, and where it stopped
49
53
 
50
- toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
51
-
52
- Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
53
- database, service definition and running service are all still the previous build's. --yes lifts that one pause.
54
- Any missing or contradicting evidence refuses and leaves the pause in place.
55
-
56
- toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
57
-
58
- Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
59
- the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
60
- proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
61
- again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
62
-
63
54
  toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
64
55
 
65
56
  Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
@@ -100,10 +91,6 @@ Options
100
91
  --new release a new commit on the branch, setting the earlier unfinished release aside
101
92
  --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
102
93
  --tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
103
- --release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
104
- --yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
105
- --recover-deploy recover the unfinished deployment (see above)
106
- --stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
107
94
  --json answer with one machine envelope
108
95
 
109
96
  A runner's lease is returned with \`toolroll worker release <lease>\`.`;
@@ -270,8 +257,23 @@ export async function runReleaseCommand(args, write, deps = {}) {
270
257
  const adapters = deps.adapters === undefined
271
258
  ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
272
259
  : { ...deps.adapters, say };
273
- const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
274
- return report(outcome, write, json, lines);
260
+ // One command: wait out another run of this release, settle what a stopped deployment left, then go on from the journal.
261
+ const taken = await takeReleaseRun(stateDir, branch, say, deps.runLock);
262
+ if (!taken.ok)
263
+ return refuse("running", `Another release of ${branch} is still running on ${taken.holder.host} (process ${taken.holder.pid}). Nothing was changed.`, 3);
264
+ try {
265
+ const world = deps.settle ?? (deps.adapters === undefined ? { rescue: installedRescueWorld(checkout), gate: installedGateWorld() } : null);
266
+ if (world !== null) {
267
+ const settled = await settleDeployment(world, say);
268
+ if (!settled.ok)
269
+ return refuse("deployment", settled.message, 3);
270
+ }
271
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
272
+ return report(outcome, write, json, lines);
273
+ }
274
+ finally {
275
+ taken.release();
276
+ }
275
277
  }
276
278
  function report(outcome, write, json, lines) {
277
279
  const release = outcome.journal === null ? null : {
@@ -590,10 +592,15 @@ async function runStatusCommand(task, write, json, deps) {
590
592
  }
591
593
  const word = { running: "running", waiting: "waiting", done: "done", failed: "failed" };
592
594
  write(`Release ${progress.version} (${progress.tag})${progress.pr === null ? "" : ` · pull request #${progress.pr}`} · task ${taskId}`);
593
- for (const one of steps)
594
- write(` ${one.step.padEnd(9)} ${word[one.state].padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
595
+ for (const one of steps) {
596
+ // A failure the release has gone on from is history, as on the task page.
597
+ const passed = one.state === "failed" && progress.stopped === null;
598
+ write(` ${one.step.padEnd(9)} ${(passed ? "went on" : word[one.state]).padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
599
+ }
595
600
  if (progress.stopped !== null)
596
601
  write(`Stopped at ${progress.stopped.step}: ${progress.stopped.message}`);
602
+ if (progress.building !== null)
603
+ write(`Build #${progress.building.run} is running the check.`);
597
604
  return 0;
598
605
  }
599
606
  finally {
@@ -26,7 +26,7 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
26
26
  let answer;
27
27
  try {
28
28
  // Every git version through a frozen read view, bounded (eval-harness review 1, finding 7).
29
- const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000 }).status;
29
+ const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000, binding: { repo } }).status;
30
30
  if (status !== 0)
31
31
  throw Object.assign(new Error("git merge-base"), { status });
32
32
  answer = true;
@@ -0,0 +1,48 @@
1
+ import { type RescueWorld } from "./deploy-rescue.js";
2
+ import { type GateReleaseWorld } from "./release-gate.js";
3
+ import { type ReleaseJournal } from "./release.js";
4
+ import type { Store } from "./store.js";
5
+ /** How long a run waits for another before it says so and stops. */
6
+ export declare const RELEASE_WAIT_MS: number;
7
+ export type RunHolder = {
8
+ host: string;
9
+ pid: number;
10
+ };
11
+ export type RunLockSeams = {
12
+ host?: string;
13
+ pid?: number;
14
+ alive?: (pid: number) => boolean;
15
+ sleep?: (ms: number) => Promise<void>;
16
+ now?: () => number;
17
+ waitMs?: number;
18
+ pollMs?: number;
19
+ };
20
+ /** Take this branch's run lock, waiting for a live holder. Answers how to give it back, or who kept it. */
21
+ export declare function takeReleaseRun(stateDir: string, branch: string, say: (line: string) => void, seams?: RunLockSeams): Promise<{
22
+ ok: true;
23
+ release: () => void;
24
+ } | {
25
+ ok: false;
26
+ holder: RunHolder;
27
+ }>;
28
+ export type SettleWorld = {
29
+ rescue: RescueWorld;
30
+ gate: GateReleaseWorld;
31
+ };
32
+ export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
33
+ /** The one action a deployment's journal calls for. */
34
+ export declare function deployActionOf(phase: unknown): DeployAction;
35
+ /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
36
+ export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
37
+ ok: true;
38
+ } | {
39
+ ok: false;
40
+ message: string;
41
+ }>;
42
+ export declare const RELEASE_RESUMED_ACTION = "release resumed";
43
+ /**
44
+ * A person let a held release go on (release-auto.ts allowAutoPublish; the Release sheet's button or `publish-ok`): each
45
+ * sweep, its journal goes on from where it stopped, once per that person's act, under the same run lock. It was merged
46
+ * already, so only the tag, publication and Homebrew are left, and each re-proves itself.
47
+ */
48
+ export declare function resumeAllowedReleases(store: Store, stateDir: string, run: (journal: ReleaseJournal) => Promise<unknown>, now: Date): Promise<number>;