toolroll 0.9.76 → 0.9.78

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 (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
@@ -56,13 +56,23 @@ export async function superviseController(args) {
56
56
  if (args.signal.aborted)
57
57
  break;
58
58
  const exited = await new Promise(done => {
59
+ const output = args.label === undefined ? "inherit" : "pipe";
59
60
  const owned = spawn(args.file, [...args.argv], {
60
61
  ...(args.cwd === undefined ? {} : { cwd: args.cwd }),
61
62
  ...(args.env === undefined ? {} : { env: args.env }),
62
- stdio: "inherit",
63
+ stdio: args.ipc === true ? ["ignore", output, output, "ipc"] : args.label === undefined ? "inherit" : ["ignore", "pipe", "pipe"],
63
64
  windowsHide: true,
64
65
  });
65
66
  child = owned;
67
+ if (args.label !== undefined)
68
+ for (const stream of [owned.stdout, owned.stderr])
69
+ labelLines(stream, args.label);
70
+ try {
71
+ args.onSpawn?.(owned, generation);
72
+ }
73
+ catch {
74
+ process.stderr.write("Controller supervisor could not attach to its controller.\n");
75
+ }
66
76
  owned.once("spawn", () => {
67
77
  emit({ phase: "running", generation, controllerPid: owned.pid ?? null });
68
78
  if (args.signal.aborted)
@@ -102,3 +112,25 @@ export async function superviseController(args) {
102
112
  emit({ phase: "stopped", generation, controllerPid: null });
103
113
  }
104
114
  }
115
+ /** Each whole line of a controller's output, prefixed with its label, on this process's stderr. */
116
+ function labelLines(stream, label) {
117
+ if (stream === null)
118
+ return;
119
+ const prefix = label === "" ? "" : `${label} `;
120
+ let pending = "";
121
+ stream.setEncoding("utf8");
122
+ stream.on("data", (chunk) => {
123
+ const lines = (pending + chunk).split("\n");
124
+ pending = lines.pop() ?? "";
125
+ // Bounded: a controller that never ends a line still reaches the log.
126
+ if (pending.length > 64 * 1024) {
127
+ lines.push(pending);
128
+ pending = "";
129
+ }
130
+ for (const line of lines)
131
+ process.stderr.write(`${prefix}${line}\n`);
132
+ });
133
+ stream.on("end", () => { if (pending !== "")
134
+ process.stderr.write(`${prefix}${pending}\n`); pending = ""; });
135
+ stream.on("error", () => { });
136
+ }
package/dist/demo.js CHANGED
@@ -989,11 +989,12 @@ export function makeDemoRepos(root) {
989
989
  writeFileSync(join(dir, file), content);
990
990
  }
991
991
  try {
992
- execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir, stdio: "ignore" });
993
- execFileSync("git", ["add", "-A"], { cwd: dir, stdio: "ignore" });
992
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir, stdio: "ignore", timeout: 30_000 });
993
+ execFileSync("git", ["add", "-A"], { cwd: dir, stdio: "ignore", timeout: 30_000 });
994
994
  execFileSync("git", ["-c", "user.email=demo@localhost", "-c", "user.name=demo", "commit", "-q", "-m", "seed"], {
995
995
  cwd: dir,
996
996
  stdio: "ignore",
997
+ timeout: 30_000,
997
998
  });
998
999
  }
999
1000
  catch {
@@ -8,6 +8,7 @@
8
8
  import { assignmentOf } from "./assignment.js";
9
9
  import { liveReleaseOf, personApprovalOf, releaseApprovalOf } from "./scope.js";
10
10
  import { verificationEvidence } from "./verification-evidence.js";
11
+ import { releaseCiRunningOf } from "./release-ci-hold.js";
11
12
  /** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
12
13
  export function deploymentGate(store, input) {
13
14
  const refuse = (message) => ({ ok: false, message });
@@ -27,6 +28,10 @@ export function deploymentGate(store, input) {
27
28
  return refuse(`Run ${input.runId} is not a built candidate at ${head.slice(0, 7)}.`);
28
29
  if (scope === null || scope.approvedDigest !== scope.digest || scope.digest !== run.scopeDigest)
29
30
  return refuse("The run's signed scope is no longer the approved one.");
31
+ // A person may approve during CI, including a release without carry terms. Every deployment road must wait
32
+ // for the plane's exact-head observation, not only the release coordinator's polling loop.
33
+ if (releaseCiRunningOf(store, ref.externalId) !== null)
34
+ return refuse("This release still waits for GitHub CI. Resume it with `toolroll release`.");
30
35
  // One approval per release (release-once.md): a release's task deploys only while its authority stands for it — the
31
36
  // live predicate proves the anchor's person approval (a carried commit's own seal is never a person's). Any other task
32
37
  // needs a person's approval of its own.
@@ -0,0 +1,41 @@
1
+ /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
2
+ export declare const FORWARD_ONLY: readonly string[];
3
+ export type RescueWorld = {
4
+ /** The live database. */
5
+ database: string;
6
+ /** The installation's state folder: deployments stage under staged-upgrades/ there. */
7
+ stateDir: string;
8
+ /** Every deployment journal under staged-upgrades/: its stage and contents, null when unreadable. */
9
+ journals(): {
10
+ stage: string;
11
+ journal: Record<string, unknown> | null;
12
+ }[];
13
+ /** The builder run's worktree, from the plane's records (the deploy script's --source), or null. */
14
+ runWorktree(run: number): string | null;
15
+ /** The deployment's own recovery for that stage (release-adapters.ts recoverDeploy). */
16
+ recover(input: {
17
+ run: number;
18
+ worktree: string;
19
+ stage: string;
20
+ }): Promise<{
21
+ ok: true;
22
+ } | {
23
+ ok: false;
24
+ message: string;
25
+ }>;
26
+ };
27
+ export type RescueOutcome = {
28
+ ok: true;
29
+ acted: boolean;
30
+ stage: string | null;
31
+ phase: string | null;
32
+ words: string[];
33
+ } | {
34
+ ok: false;
35
+ reason: "usage" | "conflict" | "damaged" | "forward" | "failed";
36
+ message: string;
37
+ };
38
+ /** What recovery will do from a phase, in a few words. */
39
+ export declare function rescuePlan(phase: string, journal: Record<string, unknown>): string[];
40
+ /** Find the one unfinished deployment (or the named stage), verify its journal, and recover it (with `yes`). */
41
+ export declare function rescueDeployment(stageArg: string | undefined, yes: boolean, world: RescueWorld): Promise<RescueOutcome>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `toolroll release --recover-deploy [--stage <dir>] [--yes]`: one command for a browser deployment that stopped half
3
+ * way and could not put itself back (the 2026-10-29 deploy stuck at "stopping": an `up` left running under launchd
4
+ * that ignored SIGTERM, a restart refused while its stale heartbeat lapsed, and every resume refusing "not proved
5
+ * stopped"). It does what was done by hand, through the deployment's own recovery (scripts/deploy-browser.mjs
6
+ * --phase recover, run from the recorded commit's pristine extraction exactly as `toolroll release` resumes one):
7
+ *
8
+ * - the journal is verified (one unfinished deployment of this database, its run recorded) under the deployment lock
9
+ * - when the journal says the old service must be stopped, recovery requires proof it already exited; historical
10
+ * PIDs and argv never authorize signals or booting out a currently loaded service. Remaining processes must be
11
+ * stopped through their owner before recovery can resume.
12
+ * - the previous service is started again (the verified backup first when the database was migrated past it) and
13
+ * waited for past a stale worker heartbeat's window, then the deployment's update pause is lifted
14
+ *
15
+ * A journal at or past the new service's start is never touched here: the new service may be running, and the
16
+ * release resumes it forward. Finished journals need nothing. Run again after a refusal or a failure: every step
17
+ * re-proves itself, so a rerun does only what is still left.
18
+ */
19
+ import { realpathSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { BEFORE_SWAP } from "./release-gate.js";
22
+ /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
23
+ export const FORWARD_ONLY = Object.freeze(["starting", "started", "healthy"]);
24
+ const FINISHED = Object.freeze(["released", "deployed"]);
25
+ const real = (path) => { try {
26
+ return realpathSync(path);
27
+ }
28
+ catch {
29
+ return path;
30
+ } };
31
+ /** What recovery will do from a phase, in a few words. */
32
+ export function rescuePlan(phase, journal) {
33
+ const id = String(journal["id"] ?? "?"), prior = String(journal["priorRuntime"] ?? "?");
34
+ if (BEFORE_SWAP.includes(phase))
35
+ return [`The service was never stopped. Lifts update pause ${id}, if this deployment holds it.`];
36
+ if (phase === "restored")
37
+ return [`The previous service is already running again. Lifts update pause ${id}.`];
38
+ const words = [];
39
+ if (phase === "stopping") {
40
+ words.push("The old service must already be stopped. Recovery checks for remaining processes and refuses while any may still be running. Stop them through the service owner, then rerun recovery.");
41
+ }
42
+ if (["stopping", "stopped", "migrating", "migrated", "start-failed", "backup-restored"].includes(phase)) {
43
+ if (["migrating", "migrated", "start-failed"].includes(phase))
44
+ words.push("Puts the verified backup back first when the previous runtime can't read the migrated database.");
45
+ words.push(`Starts the previous service (${prior}) again and waits for it to answer, past a stopped worker's heartbeat window; then lifts update pause ${id}.`);
46
+ }
47
+ return words.length > 0 ? words : [`Journal phase ${phase}: the deployment's own recovery decides; it refuses anything it can't prove.`];
48
+ }
49
+ /** Find the one unfinished deployment (or the named stage), verify its journal, and recover it (with `yes`). */
50
+ export async function rescueDeployment(stageArg, yes, world) {
51
+ const all = world.journals();
52
+ let chosen;
53
+ if (stageArg !== undefined) {
54
+ chosen = all.find(one => real(one.stage) === real(stageArg));
55
+ if (chosen === undefined)
56
+ return { ok: false, reason: "usage", message: `${stageArg} is not a deployment stage of this installation (under ${join(world.stateDir, "staged-upgrades")}).` };
57
+ }
58
+ else {
59
+ const open = all.filter(one => one.journal === null || !FINISHED.includes(String(one.journal["phase"])));
60
+ if (open.length === 0)
61
+ return { ok: true, acted: false, stage: null, phase: null, words: ["Nothing to recover: every deployment finished or was already put back."] };
62
+ if (open.length > 1)
63
+ return { ok: false, reason: "conflict", message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Name one with --stage <dir>.` };
64
+ chosen = open[0];
65
+ }
66
+ const { stage, journal } = chosen;
67
+ if (journal === null)
68
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} can't be read; nothing was changed.` };
69
+ const phase = journal["phase"], run = journal["builder"];
70
+ if (typeof phase !== "string" || typeof journal["priorRuntime"] !== "string" || typeof journal["nextRuntime"] !== "string" || typeof journal["id"] !== "string") {
71
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} is missing its phase, runtimes or update id; nothing was changed.` };
72
+ }
73
+ if (journal["database"] !== world.database)
74
+ return { ok: false, reason: "conflict", message: `That deployment ran on ${String(journal["database"])}, not ${world.database}; nothing was changed.` };
75
+ if (!Number.isSafeInteger(run) || run < 1)
76
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} names no builder run, so its commit's recovery can't be found; nothing was changed.` };
77
+ if (FINISHED.includes(phase))
78
+ return { ok: true, acted: false, stage, phase, words: [`Nothing to recover: that deployment is ${phase}.`] };
79
+ if (FORWARD_ONLY.includes(phase)) {
80
+ return { ok: false, reason: "forward", message: `That deployment is at ${phase}: the new service may be running, so nothing was stopped. Continue it forward with \`toolroll release <branch>\` (it resumes the deployment's finish).` };
81
+ }
82
+ const plan = rescuePlan(phase, journal);
83
+ if (!yes)
84
+ return { ok: true, acted: false, stage, phase, words: [`Deployment ${String(journal["id"])} stopped at ${phase} (${stage}).`, ...plan, "Run it: toolroll release --recover-deploy --yes"] };
85
+ const worktree = world.runWorktree(run);
86
+ if (worktree === null)
87
+ return { ok: false, reason: "damaged", message: `Run ${String(run)} has no recorded worktree, so its commit's recovery can't run; nothing was changed.` };
88
+ const recovered = await world.recover({ run: run, worktree, stage });
89
+ const after = world.journals().find(one => one.stage === stage)?.journal ?? null;
90
+ const now = typeof after?.["phase"] === "string" ? after["phase"] : null;
91
+ if (!recovered.ok)
92
+ return { ok: false, reason: "failed", message: `${recovered.message}${now === null ? "" : ` The journal is at ${now}.`} Fix what it names, then run \`toolroll release --recover-deploy --yes\` again.` };
93
+ return { ok: true, acted: true, stage, phase: now, words: [now === "released" ? "The previous service is running and new work can start again." : `The deployment's recovery finished; its journal is at ${now ?? "an unreadable phase"}.`] };
94
+ }
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
+ version?: never;
100
101
  active: boolean;
101
102
  phase: string;
102
103
  detail: string;
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
110
111
  wasRunning?: never;
111
112
  backupPath?: never;
112
113
  updateId?: never;
113
- version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
package/dist/dispose.d.ts CHANGED
@@ -132,6 +132,9 @@ export declare function regateTask(store: Store, taskId: string, now: Date, appr
132
132
  kind: "mode";
133
133
  name: string;
134
134
  modeDigest: string;
135
+ } | {
136
+ kind: "release";
137
+ name: string;
135
138
  }): {
136
139
  ok: true;
137
140
  run: number;
package/dist/dispose.js CHANGED
@@ -23,7 +23,7 @@ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
23
23
  import {} from "./proof.js";
24
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
25
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
26
- import { propose, approve, liveReleaseOf } from "./scope.js";
26
+ import { propose, approve, liveReleaseOf, releaseCheckScope } from "./scope.js";
27
27
  import { headWithin } from "./names.js";
28
28
  /** The reasons tick classifies as the attempt itself breaking. */
29
29
  const TICK_FAILURE_REASONS = new Set([
@@ -455,12 +455,16 @@ export function regateTask(store, taskId, now, approval) {
455
455
  if (scope === null)
456
456
  return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
457
457
  const head = last.headRevision;
458
- const actor = approval.kind === "operator" ? approval.name : `mode ${approval.name}`;
458
+ const actor = approval.kind === "operator" ? approval.name : approval.kind === "release" ? approval.name : `mode ${approval.name}`;
459
+ // A release task: signed terms or a release check purpose (pipeline-speed.md, P0), each sealed with the approval.
460
+ if (approval.kind === "release" && ((scope.candidate ?? null) === null || !releaseCheckScope(scope))) {
461
+ return { ok: false, reason: "not-approved", message: `${taskId} isn't a release candidate; only a release reruns under its own approval` };
462
+ }
459
463
  // A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
460
464
  // exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
461
465
  // sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
462
466
  // (2026-10-27: a regate rewrote release-0.9.73's candidate to its own build commit and the release refused it).
463
- if ((scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null) {
467
+ if ((scope.candidate ?? null) !== null || releaseCheckScope(scope)) {
464
468
  const candidate = scope.candidate ?? null;
465
469
  if (candidate === null || scope.approvedDigest !== scope.digest || last.scopeDigest !== scope.digest) {
466
470
  return { ok: false, reason: "not-approved", message: `${taskId}'s approved commit or scope changed since attempt #${last.id}, or its approval no longer stands; approve its scope again instead` };
@@ -481,7 +485,7 @@ export function regateTask(store, taskId, now, approval) {
481
485
  return { ok: false, reason: why[queued.reason] ?? "not-rejected", message: `${taskId} could not be queued again: ${queued.reason}` };
482
486
  }
483
487
  }
484
- store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : "automatically once, because the failure was outside this change"}.`, now);
488
+ store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : approval.kind === "release" ? "because Toolroll confirmed the failure was its own infrastructure, not the code" : "automatically once, because the failure was outside this change"}.`, now);
485
489
  return { ok: true, run: last.id, head: candidate };
486
490
  });
487
491
  }
@@ -494,7 +498,7 @@ export function regateTask(store, taskId, now, approval) {
494
498
  let sealed = false;
495
499
  if (approval.kind === "operator")
496
500
  sealed = approve(store, taskId, approval.name, now, proposed.digest, approval.token).ok;
497
- else
501
+ else if (approval.kind === "mode")
498
502
  sealed = store.sealScopeApproval(taskId, actor, now, {}, { kind: "mode", modeDigest: approval.modeDigest });
499
503
  if (!sealed)
500
504
  return { ok: false, reason: "not-approved", message: `${taskId}'s rerun scope could not be approved` };
package/dist/doctor.d.ts CHANGED
@@ -24,6 +24,11 @@ export type DoctorReport = {
24
24
  version: string;
25
25
  problem: string | null;
26
26
  };
27
+ /** The installed git against Toolroll's floor (git-floor.ts): null when it is new enough (or unreadable). */
28
+ git?: {
29
+ floor: string;
30
+ problem: string | null;
31
+ };
27
32
  };
28
33
  export type DoctorDeps = {
29
34
  platform?: NodeJS.Platform;
@@ -35,6 +40,8 @@ export type DoctorDeps = {
35
40
  procRoot?: string;
36
41
  /** The doctor's own environment: the terminal a `toolroll up` would start from. */
37
42
  env?: NodeJS.ProcessEnv;
43
+ /** The installed git's floor problem (a test's stand-in for git-floor.ts installedGitFloorProblem). */
44
+ gitFloorProblem?: string | null;
38
45
  };
39
46
  /** The names among `pairs` that hold a credential: a provider key, a credential-looking name, or a key-shaped value.
40
47
  * Values are only tested here and never leave. */
package/dist/doctor.js CHANGED
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { daemonStatus, installedServiceDefinition, planDaemon } from "./daemon.js";
22
22
  import { scanForSecrets } from "./evidence.js";
23
23
  import { envelopeJson } from "./envelope.js";
24
+ import { GIT_FLOOR_WORDS, installedGitFloorProblem } from "./git-floor.js";
24
25
  import { run as runCommand } from "./exec.js";
25
26
  import { OWN_KEY_ENV } from "./keys.js";
26
27
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
@@ -123,7 +124,8 @@ function reinstallCommand(args) {
123
124
  const flag = equal < 0 ? arg : arg.slice(0, equal);
124
125
  const value = equal < 0 ? args[index + 1] : arg.slice(equal + 1);
125
126
  const known = freeform.has(flag) || numeric.has(flag) || Object.hasOwn(enums, flag);
126
- if (equal < 0 && (known || CREDENTIAL_FLAG.test(flag)) && value !== undefined && !value.startsWith("-"))
127
+ // A credential's value is the next argument even when it starts with "-" (random base64url can).
128
+ if (equal < 0 && value !== undefined && (CREDENTIAL_FLAG.test(flag) || (known && !value.startsWith("-"))))
127
129
  index += 1;
128
130
  if (flag === "--token") {
129
131
  kept.push("--token-file", "<set-again>");
@@ -386,7 +388,9 @@ export async function auditServiceEnvironment(deps = {}) {
386
388
  /** The report as the owner reads it: one state, then each finding with its steps; detail after. */
387
389
  export function doctorLines(report) {
388
390
  const lines = [];
389
- if (report.ok)
391
+ if (report.git?.problem != null)
392
+ lines.push(`Git: ${report.git.problem}`, "");
393
+ if (report.findings.length === 0 && report.unreadable.length === 0)
390
394
  lines.push("No secrets found in Toolroll's service. A build can't read a key from it.");
391
395
  else if (report.findings.length > 0)
392
396
  lines.push(`Toolroll's service holds ${report.findings.length === 1 ? "a secret" : "secrets"} a build could read. Nothing was changed; follow these steps.`);
@@ -422,6 +426,10 @@ export async function runDoctorCommand(argv, write, deps = {}) {
422
426
  }
423
427
  const report = await auditServiceEnvironment(deps);
424
428
  report.sandbox = { engine: SANDBOX_RUNTIME.name, version: SANDBOX_RUNTIME.version, problem: sandboxRuntimeProblem() };
429
+ // A git older than the floor: named here too, and the console and worker won't start on it.
430
+ report.git = { floor: GIT_FLOOR_WORDS, problem: deps.gitFloorProblem === undefined ? installedGitFloorProblem() : deps.gitFloorProblem };
431
+ if (report.git.problem !== null)
432
+ report.ok = false;
425
433
  if (json)
426
434
  write(envelopeJson({ command: "doctor", ...report }));
427
435
  else
package/dist/envelope.js CHANGED
@@ -74,7 +74,8 @@ export const DOCUMENTED_REASONS = [
74
74
  "drift", // the branch, pull request or result moved from the gated commit
75
75
  "check-failed", // the release check failed; it stays visible and nothing reruns it
76
76
  "ci-failed", // a required pull-request check failed or is missing — nothing merged
77
- "base-moved", // main gained a commit the release branch lacks — nothing merged
77
+ "git-too-old", // the installed git is older than Toolroll's floor (git-floor.ts) — serve, watch and up don't start
78
+ "base-moved", // the release branch lacks main's head — nothing filed (gate) or merged
78
79
  "tree-mismatch", // main's merge commit has a different tree than the gated commit — nothing tagged
79
80
  "conflict", // a task or tag exists for a different commit
80
81
  "mixed-runtime", // the console, worker and CLI names don't run one build
@@ -73,6 +73,15 @@ export declare function isBinaryAssetPath(path: string): boolean;
73
73
  export declare const SHORTENED_MARKER = "\n\u2026 output shortened; ending follows \u2026\n";
74
74
  export declare function boundStreamHeadTail(value: string, cap: number, headShare?: number): string;
75
75
  export declare const EVIDENCE_CAPS: Record<Artifact["kind"], number>;
76
+ /**
77
+ * The bound a writer may seal a kind at with storeEvidence's `cap`, above its default: a release check's sealed report and
78
+ * inventory (check-report.ts CHECK_ARTIFACT_CAP, 256 KiB). Readers accept exactly what writers may seal: 0.9.77 sealed
79
+ * reports up to 256 KiB but read structured output back only to 64 KiB, so every full report over that (~85 KiB for a
80
+ * 585-file suite) read as "no longer verifies" and the release's check as unavailable.
81
+ */
82
+ export declare const SEALED_EVIDENCE_CAPS: Partial<Record<Artifact["kind"], number>>;
83
+ /** The most a stored artifact of this kind may hold: its default cap, or the larger bound a sealed payload may use. */
84
+ export declare function evidenceReadCap(kind: Artifact["kind"]): number;
76
85
  export declare function evidenceRoot(home: string): string;
77
86
  /**
78
87
  * Unpredictable per attempt, so nothing on disk before this build can already
package/dist/evidence.js CHANGED
@@ -23,6 +23,7 @@ import { parseReport, REPORT_LIMITS } from "./scout-report.js";
23
23
  import { parseProof, PROOF_LIMITS } from "./proof.js";
24
24
  import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
25
25
  import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
26
+ import { noLazyFetch } from "./teardown-git.js";
26
27
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
27
28
  export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
28
29
  /** The terminal handoff: how every non-parking attempt says how it ended. */
@@ -115,6 +116,17 @@ export const EVIDENCE_CAPS = {
115
116
  // to hold the JSON-escaped form of that bounded content plus provenance.
116
117
  "review-context": 200 * 1024 * 1024,
117
118
  };
119
+ /**
120
+ * The bound a writer may seal a kind at with storeEvidence's `cap`, above its default: a release check's sealed report and
121
+ * inventory (check-report.ts CHECK_ARTIFACT_CAP, 256 KiB). Readers accept exactly what writers may seal: 0.9.77 sealed
122
+ * reports up to 256 KiB but read structured output back only to 64 KiB, so every full report over that (~85 KiB for a
123
+ * 585-file suite) read as "no longer verifies" and the release's check as unavailable.
124
+ */
125
+ export const SEALED_EVIDENCE_CAPS = { "structured-output": 256 * 1024 };
126
+ /** The most a stored artifact of this kind may hold: its default cap, or the larger bound a sealed payload may use. */
127
+ export function evidenceReadCap(kind) {
128
+ return Math.max(EVIDENCE_CAPS[kind], SEALED_EVIDENCE_CAPS[kind] ?? 0);
129
+ }
118
130
  export function evidenceRoot(home) {
119
131
  // Same continuity rule as the database: evidence recorded under an older
120
132
  // name keeps verifying until a new-name root exists.
@@ -397,8 +409,9 @@ export function imageDimensions(bytes, kind) {
397
409
  * store the caller passed — inside whatever transaction the caller holds.
398
410
  */
399
411
  export function storeEvidence(store, root, runId, kind, name, content, capture, now, options = {}) {
400
- // `cap`: a sealed structured payload with its own documented bound (a release check's report: 256 KiB), never above 1 MiB.
401
- const cap = options.cap === undefined ? EVIDENCE_CAPS[kind] : Math.min(options.cap, 1024 * 1024);
412
+ // `cap`: a sealed structured payload with its own documented bound (a release check's report: 256 KiB), never above what
413
+ // a reader of this kind accepts (evidenceReadCap): a record written larger would never verify again.
414
+ const cap = options.cap === undefined ? EVIDENCE_CAPS[kind] : Math.min(options.cap, evidenceReadCap(kind));
402
415
  const stored = content.subarray(0, cap);
403
416
  // Some callers that deliberately summarize/bound a source before storage
404
417
  // may still report its true original size. This keeps `truncated` honest
@@ -468,7 +481,7 @@ function withArtifactFile(root, artifact, use) {
468
481
  !/[\u0000-\u001f\u007f:]/.test(segment));
469
482
  if (!wellFormed)
470
483
  return { ok: false, problem: "the key is not a normalized relative path" };
471
- const cap = EVIDENCE_CAPS[artifact.kind];
484
+ const cap = evidenceReadCap(artifact.kind);
472
485
  if (artifact.bytesStored > cap) {
473
486
  return { ok: false, problem: "the record claims more bytes than its kind may store" };
474
487
  }
@@ -929,7 +942,9 @@ export async function captureBaseTree(store, git, repoRoot, repo, base, root, ru
929
942
  }
930
943
  }
931
944
  async function captureBaseTreeInner(store, git, repoRoot, repo, base, root, runId, now) {
932
- const args = ["--no-lazy-fetch", "--no-replace-objects", "--no-optional-locks", "ls-tree", "-r", "-l", "-z", base];
945
+ const args = [...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks", "ls-tree", "-r", "-l", "-z", base];
946
+ // `git` is the builder's hardened checkout git (a frozen view): on a git without --no-lazy-fetch it refuses a partial
947
+ // clone from the same config it runs with (teardown-git.ts lazyFetchSetting).
933
948
  const listed = await git("git", args, { cwd: repoRoot, envAllowlist: ["PATH", "HOME"] });
934
949
  const command = `git ls-tree -r -l -z ${base} (exit ${listed.code})`;
935
950
  const refuse = () => {
package/dist/exec.d.ts CHANGED
@@ -28,6 +28,13 @@ export type ExecResult = {
28
28
  * returned — a transport exit alone never proves that.
29
29
  */
30
30
  containment?: ContainmentOutcome;
31
+ /** The signal that ended the process, when one did (its `code` then reads 1, as it always has): kept apart so a
32
+ * killed command is never mistaken for one that exited 1 on its own (release check 3261). Only the buffered
33
+ * process-group transport says. */
34
+ signal?: NodeJS.Signals | null;
35
+ /** This process (the plane) ended it with its hard stop (terminateLiveProviders: a worker shutting down), not
36
+ * anything the command or another process did. */
37
+ stoppedByPlane?: boolean;
31
38
  };
32
39
  export type ContainmentOutcome = {
33
40
  refused: string;
@@ -245,6 +252,8 @@ export type SpawnAttempt = {
245
252
  */
246
253
  export declare function retryTransientSpawn(attempt: () => Promise<SpawnAttempt>, delays?: readonly number[], sleep?: (ms: number) => Promise<void>): Promise<ExecResult>;
247
254
  export declare function run(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
255
+ /** How long a timed-out plain command has to exit after SIGTERM before it is killed outright. */
256
+ export declare const TERM_GRACE_MS = 2000;
248
257
  /**
249
258
  * Provider replies are nested inside their JSONL envelope. A 64 KiB reply
250
259
  * therefore needs appreciably more than 64 KiB on the wire once quotes,
package/dist/exec.js CHANGED
@@ -21,6 +21,8 @@ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
21
21
  export const CONTAINMENT_REFUSED_CODE = 126;
22
22
  /** Live provider children, for the deterministic stop. Registered only when `processGroup` was set. */
23
23
  const liveProviders = new Set();
24
+ /** Children this process's own hard stop ended: their results say so (`stoppedByPlane`). */
25
+ const hardStopped = new WeakSet();
24
26
  /** Live children by owner tag (v52): the exact-attempt stop's handle. A
25
27
  * child is registered here beside liveProviders when its options named an
26
28
  * owner, and dropped the moment it closes. */
@@ -370,6 +372,7 @@ function killGroup(child) {
370
372
  export function terminateLiveProviders() {
371
373
  let terminated = 0;
372
374
  for (const child of liveProviders) {
375
+ hardStopped.add(child);
373
376
  killGroup(child);
374
377
  terminated += 1;
375
378
  }
@@ -581,7 +584,10 @@ export function run(file, args, options = {}) {
581
584
  return Promise.resolve({ code: CONTAINMENT_REFUSED_CODE, stdout: "", stderr: error.message, timedOut: false, notFound: false, containment: { refused: error.message } });
582
585
  }
583
586
  return retryTransientSpawn(() => new Promise(resolve => {
584
- execFile(wrapped.file, wrapped.args, {
587
+ // execFile's timeout sends SIGTERM and nothing more: a process that survives it (one that ignores or blocks
588
+ // it) never exits, and its caller would wait forever. SIGKILL follows a short grace later.
589
+ let hardKill;
590
+ const child = execFile(wrapped.file, wrapped.args, {
585
591
  cwd,
586
592
  timeout: timeoutMs,
587
593
  maxBuffer,
@@ -590,6 +596,8 @@ export function run(file, args, options = {}) {
590
596
  windowsHide: true,
591
597
  ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env, (options.fence?.length ?? 0) > 0)),
592
598
  }, (error, stdout, stderr) => {
599
+ if (hardKill !== undefined)
600
+ clearTimeout(hardKill);
593
601
  if (error === null) {
594
602
  resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
595
603
  return;
@@ -599,8 +607,17 @@ export function run(file, args, options = {}) {
599
607
  transient: isTransientSpawnFailure(error),
600
608
  });
601
609
  });
610
+ if (timeoutMs > 0) {
611
+ hardKill = setTimeout(() => { try {
612
+ child.kill("SIGKILL");
613
+ }
614
+ catch { /* gone */ } }, timeoutMs + TERM_GRACE_MS);
615
+ hardKill.unref?.();
616
+ }
602
617
  }));
603
618
  }
619
+ /** How long a timed-out plain command has to exit after SIGTERM before it is killed outright. */
620
+ export const TERM_GRACE_MS = 2_000;
604
621
  /**
605
622
  * The buffered transport for process-group providers (claude): spawn
606
623
  * detached so the harness and everything it launches share one killable
@@ -677,6 +694,7 @@ function runBufferedGroup(file, args, bag) {
677
694
  });
678
695
  let settled = false;
679
696
  let transient = false;
697
+ let endSignal = null;
680
698
  const finish = (code) => {
681
699
  if (settled)
682
700
  return;
@@ -697,6 +715,8 @@ function runBufferedGroup(file, args, bag) {
697
715
  timedOut: timedOut && !overflowed,
698
716
  notFound,
699
717
  ...containmentOf(container, empty, attachFailure),
718
+ ...(endSignal === null ? {} : { signal: endSignal }),
719
+ ...(hardStopped.has(child) ? { stoppedByPlane: true } : {}),
700
720
  },
701
721
  transient,
702
722
  });
@@ -709,7 +729,7 @@ function runBufferedGroup(file, args, bag) {
709
729
  stderr = String(error);
710
730
  finish(null);
711
731
  });
712
- child.on("close", code => finish(code));
732
+ child.on("close", (code, signal) => { endSignal = code === null ? signal ?? null : null; finish(code); });
713
733
  if (bag.stdin !== undefined && child.stdin !== null) {
714
734
  child.stdin.on("error", () => {
715
735
  inputFailed = true;
@@ -0,0 +1,16 @@
1
+ export declare const GIT_FLOOR: {
2
+ readonly major: 2;
3
+ readonly minor: 41;
4
+ };
5
+ export declare const GIT_FLOOR_WORDS: string;
6
+ /** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
7
+ export declare function parseGitVersion(text: string): {
8
+ major: number;
9
+ minor: number;
10
+ words: string;
11
+ } | null;
12
+ /** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
13
+ export declare function gitFloorWords(versionText: string): string | null;
14
+ /** The installed git's floor problem, asked once per process with no system or global config; null when git is new
15
+ * enough, or when no version could be read (a missing git is reported where it is used). */
16
+ export declare function installedGitFloorProblem(): string | null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The oldest git Toolroll runs on (README, Requirements): the global `git --attr-source` option every hardened read
3
+ * takes arrived in git 2.41 (others in use are older: `worktree list -z` 2.36, GIT_CONFIG_COUNT and `--path-format`
4
+ * 2.31). On git older than 2.45, which lacks `--no-lazy-fetch`, partial clones are refused for protected reads
5
+ * (teardown-git.ts lazyFetchSupported), every protected read runs through a frozen view and refuses every transport,
6
+ * and nothing leans on GIT_NO_LAZY_FETCH, which 2.41.0 ignores. The CI git lanes test this floor's oldest release
7
+ * exactly, 2.41.0, and 2.47 (.github/workflows/ci.yml, scripts/ci-git.sh).
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ export const GIT_FLOOR = { major: 2, minor: 41 };
11
+ export const GIT_FLOOR_WORDS = `${GIT_FLOOR.major}.${GIT_FLOOR.minor}`;
12
+ /** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
13
+ export function parseGitVersion(text) {
14
+ const found = /^git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(text.trim());
15
+ return found === null ? null : { major: Number(found[1]), minor: Number(found[2]), words: `${found[1]}.${found[2]}${found[3] === undefined ? "" : `.${found[3]}`}` };
16
+ }
17
+ /** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
18
+ export function gitFloorWords(versionText) {
19
+ const version = parseGitVersion(versionText);
20
+ if (version === null)
21
+ return null;
22
+ if (version.major > GIT_FLOOR.major || (version.major === GIT_FLOOR.major && version.minor >= GIT_FLOOR.minor))
23
+ return null;
24
+ return `Toolroll needs git ${GIT_FLOOR_WORDS} or newer, and this machine's git is ${version.words}. Install a newer git (and put it first on PATH), then start Toolroll again.`;
25
+ }
26
+ let probed;
27
+ /** The installed git's floor problem, asked once per process with no system or global config; null when git is new
28
+ * enough, or when no version could be read (a missing git is reported where it is used). */
29
+ export function installedGitFloorProblem() {
30
+ if (probed === undefined) {
31
+ const answer = spawnSync("git", ["--version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"],
32
+ env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
33
+ probed = answer.status === 0 ? gitFloorWords(answer.stdout ?? "") : null;
34
+ }
35
+ return probed;
36
+ }
package/dist/grant.js CHANGED
@@ -30,6 +30,7 @@
30
30
  * the same reason an unknown lease is: silence is not consent.
31
31
  */
32
32
  import { run } from "./exec.js";
33
+ import { lazyFetchSafeGit, noLazyFetch } from "./teardown-git.js";
33
34
  export const MUTATION_CLASSES = [
34
35
  "create",
35
36
  "transition",
@@ -175,12 +176,15 @@ async function observedByGit(repo, paths, runner) {
175
176
  const local = paths.filter(path => !isRemoteRef(path));
176
177
  if (local.length === 0)
177
178
  return false;
179
+ // On a git without --no-lazy-fetch, through a frozen read view (teardown-git.ts lazyFetchSafeGit): a partial clone is
180
+ // refused there, and a refusal reads as "not ignored", so observed.
181
+ const git = lazyFetchSafeGit(runner);
178
182
  for (const path of local) {
179
183
  const asked = [path, `${normalise(path)}/`];
180
184
  const ignored = await Promise.all(asked.map(async (candidate) => {
181
185
  // check-ignore reads the index, so a planted fsmonitor would run: off (and hooks, lazy fetch) on the command line,
182
186
  // which wins over any config the repository's builds can write.
183
- const result = await runner("git", ["--no-optional-locks", "--no-lazy-fetch", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
187
+ const result = await git("git", ["--no-optional-locks", ...noLazyFetch(), "-c", "protocol.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
184
188
  cwd: repo,
185
189
  });
186
190
  return result.code === 0;