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
@@ -45,6 +45,7 @@ import { createHash } from "node:crypto";
45
45
  import { hasForbiddenControls } from "./decision.js";
46
46
  import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
47
47
  import { parseProof } from "./proof.js";
48
+ import { noLazyFetch } from "./teardown-git.js";
48
49
  /** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
49
50
  * redacted) text; the aggregate is the sum over every stored item. */
50
51
  export const LEGACY_REVIEW_CONTEXT_LIMITS = {
@@ -468,11 +469,12 @@ export function citesSuppliedProvenance(note, supplied) {
468
469
  return true;
469
470
  return false;
470
471
  }
471
- const GIT_READ = ["--literal-pathspecs", "--no-lazy-fetch", "--no-replace-objects", "--no-optional-locks"];
472
+ /** Read flags: --no-lazy-fetch where git has it (the hardened runner refuses a partial clone where it doesn't). */
473
+ const gitRead = () => ["--literal-pathspecs", ...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks"];
472
474
  async function treeEntries(git, cwd, revision, paths) {
473
475
  if (paths.length === 0)
474
476
  return new Map();
475
- const listed = await git("git", [...GIT_READ, "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
477
+ const listed = await git("git", [...gitRead(), "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
476
478
  if (listed.code !== 0)
477
479
  return null;
478
480
  const entries = new Map();
@@ -570,7 +572,7 @@ async function ancestorPatch(store, git, args, inventory, path) {
570
572
  if (!base || !head || !SHA1.test(base) || !SHA1.test(head))
571
573
  return fail(`run #${runId} has no exact patch endpoints`);
572
574
  for (const from of new Set([base, previousHead ?? base])) {
573
- const ancestor = await git("git", [...GIT_READ, "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
575
+ const ancestor = await git("git", [...gitRead(), "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
574
576
  if (ancestor.code !== 0)
575
577
  return fail(`run #${runId}'s patch ancestry could not be verified`);
576
578
  }
@@ -780,7 +782,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
780
782
  const sourceCriteria = new Map((sourceScopeVerified ? sourceScope.acceptance : []).map(one => [one.id, one]));
781
783
  // 3. Ancestry: is the source head an ancestor of this head?
782
784
  if (sourceHead !== null) {
783
- const check = await git("git", [...GIT_READ, "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
785
+ const check = await git("git", [...gitRead(), "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
784
786
  if (check.code === 0)
785
787
  inventory.ancestry = { verified: true, detail: `${sourceHead} is an ancestor of ${args.head}` };
786
788
  else if (check.code === 1) {
@@ -832,7 +834,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
832
834
  if (generalPaths.length === 0 && sourceScopeVerified) {
833
835
  const paths = sourceScope.touches.filter(path => safeContextPath(path.replace(/\/$/, "")) && !/[?*\[\]]/.test(path));
834
836
  if (paths.length > 0) {
835
- const listed = await git("git", [...GIT_READ, "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
837
+ const listed = await git("git", [...gitRead(), "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
836
838
  if (listed.code === 0)
837
839
  generalPaths.push(...[...new Set(listed.stdout.split("\0").filter(safeContextPath))].sort());
838
840
  else
@@ -949,7 +951,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
949
951
  gap("aggregate-limit", `${entry.size} bytes would cross the ${REVIEW_CONTEXT_LIMITS.aggregateBytes}-byte aggregate limit`);
950
952
  continue;
951
953
  }
952
- const read = await git("git", [...GIT_READ, "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
954
+ const read = await git("git", [...gitRead(), "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
953
955
  if (read.code !== 0) {
954
956
  gap("capture-failed", `blob ${entry.object} could not be read (git exit ${read.code})`);
955
957
  continue;
@@ -0,0 +1,67 @@
1
+ import type { Runner } from "./runner.js";
2
+ export type RunnerProcessRole = "up" | "console" | "worker";
3
+ export type RunnerProcess = {
4
+ pid: number;
5
+ role: RunnerProcessRole;
6
+ started: string;
7
+ };
8
+ export type RunnerProcessRecord = {
9
+ v: 1;
10
+ runner: string;
11
+ host: string;
12
+ registeredAt: string;
13
+ /** A digest of the plane's credential hash for this registration: it names the incarnation, it proves nothing. */
14
+ credential: string;
15
+ processes: RunnerProcess[];
16
+ /** Authenticated with the plane's stored credential hash; legacy/forged records prove nothing. */
17
+ signature?: string;
18
+ };
19
+ export declare const RUNNER_PROCESS_DIR = "runner-processes";
20
+ /** The namespace is created before builds launch and denied by name, including in a shared state folder. */
21
+ export declare function ensureRunnerProcessFolder(folder: string): void;
22
+ export declare function sealRunnerProcessRecord(record: RunnerProcessRecord, credentialHash: string): RunnerProcessRecord;
23
+ /** Where a runner's record lives: beside the database, under a name derived from the runner's (never the name itself). */
24
+ export declare function runnerProcessFile(databaseFile: string, runner: string): string;
25
+ /** The incarnation's name in a record: a digest of the stored credential hash, so the record never holds that hash. */
26
+ export declare function credentialDigest(credentialHash: string): string;
27
+ /** `ps -o lstart=` (LC_ALL=C, TZ=UTC) as an ISO second, or null when it isn't exactly that shape. */
28
+ export declare function parseProcessStart(raw: string): string | null;
29
+ /** When a process started (whole seconds), or null when there is no such process or it can't be read. */
30
+ export declare function processStart(pid: number): string | null;
31
+ /** The probes a proof uses; real by default, supplied by tests. */
32
+ export type ProcessProbe = {
33
+ exists: (pid: number) => "absent" | "present" | "unknown";
34
+ start: (pid: number) => string | null;
35
+ };
36
+ export declare const realProcessProbe: ProcessProbe;
37
+ /** Proved gone: no process has this id, or the one that has it started at another time (the id was reused). */
38
+ export declare function processGone(entry: RunnerProcess, probe?: ProcessProbe): boolean;
39
+ /** A record read strictly: a regular private file of bounded size, every field its exact type, or null. */
40
+ export declare function readRunnerProcessRecord(file: string): RunnerProcessRecord | null;
41
+ /** Replace the record atomically: a private temp file in a private folder, renamed into place. */
42
+ export declare function writeRunnerProcessRecord(file: string, record: RunnerProcessRecord): void;
43
+ /**
44
+ * Whether the plane's current holder of a runner name is proved gone: the record is for exactly that registration,
45
+ * on this host, and every process it names has exited (by identity). `detail` says which processes, for the ledger.
46
+ */
47
+ export declare function holderProvedGone(record: RunnerProcessRecord | null, holder: {
48
+ runner: Runner;
49
+ credentialHash: string;
50
+ }, host: string, probe?: ProcessProbe): {
51
+ gone: true;
52
+ detail: string;
53
+ } | {
54
+ gone: false;
55
+ why: string;
56
+ };
57
+ /** Re-read after observing exit: an up may append a child between the first read and its own exit. */
58
+ export declare function recordedHolderProvedGone(file: string, holder: {
59
+ runner: Runner;
60
+ credentialHash: string;
61
+ }, host: string, probe?: ProcessProbe): {
62
+ gone: true;
63
+ detail: string;
64
+ } | {
65
+ gone: false;
66
+ why: string;
67
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Which processes hold a runner's credential, kept beside the plane's database so a later `up` can prove the previous
3
+ * holder is gone (docs: the 2026-10-29 deploy incident). `toolroll up` registers its runner, then records itself and
4
+ * every role child it hands the token to, each by process id AND start time, before the child receives the token.
5
+ *
6
+ * A fresh `up` whose runner name is still held (its heartbeat looks fresh, or its watch lease has not lapsed) asks
7
+ * this record whether that holder is gone: the record must be for exactly the registration the plane holds (name,
8
+ * host, registration time and credential), and every process in it must have exited, each proved by identity (no
9
+ * such process, or a process with that id started at a different time). Then the plane retires the stale holder in
10
+ * the same transaction that proved it (runner.ts registerRunnerIfIdle). Anything missing, unreadable or ambiguous
11
+ * proves nothing, and the door keeps refusing until the heartbeat ages out, exactly as before.
12
+ *
13
+ * The record is the plane's own state (the folder that holds the database). It grants nothing by itself: it can
14
+ * only let a name whose every recorded holder has exited be taken over early.
15
+ */
16
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
17
+ import { execFileSync } from "node:child_process";
18
+ import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, fsyncSync, constants } from "node:fs";
19
+ import { dirname, join, resolve } from "node:path";
20
+ export const RUNNER_PROCESS_DIR = "runner-processes";
21
+ /** The namespace is created before builds launch and denied by name, including in a shared state folder. */
22
+ export function ensureRunnerProcessFolder(folder) {
23
+ try {
24
+ mkdirSync(folder, { mode: 0o700 });
25
+ }
26
+ catch (error) {
27
+ if (error.code !== "EEXIST")
28
+ throw error;
29
+ }
30
+ const stat = lstatSync(folder);
31
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0
32
+ || (process.getuid && stat.uid !== process.getuid()) || realpathSync(folder) !== resolve(folder))
33
+ throw Error("Untrusted runner process folder");
34
+ }
35
+ export function sealRunnerProcessRecord(record, credentialHash) {
36
+ const { signature: _signature, ...plain } = record;
37
+ return { ...plain, signature: createHmac("sha256", credentialHash).update(JSON.stringify(plain)).digest("hex") };
38
+ }
39
+ const MAX_RECORD = 64 * 1024;
40
+ const MAX_PROCESSES = 64;
41
+ const ROLES = new Set(["up", "console", "worker"]);
42
+ /** Where a runner's record lives: beside the database, under a name derived from the runner's (never the name itself). */
43
+ export function runnerProcessFile(databaseFile, runner) {
44
+ let database = databaseFile;
45
+ try {
46
+ database = realpathSync(databaseFile);
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "ENOENT")
50
+ throw error;
51
+ }
52
+ return join(realpathSync(dirname(database)), RUNNER_PROCESS_DIR, `${createHash("sha256").update(runner, "utf8").digest("hex").slice(0, 32)}.json`);
53
+ }
54
+ /** The incarnation's name in a record: a digest of the stored credential hash, so the record never holds that hash. */
55
+ export function credentialDigest(credentialHash) {
56
+ return createHash("sha256").update(`runner-process:${credentialHash}`, "utf8").digest("hex").slice(0, 32);
57
+ }
58
+ const LSTART = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/;
59
+ /** `ps -o lstart=` (LC_ALL=C, TZ=UTC) as an ISO second, or null when it isn't exactly that shape. */
60
+ export function parseProcessStart(raw) {
61
+ const match = LSTART.exec(raw.trim().replace(/ +/g, " "));
62
+ if (match === null)
63
+ return null;
64
+ const utc = `${match[1]}, ${match[3].padStart(2, "0")} ${match[2]} ${match[5]} ${match[4]} GMT`;
65
+ const at = Date.parse(utc);
66
+ if (!Number.isFinite(at) || new Date(at).toUTCString() !== utc)
67
+ return null;
68
+ return new Date(at).toISOString();
69
+ }
70
+ /** When a process started (whole seconds), or null when there is no such process or it can't be read. */
71
+ export function processStart(pid) {
72
+ if (!Number.isSafeInteger(pid) || pid <= 1 || process.platform === "win32")
73
+ return null;
74
+ try {
75
+ const raw = execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
76
+ encoding: "utf8", env: { PATH: "/usr/bin:/bin", LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"],
77
+ maxBuffer: 1024, timeout: 2_000, killSignal: "SIGKILL",
78
+ });
79
+ return parseProcessStart(raw);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ export const realProcessProbe = {
86
+ exists: pid => {
87
+ try {
88
+ process.kill(pid, 0);
89
+ return "present";
90
+ }
91
+ catch (error) {
92
+ const code = error.code;
93
+ return code === "ESRCH" ? "absent" : code === "EPERM" ? "present" : "unknown";
94
+ }
95
+ },
96
+ start: processStart,
97
+ };
98
+ /** Proved gone: no process has this id, or the one that has it started at another time (the id was reused). */
99
+ export function processGone(entry, probe = realProcessProbe) {
100
+ const exists = probe.exists(entry.pid);
101
+ if (exists === "absent")
102
+ return true;
103
+ if (exists === "unknown")
104
+ return false;
105
+ const started = probe.start(entry.pid);
106
+ if (started === null)
107
+ return probe.exists(entry.pid) === "absent";
108
+ return started !== entry.started;
109
+ }
110
+ /** A record read strictly: a regular private file of bounded size, every field its exact type, or null. */
111
+ export function readRunnerProcessRecord(file) {
112
+ let fd = null;
113
+ try {
114
+ const folder = lstatSync(dirname(file));
115
+ if (!folder.isDirectory() || folder.isSymbolicLink() || (folder.mode & 0o077) !== 0
116
+ || (process.getuid && folder.uid !== process.getuid()) || realpathSync(dirname(file)) !== resolve(dirname(file)))
117
+ return null;
118
+ const link = lstatSync(file);
119
+ if (!link.isFile() || link.isSymbolicLink() || link.size > MAX_RECORD)
120
+ return null;
121
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
122
+ const stat = fstatSync(fd);
123
+ if (!stat.isFile() || stat.size > MAX_RECORD || stat.ino !== link.ino || stat.dev !== link.dev || (stat.mode & 0o077) !== 0)
124
+ return null;
125
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
126
+ return null;
127
+ const buffer = Buffer.alloc(stat.size);
128
+ let read = 0;
129
+ while (read < stat.size) {
130
+ const n = readSync(fd, buffer, read, stat.size - read, read);
131
+ if (n === 0)
132
+ break;
133
+ read += n;
134
+ }
135
+ return parseRecord(JSON.parse(buffer.subarray(0, read).toString("utf8")));
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ finally {
141
+ if (fd !== null)
142
+ closeSync(fd);
143
+ }
144
+ }
145
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
146
+ function parseRecord(value) {
147
+ if (typeof value !== "object" || value === null || Array.isArray(value))
148
+ return null;
149
+ const v = value;
150
+ const text = (one, max = 512) => typeof one === "string" && one !== "" && one.length <= max && !one.includes("\0");
151
+ if (v["v"] !== 1 || !text(v["runner"]) || !text(v["host"]) || !text(v["registeredAt"]) || !ISO.test(v["registeredAt"]) || !text(v["credential"], 64))
152
+ return null;
153
+ const list = v["processes"];
154
+ if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROCESSES)
155
+ return null;
156
+ const processes = [];
157
+ for (const one of list) {
158
+ if (typeof one !== "object" || one === null)
159
+ return null;
160
+ const p = one;
161
+ if (!Number.isSafeInteger(p["pid"]) || p["pid"] <= 1 || !ROLES.has(p["role"]) || !text(p["started"], 32) || !ISO.test(p["started"]))
162
+ return null;
163
+ processes.push({ pid: p["pid"], role: p["role"], started: p["started"] });
164
+ }
165
+ return { v: 1, runner: v["runner"], host: v["host"], registeredAt: v["registeredAt"], credential: v["credential"], processes,
166
+ ...(typeof v["signature"] === "string" ? { signature: v["signature"] } : {}) };
167
+ }
168
+ /** Replace the record atomically: a private temp file in a private folder, renamed into place. */
169
+ export function writeRunnerProcessRecord(file, record) {
170
+ ensureRunnerProcessFolder(dirname(file));
171
+ const temp = `${file}.${randomUUID()}.tmp`;
172
+ try {
173
+ const fd = openSync(temp, "wx", 0o600);
174
+ try {
175
+ writeFileSync(fd, JSON.stringify(record));
176
+ fsyncSync(fd);
177
+ }
178
+ finally {
179
+ closeSync(fd);
180
+ }
181
+ renameSync(temp, file);
182
+ }
183
+ finally {
184
+ rmSync(temp, { force: true });
185
+ }
186
+ }
187
+ /**
188
+ * Whether the plane's current holder of a runner name is proved gone: the record is for exactly that registration,
189
+ * on this host, and every process it names has exited (by identity). `detail` says which processes, for the ledger.
190
+ */
191
+ export function holderProvedGone(record, holder, host, probe = realProcessProbe) {
192
+ if (record === null)
193
+ return { gone: false, why: "no readable record of its processes" };
194
+ const expected = sealRunnerProcessRecord(record, holder.credentialHash).signature;
195
+ if (typeof record.signature !== "string" || !/^[a-f0-9]{64}$/.test(record.signature)
196
+ || !timingSafeEqual(Buffer.from(record.signature, "hex"), Buffer.from(expected, "hex")))
197
+ return { gone: false, why: "its process record is not authenticated" };
198
+ if (record.runner !== holder.runner.name || record.host !== holder.runner.host || record.host !== host)
199
+ return { gone: false, why: "its record is for another worker or machine" };
200
+ if (record.registeredAt !== holder.runner.registeredAt || record.credential !== credentialDigest(holder.credentialHash))
201
+ return { gone: false, why: "its record is from an earlier start" };
202
+ if (!record.processes.some(one => one.role === "up"))
203
+ return { gone: false, why: "its record names no `up` process" };
204
+ const left = record.processes.filter(one => !processGone(one, probe));
205
+ if (left.length > 0)
206
+ return { gone: false, why: `process ${left.map(one => one.pid).join(", ")} is still running` };
207
+ return { gone: true, detail: `${record.runner}: process ${record.processes.map(one => `${one.pid} (${one.role})`).join(", ")} proved gone` };
208
+ }
209
+ /** Re-read after observing exit: an up may append a child between the first read and its own exit. */
210
+ export function recordedHolderProvedGone(file, holder, host, probe = realProcessProbe) {
211
+ const record = readRunnerProcessRecord(file);
212
+ const proof = holderProvedGone(record, holder, host, probe);
213
+ if (!proof.gone)
214
+ return proof;
215
+ const after = readRunnerProcessRecord(file);
216
+ if (after === null || JSON.stringify(after) !== JSON.stringify(record))
217
+ return { gone: false, why: "its process record changed during the exit proof" };
218
+ return proof;
219
+ }
package/dist/runner.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { type AcpProviderId } from "./acp-agents.js";
23
23
  import type { AcpAttestation } from "./store.js";
24
- import type { Store, Mutation } from "./store.js";
24
+ import { type Store, type Mutation } from "./store.js";
25
25
  import { type ProviderId } from "./provider.js";
26
26
  import type { ReadinessObservation } from "./phase-routing.js";
27
27
  /** A runner's repo binding, in the same canonical form task filing uses —
@@ -59,11 +59,18 @@ export declare const RUNNER_TOKEN_MS: number;
59
59
  /**
60
60
  * How long a runner may go quiet before it is presumed gone.
61
61
  *
62
- * Comfortably more than any sensible heartbeat interval: declaring a busy
63
- * machine dead because one check-in was slow costs more than waiting another
64
- * minute for a machine that really is.
62
+ * Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
63
+ * machine dead, and a machine that really is gone holds its name for a minute,
64
+ * not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
65
+ * long: a draining or stopped holder never blocks the next start
66
+ * (registerRunnerIfIdle).
65
67
  */
66
- export declare const DEFAULT_LIVENESS_MS: number;
68
+ export declare const DEFAULT_LIVENESS_MS = 60000;
69
+ /** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
70
+ export declare const RUNNER_HEARTBEAT_MS = 15000;
71
+ /** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
72
+ * holder's projects are free within a minute. The store clamps every ttl to it. */
73
+ export { MAX_WATCH_LEASE_MS } from "./store.js";
67
74
  export type RegisterOptions = {
68
75
  name: string;
69
76
  host: string;
@@ -140,13 +147,35 @@ export declare function normalizeRunnerName(raw: string): string;
140
147
  * live claim owns), reclaims, and rotates. Two racers serialize: one wins,
141
148
  * the other gets the typed refusal.
142
149
  */
143
- export declare function registerRunnerIfIdle(store: Store, options: RegisterOptions): (Registration & {
150
+ export declare function registerRunnerIfIdle(store: Store, options: RegisterOptions & {
151
+ /**
152
+ * Whether the name's current holder is proved gone (runner-process.ts): every process it handed its credential to
153
+ * has exited, verified by identity. Asked only while the holder still blocks the name, inside this transaction;
154
+ * a proof retires that holder and lapses its watch leases here, so a stale heartbeat never outlives its processes.
155
+ */
156
+ previousGone?: (holder: {
157
+ runner: Runner;
158
+ credentialHash: string;
159
+ }) => {
160
+ gone: true;
161
+ detail: string;
162
+ } | {
163
+ gone: false;
164
+ why: string;
165
+ };
166
+ }): (Registration & {
144
167
  ok: true;
145
168
  recoveredRuns: number;
169
+ signedOff: string | null;
146
170
  }) | {
147
171
  ok: false;
148
172
  reason: "runner-alive" | "watch-live";
149
173
  detail: string;
174
+ } | {
175
+ ok: false;
176
+ reason: "runner-draining";
177
+ detail: string;
178
+ until: string;
150
179
  };
151
180
  /**
152
181
  * The authenticated lease door (arc 2 findings 15/26): credential verified
@@ -280,3 +309,16 @@ export declare function reportProviderReadinessAuthed(store: Store, args: {
280
309
  token: string;
281
310
  observations: readonly Omit<ReadinessObservation, "runner" | "observedAt">[];
282
311
  }, now: Date): AuthResult;
312
+ /** A worker's lifecycle row (service_lifecycle). */
313
+ export declare const workerComponent: (name: string) => string;
314
+ /**
315
+ * The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
316
+ * no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
317
+ */
318
+ export declare function drainRunnerIfCurrent(store: Store, name: string, token: string, now: Date, until: Date): boolean;
319
+ /** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
320
+ * isn't draining; ended once its bound passed or it reported stopped. */
321
+ export declare function drainOf(store: Store, runner: Runner, now: Date): {
322
+ until: string;
323
+ ended: boolean;
324
+ } | null;
package/dist/runner.js CHANGED
@@ -19,12 +19,14 @@
19
19
  * again. Reaping on the second tells you a *machine* is gone, and everything
20
20
  * it was holding — claims and worktrees alike — needs recovering together.
21
21
  */
22
+ import { mintSecret } from "./secret-mint.js";
22
23
  import { isAcpProvider } from "./acp-agents.js";
23
24
  import { verifyLaunch } from "./acp-attest.js";
24
25
  import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
25
26
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
27
  import { resolve } from "node:path";
27
28
  import { canonicalProject } from "./project.js";
29
+ import { lifecycleOf, reportLifecycle } from "./store.js";
28
30
  import { ALL_CREDENTIAL_ENV, PROVIDER_IDS, inspectionOf } from "./provider.js";
29
31
  import { attestationOf, versionInRange } from "./attest.js";
30
32
  /** A runner's repo binding, in the same canonical form task filing uses —
@@ -38,11 +40,18 @@ export const RUNNER_TOKEN_MS = 365 * 86_400_000;
38
40
  /**
39
41
  * How long a runner may go quiet before it is presumed gone.
40
42
  *
41
- * Comfortably more than any sensible heartbeat interval: declaring a busy
42
- * machine dead because one check-in was slow costs more than waiting another
43
- * minute for a machine that really is.
43
+ * Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
44
+ * machine dead, and a machine that really is gone holds its name for a minute,
45
+ * not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
46
+ * long: a draining or stopped holder never blocks the next start
47
+ * (registerRunnerIfIdle).
44
48
  */
45
- export const DEFAULT_LIVENESS_MS = 3 * 60_000;
49
+ export const DEFAULT_LIVENESS_MS = 60_000;
50
+ /** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
51
+ export const RUNNER_HEARTBEAT_MS = 15_000;
52
+ /** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
53
+ * holder's projects are free within a minute. The store clamps every ttl to it. */
54
+ export { MAX_WATCH_LEASE_MS } from "./store.js";
46
55
  export function register(store, options) {
47
56
  const { name, host, capacity = 1, repos = [], agents = [], now, newToken = mintToken, mutation = {}, } = options;
48
57
  const token = newToken();
@@ -77,6 +86,9 @@ export function register(store, options) {
77
86
  // Doing it from the `previous` snapshot above is racy: two registrars can
78
87
  // read the same predecessor and otherwise mint the same generation.
79
88
  const runner = store.saveRunner(proposed, hashToken(token), mutation);
89
+ // v137: this registration is started; its lifecycle row is bound to it by registered_at, so an earlier holder's
90
+ // drain or stop never describes it.
91
+ reportLifecycle(store, workerComponent(name), "started", runner.registeredAt, now);
80
92
  return {
81
93
  runner,
82
94
  token,
@@ -203,9 +215,30 @@ export function normalizeRunnerName(raw) {
203
215
  * the other gets the typed refusal.
204
216
  */
205
217
  export function registerRunnerIfIdle(store, options) {
206
- const { name, now } = options;
218
+ const { name, now, previousGone, ...registration } = options;
207
219
  return store.transact(() => {
208
- const existing = store.getRunner(name);
220
+ let existing = store.getRunner(name);
221
+ // The protection against two live holders stands: only a holder whose every process is proved gone, or whose own
222
+ // drain has ended (its stop bound passed, or it reported stopped), is signed off.
223
+ let signedOff = null;
224
+ const blocked = existing !== null && ((existing.runner.retiredAt === null && isAlive(existing.runner, now)) || store.liveWatchLeaseOf(name, now) !== null);
225
+ const drain = existing === null ? null : drainOf(store, existing.runner, now);
226
+ // A scheduled drain deadline is not proof that its timer ran or its children exited.
227
+ if (blocked && existing !== null && previousGone !== undefined) {
228
+ const proof = previousGone(existing);
229
+ if (proof.gone) {
230
+ store.retireRunner(name, now);
231
+ store.expireWatchLeasesOf(name, now);
232
+ store.recordAction({ at: now.toISOString(), actor: "toolroll up", repo: null, taskId: null, runId: null,
233
+ action: "worker signed off", outcome: "processes proved gone", source: "policy", detail: proof.detail });
234
+ signedOff = proof.detail;
235
+ existing = store.getRunner(name);
236
+ }
237
+ }
238
+ // A live draining holder remains exclusive even if its advertised deadline has passed.
239
+ if (existing !== null && drain !== null && (isAlive(existing.runner, now) || store.liveWatchLeaseOf(name, now) !== null)) {
240
+ return { ok: false, reason: "runner-draining", until: drain.until, detail: `Toolroll is restarting (draining): ${name} is still stopping (scheduled by ${drain.until})` };
241
+ }
209
242
  if (existing !== null && existing.runner.retiredAt === null && isAlive(existing.runner, now)) {
210
243
  return {
211
244
  ok: false,
@@ -226,8 +259,8 @@ export function registerRunnerIfIdle(store, options) {
226
259
  // otherwise hide it from every later recovery.
227
260
  const recoveredRuns = existing === null ? 0 : store.recoverRunnerWork(name, now).runs.length;
228
261
  // A restart keeps the capacity an operator set; only an explicit one changes it.
229
- const registration = register(store, { ...options, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
230
- return { ok: true, ...registration, recoveredRuns };
262
+ const registered = register(store, { ...registration, name, now, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
263
+ return { ok: true, ...registered, recoveredRuns, signedOff };
231
264
  });
232
265
  }
233
266
  /**
@@ -281,6 +314,10 @@ export function retireRunnerIfCurrent(store, name, token, now) {
281
314
  if (!auth.ok)
282
315
  return { ok: false, reason: auth.reason };
283
316
  store.retireRunner(name, now);
317
+ // Stopped is retired: its leases lapse now, so the next start or watch takes its projects over (recovering whatever
318
+ // this incarnation left) without waiting for them to run out.
319
+ store.expireWatchLeasesOf(name, now);
320
+ reportLifecycle(store, workerComponent(name), "stopped", auth.runner.registeredAt, now);
284
321
  return { ok: true };
285
322
  });
286
323
  }
@@ -352,7 +389,7 @@ export function recoverDead(store, now, livenessMs = DEFAULT_LIVENESS_MS) {
352
389
  }
353
390
  /** 32 bytes of randomness, urlsafe, so it survives being pasted anywhere. */
354
391
  function mintToken() {
355
- return randomBytes(32).toString("base64url");
392
+ return mintSecret(32);
356
393
  }
357
394
  export function hashToken(token) {
358
395
  return createHash("sha256").update(token, "utf8").digest("hex");
@@ -434,3 +471,30 @@ export function reportProviderReadinessAuthed(store, args, now) {
434
471
  return auth;
435
472
  });
436
473
  }
474
+ // ---- v137 lifecycle (docs/plans/actors-lessons.md): last in the file, so the reviewed function positions stay ----
475
+ /** A worker's lifecycle row (service_lifecycle). */
476
+ export const workerComponent = (name) => `worker:${name}`;
477
+ /**
478
+ * The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
479
+ * no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
480
+ */
481
+ export function drainRunnerIfCurrent(store, name, token, now, until) {
482
+ return store.transact(() => {
483
+ const auth = authenticate(store, name, token);
484
+ if (!auth.ok)
485
+ return false;
486
+ return reportLifecycle(store, workerComponent(name), "draining", auth.runner.registeredAt, now, { drainUntil: until });
487
+ });
488
+ }
489
+ /** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
490
+ * isn't draining; ended once its bound passed or it reported stopped. */
491
+ export function drainOf(store, runner, now) {
492
+ const row = lifecycleOf(store, workerComponent(runner.name));
493
+ if (row === null || row.holder !== runner.registeredAt)
494
+ return null;
495
+ if (row.state === "stopped")
496
+ return { until: row.changedAt, ended: true };
497
+ if (row.state !== "draining" || row.drainUntil === null)
498
+ return null;
499
+ return { until: row.drainUntil, ended: Date.parse(row.drainUntil) <= now.getTime() };
500
+ }
@@ -63,6 +63,14 @@ export declare const SEATBELT_NO_OTHER_TERMINAL: string;
63
63
  * any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
64
64
  */
65
65
  export declare const SEATBELT_NO_KEYCHAIN: string;
66
+ /**
67
+ * First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
68
+ * On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
69
+ * (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
70
+ * 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
71
+ * same; only the report goes.
72
+ */
73
+ export declare const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
66
74
  /**
67
75
  * Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
68
76
  * port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
@@ -122,8 +122,16 @@ export const SEATBELT_NO_OTHER_TERMINAL = [
122
122
  */
123
123
  export const SEATBELT_NO_KEYCHAIN = [
124
124
  "; Toolroll: no keychain",
125
- "(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
125
+ "(deny mach-lookup (with no-report) (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
126
126
  ].join("\n");
127
+ /**
128
+ * First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
129
+ * On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
130
+ * (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
131
+ * 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
132
+ * same; only the report goes.
133
+ */
134
+ export const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
127
135
  /**
128
136
  * Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
129
137
  * port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
@@ -226,11 +234,15 @@ export function withSeatbeltRules(command, rules, expected) {
226
234
  const profile = words[exec + 2];
227
235
  if (!profile.startsWith("(version 1)\n(deny default"))
228
236
  throw shape();
237
+ // Where the default deny's line ends (the profile's end when it is the last line).
238
+ const lineEnd = profile.indexOf("\n", "(version 1)\n".length);
239
+ const opening = lineEnd < 0 ? profile.length : lineEnd;
229
240
  if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
230
241
  throw shape();
231
242
  if (expected !== undefined && words[exec + 5] !== expected)
232
243
  throw shape();
233
- return srtQuote([...words.slice(0, exec + 2), `${profile}\n${rules}\n`, ...words.slice(exec + 3)]);
244
+ const quiet = `${profile.slice(0, opening)}\n${SEATBELT_QUIET_MACH_DENY}${profile.slice(opening)}`;
245
+ return srtQuote([...words.slice(0, exec + 2), `${quiet}\n${rules}\n`, ...words.slice(exec + 3)]);
234
246
  }
235
247
  /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
236
248
  export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {