toolroll 0.9.76 → 0.9.77

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 (102) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +28 -8
  5. package/dist/assignment.js +3 -0
  6. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/builder.d.ts +14 -0
  10. package/dist/builder.js +140 -41
  11. package/dist/chat-decide.js +4 -0
  12. package/dist/check-failure.d.ts +92 -0
  13. package/dist/check-failure.js +267 -0
  14. package/dist/check-progress.d.ts +28 -1
  15. package/dist/check-progress.js +105 -6
  16. package/dist/check-report.d.ts +52 -6
  17. package/dist/check-report.js +193 -8
  18. package/dist/checkout-cleanup.js +2 -2
  19. package/dist/cli.js +8 -5
  20. package/dist/coding-handoff.d.ts +1 -0
  21. package/dist/coding-handoff.js +20 -11
  22. package/dist/commit-extract.d.ts +6 -0
  23. package/dist/commit-extract.js +63 -8
  24. package/dist/console-chrome.d.ts +41 -0
  25. package/dist/console-chrome.js +166 -0
  26. package/dist/contracts/release-check.d.ts +12 -0
  27. package/dist/contracts/release-check.js +17 -0
  28. package/dist/contracts/verification-receipt.d.ts +20 -2
  29. package/dist/contracts/verification-receipt.js +15 -3
  30. package/dist/contracts/verification-view.d.ts +8 -0
  31. package/dist/controller-supervisor.d.ts +8 -0
  32. package/dist/controller-supervisor.js +33 -1
  33. package/dist/demo.js +3 -2
  34. package/dist/deploy-gate.js +5 -0
  35. package/dist/dispose.d.ts +3 -0
  36. package/dist/dispose.js +6 -3
  37. package/dist/doctor.d.ts +7 -0
  38. package/dist/doctor.js +10 -2
  39. package/dist/envelope.js +2 -1
  40. package/dist/evidence.js +4 -1
  41. package/dist/exec.d.ts +7 -0
  42. package/dist/exec.js +7 -1
  43. package/dist/git-floor.d.ts +16 -0
  44. package/dist/git-floor.js +36 -0
  45. package/dist/grant.js +5 -1
  46. package/dist/guides.js +4 -3
  47. package/dist/operate.d.ts +13 -0
  48. package/dist/operate.js +773 -319
  49. package/dist/prepared-evidence.d.ts +2 -1
  50. package/dist/prepared-evidence.js +8 -3
  51. package/dist/project-knowledge.js +6 -2
  52. package/dist/project-learning.js +2 -2
  53. package/dist/proof.js +6 -0
  54. package/dist/read-executor-worker.js +8 -4
  55. package/dist/read-executor.d.ts +18 -1
  56. package/dist/read-executor.js +8 -0
  57. package/dist/release-adapters.d.ts +48 -1
  58. package/dist/release-adapters.js +343 -10
  59. package/dist/release-authority.js +6 -2
  60. package/dist/release-auto.d.ts +38 -7
  61. package/dist/release-auto.js +131 -15
  62. package/dist/release-card.d.ts +15 -3
  63. package/dist/release-card.js +25 -9
  64. package/dist/release-ci-hold.d.ts +41 -0
  65. package/dist/release-ci-hold.js +53 -0
  66. package/dist/release-cli.js +21 -12
  67. package/dist/release-coverage.js +5 -2
  68. package/dist/release-recheck.d.ts +1 -1
  69. package/dist/release-recheck.js +6 -1
  70. package/dist/release.d.ts +108 -11
  71. package/dist/release.js +294 -39
  72. package/dist/review-context.js +8 -6
  73. package/dist/runner.js +2 -1
  74. package/dist/sandbox-runtime.d.ts +8 -0
  75. package/dist/sandbox-runtime.js +14 -2
  76. package/dist/scope.js +7 -1
  77. package/dist/secret-mint.d.ts +1 -0
  78. package/dist/secret-mint.js +14 -0
  79. package/dist/serve.js +101 -51
  80. package/dist/server/chat.js +5 -5
  81. package/dist/server/render-tasks.d.ts +2 -0
  82. package/dist/server/render-tasks.js +2 -1
  83. package/dist/server/request-context.d.ts +6 -0
  84. package/dist/server/runtime.d.ts +2 -0
  85. package/dist/server/task-views.js +7 -2
  86. package/dist/setup-guide.d.ts +2 -2
  87. package/dist/storage.d.ts +5 -0
  88. package/dist/storage.js +28 -1
  89. package/dist/store.d.ts +10 -0
  90. package/dist/store.js +25 -1
  91. package/dist/task-base.d.ts +4 -0
  92. package/dist/task-base.js +22 -2
  93. package/dist/teardown-git.d.ts +52 -3
  94. package/dist/teardown-git.js +143 -31
  95. package/dist/test-infra.js +1 -1
  96. package/dist/up-processes.d.ts +137 -0
  97. package/dist/up-processes.js +165 -0
  98. package/dist/up-role-child.d.ts +1 -0
  99. package/dist/up-role-child.js +77 -0
  100. package/dist/verification-evidence.d.ts +1 -0
  101. package/dist/verification-evidence.js +13 -1
  102. package/package.json +2 -1
package/dist/operate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mintSecret } from "./secret-mint.js";
1
2
  import { ACP_AGENTS, ACP_PROVIDER_IDS, isAcpProvider, isKeyedProvider } from "./acp-agents.js";
2
3
  import { inspectAcpAgent } from "./acp-inspect.js";
3
4
  import { runAcpChecks } from "./acp-check.js";
@@ -99,7 +100,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
99
100
  import { homedir, hostname } from "node:os";
100
101
  import { basename, dirname, join, resolve } from "node:path";
101
102
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
102
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
103
+ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, } from "./store.js";
103
104
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
104
105
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
105
106
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -122,6 +123,9 @@ import { admissionRecorded, admitProject, commandLineActor } from "./project-adm
122
123
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
123
124
  import { createServer as createNetServer } from "node:net";
124
125
  import { spawn as spawnChild } from "node:child_process";
126
+ import { fileURLToPath } from "node:url";
127
+ import { superviseController } from "./controller-supervisor.js";
128
+ import { buildIdentity, memoryChannel, RoleGenerations, roleMessage } from "./up-processes.js";
125
129
  // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
126
130
  import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
127
131
  import { auditCommand } from "./audit-cli.js";
@@ -224,7 +228,8 @@ import { scout as scoutTask } from "./scout.js";
224
228
  import { profileDigestOf } from "./scope.js";
225
229
  import { PROVIDER_KEY_ENV, clearProviderKey, keyStatus, readAuthMode, readAuthModeStrict, readProviderKey, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
226
230
  import { run, terminateLiveProviders, run as execRun } from "./exec.js";
227
- import { hardenedGit } from "./teardown-git.js";
231
+ import { hardenedGit, lazyFetchSafeGit, noLazyFetch, PARTIAL_CLONE_REFUSAL } from "./teardown-git.js";
232
+ import { installedGitFloorProblem } from "./git-floor.js";
228
233
  import { noticeGitIncidents } from "./git-incident.js";
229
234
  import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
230
235
  import { readPulls } from "./pulls.js";
@@ -435,6 +440,8 @@ External trackers — build what a tracker nominates, under local approvals
435
440
  app; they start without a restart.
436
441
  --repo still adds an exact path;
437
442
  --no-open skips the browser;
443
+ the app and the builder run as two
444
+ processes (--single-process: one);
438
445
  --host 0.0.0.0 --allow-host name:port
439
446
  reaches a phone over a tailnet;
440
447
  --runner names the worker;
@@ -656,6 +663,8 @@ export const KEYS_ACTIONS = ["status", "set", "clear", "verify", "auth"];
656
663
  * tests): every value-taking flag any verb reads, and every boolean.
657
664
  * A --flag in neither set is a typo, refused by name.
658
665
  */
666
+ /** Flags whose value is a secret someone types or pastes: taken as the next argument even when it starts with "--". */
667
+ export const SECRET_VALUE_FLAGS = new Set(["token", "password"]);
659
668
  export const OPERATE_VALUE_FLAGS = new Set([
660
669
  "key", "db", "runner", "ttl", "state", "on", "reason", "until", "id", "backend",
661
670
  "allow", "selector", "paths", "credentials", "repo", "token", "capacity",
@@ -706,7 +715,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
706
715
  "no-carry",
707
716
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
708
717
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
709
- "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
718
+ "no-open", "single-process", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
710
719
  "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
711
720
  "html", "csv", "alerts-only",
712
721
  // project network: what its builds may reach (--open | --allowlist).
@@ -772,11 +781,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
772
781
  const value = equals === -1 ? argv[++index] : argument.slice(equals + 1);
773
782
  // A following --flag is not a value — consuming it would swallow a real
774
783
  // flag and leave this one holding a name-shaped lie.
775
- // Minted 32-byte base64url credentials may begin with two hyphens.
776
- // Accept that exact token shape without swallowing a following flag.
777
- // Explicit --name=value also carries arbitrary literal leading hyphens.
778
- const mintedToken = name === "token" && value !== undefined && /^[A-Za-z0-9_-]{43}$/.test(value);
779
- if (value === undefined || (equals === -1 && value.startsWith("--") && !mintedToken))
784
+ // A secret (a password, a token) is random base64url and may begin with two hyphens: it is the next argument
785
+ // unless that argument is exactly a flag this parser knows (a real flag is never swallowed, finding 22). Other flags
786
+ // never take a following --flag. Explicit --name=value carries arbitrary literal leading hyphens for every flag.
787
+ const namesFlag = (one) => { const flag = one.slice(2).split("=")[0]; return booleans.has(flag) || wantsValue.has(flag); };
788
+ if (value === undefined || (equals === -1 && value.startsWith("--") && (!SECRET_VALUE_FLAGS.has(name) || namesFlag(value))))
780
789
  return { error: `--${name} needs a value` };
781
790
  // `flows import --param a=1 --param b=2`: each value kept, one per line.
782
791
  flags.set(name, name === "param" && typeof flags.get(name) === "string" ? `${flags.get(name)}\n${value}` : value);
@@ -943,6 +952,8 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
943
952
  ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
944
953
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
945
954
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
955
+ ...(options.upRole === undefined ? {} : { upRole: options.upRole }),
956
+ ...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
946
957
  ...(activeRemote() === null ? {} : { principal: activeRemote() }),
947
958
  });
948
959
  return await enter(run);
@@ -1312,11 +1323,11 @@ async function dispatch(command, positional, flags, context) {
1312
1323
  case "sync":
1313
1324
  return syncCommand(flags, context);
1314
1325
  case "serve":
1315
- return serveCommand(flags, context);
1326
+ return gitTooOld(command, context) ?? serveCommand(flags, context);
1316
1327
  case "watch":
1317
- return watchCommand(flags, context);
1328
+ return gitTooOld(command, context) ?? watchCommand(flags, context);
1318
1329
  case "up":
1319
- return upCommand(flags, context);
1330
+ return gitTooOld(command, context) ?? upCommand(flags, context);
1320
1331
  case "onboard":
1321
1332
  return onboardCommand(flags, context);
1322
1333
  case "daemon":
@@ -3105,9 +3116,10 @@ async function tickCommand(flags, context) {
3105
3116
  buildBase = source.head;
3106
3117
  else {
3107
3118
  const descendant = exists.code === 0 ? branch : base;
3108
- const ancestry = await git("git", ["--no-lazy-fetch", "--no-replace-objects", "merge-base", "--is-ancestor", source.head, descendant], { cwd: repo });
3119
+ // On a git without --no-lazy-fetch, through a frozen read view that refuses a partial clone (lazyFetchSafeGit).
3120
+ const ancestry = await lazyFetchSafeGit(git)("git", [...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "merge-base", "--is-ancestor", source.head, descendant], { cwd: repo });
3109
3121
  if (ancestry.code !== 0)
3110
- problem = "the requested revision base does not contain its source head";
3122
+ problem = ancestry.stderr.includes(PARTIAL_CLONE_REFUSAL) ? PARTIAL_CLONE_REFUSAL : "the requested revision base does not contain its source head";
3111
3123
  }
3112
3124
  }
3113
3125
  if (problem !== null) {
@@ -6056,7 +6068,19 @@ async function runWatchLoop(args) {
6056
6068
  }
6057
6069
  // The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
6058
6070
  const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
6059
- const lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
6071
+ let lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
6072
+ // Only expiry hands the lease over (store.acquireWatchLease): a predecessor still renewing keeps it, and the takeover
6073
+ // recovers its incarnation in the same transaction, exactly as a fresh `up` after a crash would.
6074
+ const waitUntil = Date.now() + (args.leaseWaitMs ?? 0);
6075
+ let saidWaiting = false;
6076
+ const waitStopping = args.leaseWaitStopping ?? args.isStopping;
6077
+ while (!lease.ok && lease.reason === "watch-busy" && Date.now() < waitUntil && !waitStopping()) {
6078
+ if (!saidWaiting)
6079
+ progress(`watch: ${repo} is still held by the previous worker until ${lease.until ?? "its lease lapses"}; taking it over then`);
6080
+ saidWaiting = true;
6081
+ await new Promise(resolve => setTimeout(resolve, 1_000));
6082
+ lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
6083
+ }
6060
6084
  if (!lease.ok) {
6061
6085
  return {
6062
6086
  ok: false,
@@ -6656,8 +6680,9 @@ async function watchCommand(flags, context) {
6656
6680
  *
6657
6681
  * COMPOSITION ONLY: identities mint through the atomic doors (a first
6658
6682
  * approver only while none exists; a runner only while its name is idle),
6659
- * then the extracted console and one watch loop per repository run in this
6660
- * one process, under one supervisor, over one shared store. Nothing here
6683
+ * then the console and the worker (one watch loop per repository) run as two
6684
+ * supervised processes over one shared store (isolated-checks.md, stage 1;
6685
+ * up-processes.ts says what each holds). Nothing here
6661
6686
  * loosens a ceremony: approval, budgets, publication, and every fence work
6662
6687
  * exactly as they do for the long-hand verbs.
6663
6688
  */
@@ -6728,7 +6753,7 @@ async function resolveUpApprover(context, flags, canPrompt) {
6728
6753
  // the insert (finding 27). A refused insert unlinks only when an
6729
6754
  // UNRELATED approver won (finding 32).
6730
6755
  const name = asFlag ?? process.env["USER"] ?? process.env["USERNAME"] ?? "operator";
6731
- const password = randomBytes(12).toString("base64url");
6756
+ const password = mintSecret(12);
6732
6757
  try {
6733
6758
  writeLoginFileDurably(loginFile, name, password);
6734
6759
  }
@@ -6960,6 +6985,9 @@ async function onboardCommand(flags, context) {
6960
6985
  }
6961
6986
  async function upCommand(flags, context) {
6962
6987
  const { store, write, json, clock } = context;
6988
+ // A role of an `up` that is already running (up-role-child.ts): no validation, identity or port of its own.
6989
+ if (context.upRole !== undefined)
6990
+ return upRoleCommand(context, context.upRole);
6963
6991
  const demoFence = refuseDemo(context, "up");
6964
6992
  if (demoFence !== null)
6965
6993
  return demoFence;
@@ -7158,292 +7186,133 @@ async function upCommand(flags, context) {
7158
7186
  finally {
7159
7187
  await new Promise(done => probe.close(() => done()));
7160
7188
  }
7161
- // 7. The console, on the just-released port. The tiny window between the
7162
- // probe closing and this bind can lose a race; that failure tears down
7163
- // cleanly below instead of leaving identities half-claimed silently.
7189
+ // 7–8. The console and the worker (docs/plans/isolated-checks.md, stage 1): two processes, each started and
7190
+ // restarted by controller-supervisor, so a long check in the worker never holds a console response. With seams
7191
+ // that can't cross a process (tests, the desktop host) or --single-process, the same two roles run in this one.
7164
7192
  const pool = join(dirname(context.databaseFile), "worktrees");
7165
- // Mutated only by the registry supervisor below. The console reads this
7166
- // proved set on each request, so its project rail and unified chat move
7167
- // with the live builder instead of freezing at process start.
7168
- const activeRepos = new Set(repos);
7169
- // Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
7170
- let instanceSecret = null;
7171
- let console_;
7172
- try {
7173
- console_ = await startConsole({
7174
- instanceSecret: () => instanceSecret,
7175
- context,
7176
- host: hostFlag,
7177
- port,
7178
- ...(allowFlag === undefined ? {} : { allowedHosts: allowFlag.split(",").map(one => one.trim()).filter(one => one !== "") }),
7179
- ...(publicUrlFlag === undefined ? {} : { publicUrl: publicUrlFlag }),
7180
- localRunner: runnerName,
7181
- poolRoot: pool,
7182
- repos,
7183
- currentRepos: () => [...activeRepos],
7184
- admittedRepos: () => [...activeRepos],
7185
- upConsole: true,
7186
- registryPath,
7187
- projectRoots,
7188
- ...(text(flags, "public-url") === undefined ? {} : { publicUrl: text(flags, "public-url") }),
7189
- ...(text(flags, "editor") === undefined ? {} : { editorLinks: "vscode" }),
7190
- });
7191
- }
7192
- catch (error) {
7193
- retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7194
- return fail(write, json, "up", "port-busy", `${describe(error)} — the port was taken while starting; try again`, EXIT.refused);
7195
- }
7196
- // 8. The project supervisor: one watch loop per proved repository, plus a
7197
- // tiny registry poll that can add more while this process stays up. The
7198
- // poll is deliberately plain filesystem I/O (portable across macOS,
7199
- // Linux, and Windows) and never invokes an agent while nothing changed.
7193
+ const split = !flags.has("single-process") && context.upOneProcess !== true;
7194
+ const build = upBuildIdentity();
7195
+ const containmentFlag = text(flags, "containment") ?? null;
7196
+ const stopGraceMs = Number(text(flags, "stop-grace") ?? 30_000);
7200
7197
  let stopping = false;
7201
7198
  let fatal = null;
7202
- let graceTimer;
7203
7199
  let runTimer;
7204
- const followControllers = [];
7205
7200
  let resolveStopped = () => { };
7206
7201
  const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
7207
- const stopGraceMs = Number(text(flags, "stop-grace") ?? 30_000);
7208
- const hardStop = () => {
7209
- const terminated = terminateLiveProviders();
7210
- if (terminated > 0)
7211
- progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
7212
- };
7202
+ // What the worker serves now: relayed to the console (its project rail and chat), and a restarted worker's start.
7203
+ let currentRepos = [...repos];
7204
+ let consoleRole = null;
7205
+ let workerRole = null;
7213
7206
  const stop = () => {
7214
7207
  if (stopping) {
7215
- hardStop();
7208
+ workerRole?.hardStop();
7216
7209
  return;
7217
7210
  }
7218
7211
  stopping = true;
7219
7212
  resolveStopped();
7220
- for (const controller of followControllers)
7221
- controller.abort();
7222
- // The coding owner record is released first, whatever else is still
7223
- // finishing: launchd kills a service that outlasts its exit window, and a
7224
- // killed process cannot release it. Its outcome is read again at close.
7225
- console_.server.closeCoding().catch(() => { });
7226
- const building = store.liveClaimCount(runnerName, clock());
7227
- if (building > 0) {
7228
- progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
7229
- }
7230
- graceTimer = setTimeout(hardStop, stopGraceMs);
7231
- graceTimer.unref?.();
7213
+ // The coding owner record is released first, whatever else is still finishing: launchd kills a service that
7214
+ // outlasts its exit window, and a killed process cannot release it. The console keeps serving until the end.
7215
+ consoleRole?.send({ type: "stopping" });
7216
+ workerRole?.stop();
7232
7217
  };
7233
7218
  process.on("SIGINT", stop);
7234
7219
  process.on("SIGTERM", stop);
7235
- if (forGiven !== undefined) {
7236
- runTimer = setTimeout(stop, Number(forGiven));
7237
- runTimer.unref?.();
7238
- }
7239
- const loopFlagsFor = (repo) => {
7240
- const copy = new Map();
7241
- // A bounded `up` must not advertise its child watches as an unbounded sweep service either.
7242
- if (forGiven !== undefined)
7243
- copy.set("for", forGiven);
7244
- copy.set("runner", runnerName);
7245
- copy.set("token", runnerToken);
7246
- copy.set("repo", repo);
7247
- copy.set("pool", pool);
7248
- // The co-hosted console's stated origin rides into each loop's follower:
7249
- // a phone link must name THIS console, or not exist.
7250
- const publicUrl = text(flags, "public-url");
7251
- if (publicUrl !== undefined)
7252
- copy.set("public-url", publicUrl);
7253
- return copy;
7220
+ // A log (a service, an agent, --json or --verbose) says whose each line is; a person at a terminal reads plain lines.
7221
+ const labelled = split && (json || flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true));
7222
+ const roleLine = (role) => (line) => progress(labelled ? `[${role}] ${line}` : line);
7223
+ const dropSignals = () => {
7224
+ process.removeListener("SIGINT", stop);
7225
+ process.removeListener("SIGTERM", stop);
7254
7226
  };
7255
- const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
7256
- // Containment at start: one plain sentence, once, for a person at a
7257
- // terminal; the exact status line with --verbose, in a service log (no
7258
- // terminal), and always when a required policy will refuse spawns.
7259
- const containmentDetail = flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true);
7260
- let containmentSaid = false;
7261
- const containmentLines = (effective) => {
7262
- const notice = containmentNotice(effective);
7263
- if (notice !== null && !containmentSaid)
7264
- progress(notice);
7265
- containmentSaid = true;
7266
- return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
7227
+ const consoleConfig = {
7228
+ host: hostFlag, port, allowedHosts: allowFlag === undefined ? null : allowFlag.split(",").map(one => one.trim()).filter(one => one !== ""),
7229
+ publicUrl: publicUrlFlag ?? null, localRunner: runnerName, poolRoot: pool, repos: [...repos], projectRoots, registryPath, editor: editorGiven !== undefined,
7267
7230
  };
7268
- const loopResults = new Map();
7269
- // One record of project passes for the whole service: fair scheduling
7270
- // across its projects (project-concurrency.ts).
7271
- const projectPasses = new ProjectPasses();
7272
- // Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
7273
- const releasedRepos = new Set();
7274
- const launchRepo = async (repo, bind) => {
7275
- if (loopResults.has(repo) || stopping)
7276
- return;
7277
- if (bind) {
7278
- const bound = addRunnerReposAuthed(store, { name: runnerName, token: runnerToken, repos: [repo] }, clock());
7279
- if (!bound.ok) {
7280
- fatal = `the builder identity changed while adding ${repo} (${bound.reason})`;
7281
- stop();
7282
- return;
7231
+ const pendingLinks = new Map();
7232
+ let linkRequests = 0;
7233
+ consoleRole = startUpRole({
7234
+ role: "console", split, context, build, shutdownMs: 15_000,
7235
+ start: () => ({ type: "start", role: "console", databaseFile: context.databaseFile, containment: containmentFlag, build, config: { ...consoleConfig, repos: [...currentRepos] } }),
7236
+ onMessage: message => {
7237
+ if (message.type === "log")
7238
+ roleLine("console")(message.line);
7239
+ else if (message.type === "sign-in-link") {
7240
+ pendingLinks.get(message.id)?.(message.link);
7241
+ pendingLinks.delete(message.id);
7283
7242
  }
7284
- activeRepos.add(repo);
7285
- if (!store.listProjects().some(one => one.path === repo)) {
7286
- store.upsertProject(repo, projectName(repo), clock());
7243
+ },
7244
+ // A restarted console is told what the worker serves now.
7245
+ onReady: (_, again) => { if (again) {
7246
+ roleLine("console")("started again");
7247
+ consoleRole?.send({ type: "repos", repos: [...currentRepos] });
7248
+ } },
7249
+ onEnded: (detail, restarting) => {
7250
+ if (restarting)
7251
+ roleLine("console")(`${detail}; starting it again`);
7252
+ else if (!stopping) {
7253
+ fatal = detail;
7254
+ stop();
7287
7255
  }
7288
- }
7289
- let markReady = () => { };
7290
- const ready = new Promise(resolveReady => (markReady = resolveReady));
7291
- const loop = runWatchLoop({
7292
- flags: loopFlagsFor(repo),
7293
- context,
7294
- runner: runnerName,
7295
- token: runnerToken,
7296
- repo,
7297
- progress: line => progress(`${prefix(repo)}${line}`),
7298
- isStopping: () => stopping || releasedRepos.has(repo),
7299
- onFollowController: controller => followControllers.push(controller),
7300
- containmentLines,
7301
- passes: projectPasses,
7302
- serviceBridge: true,
7303
- onReady: () => {
7304
- markReady();
7305
- if (bind)
7306
- progress(`${prefix(repo)}builder connected — queued work can start`);
7256
+ },
7257
+ });
7258
+ const consoleReady = await consoleRole.ready;
7259
+ if (!consoleReady.ok) {
7260
+ dropSignals();
7261
+ retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7262
+ if (consoleReady.reason === "port-busy")
7263
+ return fail(write, json, "up", "port-busy", `${consoleReady.detail} — the port was taken while starting; try again`, EXIT.refused);
7264
+ return fail(write, json, "up", "failed", `the console could not start (${consoleReady.detail}) — nothing was left running`, EXIT.failed);
7265
+ }
7266
+ const url = consoleReady.url;
7267
+ if (!stopping) {
7268
+ workerRole = startUpRole({
7269
+ role: "worker", split, context, build,
7270
+ // The worker drains its builds for the stop grace, then stops them; past that it is stopped outright.
7271
+ shutdownMs: Math.max(1, stopGraceMs) + 15_000,
7272
+ start: generation => ({
7273
+ type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
7274
+ config: {
7275
+ runner: runnerName, token: runnerToken, repos: generation === 1 ? [...repos] : [...currentRepos], pinnedRepos: [...pinnedRepos], projectRoots, registryPath, pool,
7276
+ forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
7277
+ terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
7278
+ // A restart waits out a crashed predecessor's lease (90 s), then recovers it: as a fresh `up` after a crash.
7279
+ leaseWaitMs: split ? WATCH_LEASE_MS + 15_000 : 0,
7280
+ },
7281
+ }),
7282
+ onMessage: message => {
7283
+ if (message.type === "log")
7284
+ roleLine("worker")(message.line);
7285
+ else if (message.type === "repos") {
7286
+ // The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
7287
+ // through the authenticated door before it reports): a worker's word alone admits nothing.
7288
+ const bound = new Set(store.getRunner(runnerName)?.runner.repos ?? []);
7289
+ currentRepos = message.repos.filter(repo => bound.has(repo));
7290
+ consoleRole?.send({ type: "repos", repos: [...currentRepos] });
7291
+ }
7307
7292
  },
7308
- }).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
7309
- loopResults.set(repo, loop);
7310
- void loop.then(({ repo: endedRepo, result }) => {
7311
- if (releasedRepos.has(endedRepo) && !stopping) {
7312
- // A removed project's watch has stopped: only now does the builder let go of it.
7313
- const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
7314
- if (!unbound.ok) {
7315
- fatal = `the builder identity changed while removing ${endedRepo} (${unbound.reason})`;
7293
+ onReady: (_, again) => { if (again)
7294
+ roleLine("worker")("started again: its builds and checks continue"); },
7295
+ onEnded: (detail, restarting) => {
7296
+ // A builder whose credential no longer proves can't come back by restarting: the whole `up` stops, and the
7297
+ // service's next start registers it again, as before the split.
7298
+ if (restarting && authenticate(store, runnerName, runnerToken).ok) {
7299
+ roleLine("worker")(`${detail}; starting it again — its interrupted builds are recovered first`);
7300
+ return;
7301
+ }
7302
+ if (!stopping) {
7303
+ fatal = restarting ? `the builder identity changed (${detail})` : detail;
7316
7304
  stop();
7317
7305
  }
7318
- loopResults.delete(endedRepo);
7319
- releasedRepos.delete(endedRepo);
7320
- progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
7321
- markReady();
7322
- return;
7323
- }
7324
- // A loop that dies while its siblings live must not leave a partial
7325
- // cockpit standing silently (finding 5).
7326
- if (!result.ok && !stopping) {
7327
- fatal = `${prefix(endedRepo)}${result.detail}`;
7328
- stop();
7329
- }
7330
- markReady(); // acquisition failures must not strand startup readiness
7331
- });
7332
- await ready;
7333
- };
7334
- await Promise.all(repos.map(repo => launchRepo(repo, false)));
7335
- // Telegram: one follower for the whole service, never one per project
7336
- // loop. Replies and notifications go out from it, over the same transport
7337
- // in the same cycle (Oct 2: a reply sent by a project loop's own follower
7338
- // failed "fetch failed" while notifications went out). A token saved
7339
- // while the service runs connects within seconds; a follower that dies
7340
- // is started again.
7341
- const telegramController = new AbortController();
7342
- followControllers.push(telegramController);
7343
- const telegramSupervisor = (async () => {
7344
- const pause = (ms) => new Promise(resolvePause => {
7345
- const timer = setTimeout(done, ms);
7346
- function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
7347
- // An aborted signal never fires again: without this a stop waited the whole pause.
7348
- if (telegramController.signal.aborted)
7349
- done();
7350
- else
7351
- telegramController.signal.addEventListener("abort", done, { once: true });
7306
+ },
7352
7307
  });
7353
- while (!stopping && !telegramController.signal.aborted) {
7354
- const follower = startTelegramFollower({
7355
- context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
7356
- died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
7357
- });
7358
- if (follower !== null)
7359
- await follower;
7360
- // Without a token (or on a fenced network) look again shortly; the fence line is said once.
7361
- if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
7362
- break;
7363
- await pause(follower === null ? 2_000 : 5_000);
7364
- }
7365
- })();
7366
- const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
7367
- const rejected = new Set();
7368
- const registrySupervisor = (async () => {
7369
- const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
7370
- while (!stopping) {
7371
- const loaded = await loadProjectRegistry(registryPath);
7372
- if ("error" in loaded) {
7373
- fatal = loaded.error;
7374
- stop();
7375
- break;
7376
- }
7377
- // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
7378
- const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
7379
- for (const repo of [...activeRepos]) {
7380
- if (stopping || pinnedRepos.has(repo) || saved.has(repo))
7381
- continue;
7382
- activeRepos.delete(repo);
7383
- releasedRepos.add(repo);
7384
- if (!loopResults.has(repo))
7385
- releasedRepos.delete(repo);
7386
- }
7387
- for (const candidate of loaded.repos) {
7388
- if (stopping)
7389
- break;
7390
- const canonical = canonicalProject(candidate);
7391
- // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
7392
- if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
7393
- continue;
7394
- const approvedAdditions = context.additionalProjectRepos?.() ?? [];
7395
- const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
7396
- // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
7397
- // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
7398
- const allowed = canonical !== null &&
7399
- ((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
7400
- (await proveRepo(canonical)) === canonical;
7401
- if (!allowed) {
7402
- if (!rejected.has(candidate)) {
7403
- rejected.add(candidate);
7404
- progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
7405
- }
7406
- continue;
7407
- }
7408
- rejected.delete(candidate);
7409
- await launchRepo(canonical, true);
7410
- }
7411
- if (!stopping)
7412
- await sleep(500);
7308
+ const workerReady = await workerRole.ready;
7309
+ if (!workerReady.ok && fatal === null) {
7310
+ fatal = workerReady.detail;
7311
+ stop();
7413
7312
  }
7414
- })().catch(error => {
7415
- fatal = `the project registry stopped (${describe(error)})`;
7416
- stop();
7417
- });
7418
- // A runner with no projects still stays visibly alive while the UI waits
7419
- // for the first addition. Watch loops heartbeat during their passes; this
7420
- // machine-level pulse also covers the intentionally empty state.
7421
- // Provider readiness (v47): this machine's non-spending observations,
7422
- // recorded under its own runner name ONCE at start — so every route
7423
- // projection can say ready / unavailable / unknown per provider, and
7424
- // the dispatch gate can halt a leg this machine cannot run. There is no
7425
- // periodic readiness daemon: startup, an explicit `providers --report`,
7426
- // and the narrow pre-dispatch observation are the roads. Production
7427
- // only: a test's injected agent runner is a fake, and its readiness
7428
- // would be a fiction.
7429
- const observeReadiness = () => {
7430
- if (context.agentRunner !== undefined)
7431
- return;
7432
- void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName))
7433
- .then(observed => {
7434
- if (!stopping)
7435
- reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
7436
- })
7437
- .catch(() => {
7438
- // A probe that cannot run leaves readiness unknown — never a fiction.
7439
- });
7440
- };
7441
- observeReadiness();
7442
- try {
7443
- notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
7444
7313
  }
7445
- catch { /* A display fact; never blocks the worker. */ }
7446
- recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
7314
+ // A runner with no projects still stays visibly alive while the UI waits for the first addition; the worker's
7315
+ // loops also renew it with their leases. Across a worker restart this keeps the name held.
7447
7316
  const runnerHeartbeat = setInterval(() => {
7448
7317
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
7449
7318
  if (!beat.ok && !stopping) {
@@ -7454,29 +7323,13 @@ async function upCommand(flags, context) {
7454
7323
  runnerHeartbeat.unref?.();
7455
7324
  // 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
7456
7325
  const approverPlan2 = approver;
7457
- const url = console_.url;
7458
7326
  const terminal = !agentRunning && (context.upSeams?.terminal ?? process.stdout.isTTY === true);
7459
7327
  const upHandoff = buildHandoff({
7460
7328
  url,
7461
7329
  login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
7462
7330
  });
7463
7331
  recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
7464
- // Cockpit B1: who this console is, for bare `toolroll` to prove before it attaches: a fresh secret in the database
7465
- // (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
7466
- // isn't offered; the console runs either way.
7467
- // The socket is taken FIRST: only the console that owns it publishes a record. A second console on the same database
7468
- // (another port) never replaces the first one's record; it runs, but isn't attached to.
7469
- let mintedSecret = null;
7470
- const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
7471
- if (instanceSocket.ok) {
7472
- mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
7473
- instanceSecret = mintedSecret;
7474
- if (mintedSecret === null)
7475
- progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
7476
- }
7477
- else
7478
- progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
7479
- if (fatal === null) {
7332
+ if (fatal === null && !stopping) {
7480
7333
  if (json) {
7481
7334
  write(envelopeJson({
7482
7335
  ok: true,
@@ -7504,7 +7357,13 @@ async function upCommand(flags, context) {
7504
7357
  // one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
7505
7358
  // saved beside the database, for later and for other devices, and is never printed either.
7506
7359
  const account = approverPlan2.approver;
7507
- const link = account === null || flags.has("no-open") ? null : console_.server.mintSignInLink(account);
7360
+ // The console mints it (its sign-in secrets stay there); it comes back only to be opened, never printed.
7361
+ const link = account === null || flags.has("no-open") ? null : await new Promise(resolveLink => {
7362
+ const id = ++linkRequests;
7363
+ const timer = setTimeout(() => { pendingLinks.delete(id); resolveLink(null); }, 10_000);
7364
+ pendingLinks.set(id, value => { clearTimeout(timer); resolveLink(value); });
7365
+ consoleRole?.send({ type: "sign-in-link", id, account });
7366
+ });
7508
7367
  const home = homedir();
7509
7368
  const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
7510
7369
  const signInWords = account === null
@@ -7523,35 +7382,26 @@ async function upCommand(flags, context) {
7523
7382
  }
7524
7383
  }
7525
7384
  }
7526
- // 10. Supervise to the end.
7385
+ // A bounded run (--for) counts from readiness, so a slow start never eats the window it asked for.
7386
+ if (forGiven !== undefined && !stopping) {
7387
+ runTimer = setTimeout(stop, Number(forGiven));
7388
+ runTimer.unref?.();
7389
+ }
7390
+ // 10. Supervise to the end: the worker drains first (its builds finish or are saved), the builder retires, and
7391
+ // only then does the console stop serving.
7527
7392
  await stopped;
7528
- await registrySupervisor;
7529
- await Promise.all([...loopResults.values()]);
7530
- // The follower was aborted with the stop. A send or a chat turn it is still
7531
- // finishing keeps its own durable record; the service does not wait past
7532
- // this for it.
7533
- await new Promise(done => {
7534
- const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
7535
- const finish = () => { clearTimeout(timer); done(); };
7536
- void telegramSupervisor.then(finish, finish);
7537
- });
7393
+ if (workerRole !== null)
7394
+ await workerRole.done;
7538
7395
  clearInterval(runnerHeartbeat);
7539
7396
  if (runTimer !== undefined)
7540
7397
  clearTimeout(runTimer);
7541
- if (graceTimer !== undefined)
7542
- clearTimeout(graceTimer);
7543
- process.removeListener("SIGINT", stop);
7544
- process.removeListener("SIGTERM", stop);
7398
+ dropSignals();
7545
7399
  retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7546
- if (instanceSocket.ok)
7547
- instanceSocket.close();
7548
- if (mintedSecret !== null)
7549
- clearInstance(store, mintedSecret);
7550
- let closeProblem = null;
7551
- await new Promise(done => console_.server.close(error => { closeProblem = error ?? null; done(); }));
7400
+ consoleRole.stop();
7401
+ const closed = await consoleRole.done;
7552
7402
  // A live terminal that couldn't be proved stopped (or any other close failure) is said, never "stopped".
7553
- if (closeProblem !== null && fatal === null)
7554
- fatal = closeProblem.message;
7403
+ if (closed.problem !== null && fatal === null)
7404
+ fatal = closed.problem;
7555
7405
  if (fatal !== null) {
7556
7406
  // The startup envelope (when json) already went out; the exit code is
7557
7407
  // the health signal (finding 11/20) — never a second envelope.
@@ -11732,3 +11582,607 @@ async function projectSandboxCommand(positional, flags, context) {
11732
11582
  "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11733
11583
  ]);
11734
11584
  }
11585
+ /** Starting the console or the worker on a git older than Toolroll's floor (git-floor.ts): refused, with one message
11586
+ * naming the installed and the required version. Other commands aren't held. */
11587
+ function gitTooOld(command, context) {
11588
+ const problem = installedGitFloorProblem();
11589
+ return problem === null ? null : fail(context.write, context.json, command, "git-too-old", problem, EXIT.refused);
11590
+ }
11591
+ /** This build's identity, as each role reports it: the version and the code of this module. */
11592
+ function upBuildIdentity() {
11593
+ return buildIdentity(PACKAGE_VERSION, fileURLToPath(import.meta.url));
11594
+ }
11595
+ /** Seams that live in this process's memory (a test's fake runner, a store wrapper, the desktop host's proof key): a
11596
+ * child process can't receive them, so `up` keeps both of its roles in this process rather than drop them. */
11597
+ function upNeedsOneProcess(options) {
11598
+ return options.openDatabase !== undefined || options.desktopIdentity !== undefined || options.additionalProjectRepos !== undefined ||
11599
+ options.agentRunner !== undefined || options.gitRunner !== undefined || options.telegramTransport !== undefined || options.telegramEnv !== undefined ||
11600
+ options.publishExec !== undefined || options.dispatchAdapter !== undefined || options.flowTriggerIo !== undefined || options.flowStepIo !== undefined ||
11601
+ options.flowEveryMs !== undefined || options.platform !== undefined || options.fenceAvailable !== undefined || options.flowFetch !== undefined ||
11602
+ options.integrationIo !== undefined || options.shouldStop !== undefined || options.leadSeams !== undefined || options.waitSleep !== undefined ||
11603
+ options.releaseIo !== undefined || options.installBin !== undefined || options.tempRoots !== undefined || options.filingSizer !== undefined ||
11604
+ options.now !== undefined;
11605
+ }
11606
+ /** The entry a role child runs: the built sibling when installed, the TypeScript source through tsx in a checkout. */
11607
+ function upRoleChildCommand(role) {
11608
+ const source = import.meta.url.endsWith(".ts");
11609
+ const entry = fileURLToPath(new URL(source ? "./up-role-child.ts" : "./up-role-child.js", import.meta.url));
11610
+ // tsx by its resolved address: the child keeps this process's working directory, which may hold no node_modules.
11611
+ // The role's name is the one argument: a process listing tells the two apart, and carries nothing secret.
11612
+ return { file: process.execPath, argv: [...(source ? ["--import", import.meta.resolve("tsx")] : []), entry, role] };
11613
+ }
11614
+ /**
11615
+ * One role of `up`: a supervised child process (split), or the same role function in this process. Either way its
11616
+ * messages pass the same checks (up-processes.ts), and its build must be this one's.
11617
+ * - onReady fires on every readiness; `again` after a restart.
11618
+ * - onEnded: the role ended while not stopping. `restarting` (split only) when the supervisor will start it again.
11619
+ */
11620
+ function startUpRole(args) {
11621
+ const { role } = args;
11622
+ let resolveReady = () => { };
11623
+ const ready = new Promise(resolve => (resolveReady = resolve));
11624
+ // Readiness per generation (Codex review round 1, finding 5): a replacement's refusal is never hidden by an earlier
11625
+ // generation having been ready.
11626
+ const generations = new RoleGenerations();
11627
+ let stopping = false;
11628
+ // Reported once: the role ended for good (onEnded with restarting false), whichever path saw it first.
11629
+ let endedForGood = false;
11630
+ const endForGood = (detail) => {
11631
+ if (stopping || endedForGood)
11632
+ return;
11633
+ endedForGood = true;
11634
+ args.onEnded(detail, false);
11635
+ };
11636
+ let failed = null;
11637
+ let fatal = null;
11638
+ let closedProblem = null;
11639
+ let livePid = null;
11640
+ // Shared by both transports: every message is checked against this role before anything acts on it.
11641
+ const receive = (raw, abort) => {
11642
+ const message = roleMessage(raw, role);
11643
+ if (message === null)
11644
+ return;
11645
+ if (message.type === "ready") {
11646
+ if (message.build !== args.build) {
11647
+ // Never a console and a worker from different builds: stop rather than run a mixed pair.
11648
+ fatal = `the ${role} is a different build (${message.build}) from this one (${args.build}) — restart Toolroll`;
11649
+ failed = { reason: "start", detail: fatal };
11650
+ generations.refused(fatal);
11651
+ abort();
11652
+ return;
11653
+ }
11654
+ livePid = message.pid;
11655
+ const again = !generations.ready();
11656
+ if (!again)
11657
+ resolveReady({ ok: true, url: message.url ?? "", port: message.port, pid: message.pid });
11658
+ args.onReady(message, again);
11659
+ return;
11660
+ }
11661
+ if (message.type === "failed") {
11662
+ failed = { reason: message.reason, detail: message.detail };
11663
+ generations.refused(message.detail);
11664
+ return;
11665
+ }
11666
+ if (message.type === "fatal") {
11667
+ fatal = message.detail;
11668
+ return;
11669
+ }
11670
+ if (message.type === "closed") {
11671
+ closedProblem = message.problem;
11672
+ return;
11673
+ }
11674
+ args.onMessage(message);
11675
+ };
11676
+ if (!args.split) {
11677
+ const channel = memoryChannel(role);
11678
+ channel.parent.onMessage(message => receive(message, () => channel.parent.stop(false)));
11679
+ const running = upRoleCommand(args.context, { start: args.start(1), port: channel.port }).catch(error => {
11680
+ fatal = describe(error);
11681
+ return EXIT.failed;
11682
+ });
11683
+ const done = running.then(code => {
11684
+ livePid = null;
11685
+ if (!generations.wasReady())
11686
+ resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} stopped while starting (exit ${code})` }) });
11687
+ else
11688
+ endForGood(fatal ?? `the ${role} stopped (exit ${code})`);
11689
+ return { problem: closedProblem };
11690
+ });
11691
+ return {
11692
+ ready, done,
11693
+ send: message => channel.parent.send(message),
11694
+ stop: () => { stopping = true; channel.parent.stop(false); },
11695
+ hardStop: () => { channel.parent.stop(true); },
11696
+ pid: () => livePid,
11697
+ };
11698
+ }
11699
+ const controller = new AbortController();
11700
+ let child = null;
11701
+ const command = upRoleChildCommand(role);
11702
+ const supervised = superviseController({
11703
+ file: command.file, argv: command.argv, signal: controller.signal, ipc: true, shutdownMs: args.shutdownMs,
11704
+ // Each role's own output carries its name; a JSON log stays one JSON object a line.
11705
+ label: envValue(process.env, "LOG_FORMAT") === "json" ? "" : `[${role}]`,
11706
+ onSpawn: (owned, generation) => {
11707
+ child = owned;
11708
+ fatal = null;
11709
+ failed = null;
11710
+ generations.spawned();
11711
+ owned.on("message", raw => receive(raw, () => controller.abort()));
11712
+ owned.on("error", () => { });
11713
+ // The start, including the runner token for the worker, goes over the private channel only.
11714
+ owned.send(args.start(generation), error => { if (error !== null && error !== undefined)
11715
+ owned.kill("SIGTERM"); });
11716
+ },
11717
+ onState: state => {
11718
+ if (state.phase !== "backoff")
11719
+ return;
11720
+ child = null;
11721
+ livePid = null;
11722
+ const exit = state.exit?.signal ?? state.exit?.code ?? "unknown";
11723
+ if (stopping && generations.wasReady())
11724
+ return;
11725
+ const verdict = generations.ended();
11726
+ if (verdict === "fail-start") {
11727
+ // A role that never became ready is not retried: the start fails as one, as before the split.
11728
+ controller.abort();
11729
+ resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} stopped while starting (exit ${exit})` }) });
11730
+ return;
11731
+ }
11732
+ if (verdict === "end") {
11733
+ // A replacement that refused its start (another build or database), or kept ending before it was ready: a
11734
+ // restart can't fix that, so the whole `up` stops instead of looping with the other role still running.
11735
+ controller.abort();
11736
+ endForGood(generations.refusedWith() ?? fatal ?? `the ${role} kept stopping before it was ready (exit ${exit})`);
11737
+ return;
11738
+ }
11739
+ // A build mismatch already aborted: that is the end, not a restart.
11740
+ if (controller.signal.aborted) {
11741
+ endForGood(fatal ?? `the ${role} stopped (exit ${exit})`);
11742
+ return;
11743
+ }
11744
+ args.onEnded(`${fatal === null ? "" : `${fatal} — `}the ${role} stopped unexpectedly (exit ${exit})`, true);
11745
+ },
11746
+ }).catch(error => { if (!generations.wasReady())
11747
+ resolveReady({ ok: false, reason: "start", detail: describe(error) }); });
11748
+ const done = supervised.then(() => {
11749
+ if (!generations.wasReady())
11750
+ resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} did not start` }) });
11751
+ // The supervisor ended without `up` stopping it (an abort on a refusal emits no backoff): `up` hears it either way.
11752
+ else
11753
+ endForGood(generations.refusedWith() ?? fatal ?? `the ${role} stopped`);
11754
+ return { problem: closedProblem };
11755
+ });
11756
+ return {
11757
+ ready, done,
11758
+ send: message => {
11759
+ const owned = child;
11760
+ if (owned !== null && owned.connected) {
11761
+ try {
11762
+ owned.send(message, () => { });
11763
+ }
11764
+ catch { /* it is ending; the supervisor sees it */ }
11765
+ }
11766
+ },
11767
+ // The supervisor's stop is SIGTERM, which a role takes as "drain"; past shutdownMs its whole tree is stopped.
11768
+ stop: () => { stopping = true; controller.abort(); },
11769
+ hardStop: () => {
11770
+ const owned = child;
11771
+ if (owned !== null && owned.connected) {
11772
+ try {
11773
+ owned.send({ type: "hard-stop" }, () => { });
11774
+ }
11775
+ catch { /* ending */ }
11776
+ }
11777
+ },
11778
+ pid: () => livePid,
11779
+ };
11780
+ }
11781
+ /** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
11782
+ * role itself. Only a process started by `up` gets here; no command line selects a role. */
11783
+ export async function runUpRole(start, port) {
11784
+ return runOperate("up", ["--db", start.databaseFile, ...(start.containment === null ? [] : ["--containment", start.containment])], line => port.send({ type: "log", line }), { upRole: { start, port } });
11785
+ }
11786
+ async function upRoleCommand(context, host) {
11787
+ const { start, port } = host;
11788
+ // A role runs exactly this build, on exactly this database: anything else ends it before it touches either.
11789
+ if (start.build !== upBuildIdentity()) {
11790
+ port.send({ type: "failed", reason: "start", detail: `this ${start.role} is a different build (${upBuildIdentity()}) from the \`up\` that started it (${start.build})` });
11791
+ return EXIT.refused;
11792
+ }
11793
+ if (resolve(start.databaseFile) !== resolve(context.databaseFile)) {
11794
+ port.send({ type: "failed", reason: "start", detail: `the ${start.role} opened a different database` });
11795
+ return EXIT.refused;
11796
+ }
11797
+ return start.role === "console" ? runUpConsoleRole(context, start.config, port, start.build) : runUpWorkerRole(context, start.config, port, start.build);
11798
+ }
11799
+ /** The console role: HTTP only — serving, reading, and the person's ceremonies. It never holds the runner token. */
11800
+ async function runUpConsoleRole(context, config, port, build) {
11801
+ const { store, clock } = context;
11802
+ const progress = (line) => port.send({ type: "log", line });
11803
+ // Mutated only by the worker's word (relayed by `up`): the console's project rail and unified chat follow the live
11804
+ // builder instead of freezing at process start.
11805
+ const activeRepos = new Set(config.repos);
11806
+ // Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
11807
+ let instanceSecret = null;
11808
+ let console_;
11809
+ try {
11810
+ console_ = await startConsole({
11811
+ instanceSecret: () => instanceSecret,
11812
+ context,
11813
+ host: config.host,
11814
+ port: config.port,
11815
+ ...(config.allowedHosts === null ? {} : { allowedHosts: config.allowedHosts }),
11816
+ ...(config.publicUrl === null ? {} : { publicUrl: config.publicUrl }),
11817
+ localRunner: config.localRunner,
11818
+ poolRoot: config.poolRoot,
11819
+ repos: config.repos,
11820
+ currentRepos: () => [...activeRepos],
11821
+ admittedRepos: () => [...activeRepos],
11822
+ upConsole: true,
11823
+ registryPath: config.registryPath,
11824
+ projectRoots: config.projectRoots,
11825
+ ...(config.editor ? { editorLinks: "vscode" } : {}),
11826
+ });
11827
+ }
11828
+ catch (error) {
11829
+ port.send({ type: "failed", reason: "port-busy", detail: describe(error) });
11830
+ return EXIT.refused;
11831
+ }
11832
+ let codingClosed = false;
11833
+ const closeCoding = () => {
11834
+ if (codingClosed)
11835
+ return;
11836
+ codingClosed = true;
11837
+ console_.server.closeCoding().catch(() => { });
11838
+ };
11839
+ let resolveStopped = () => { };
11840
+ const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
11841
+ port.onStop(() => resolveStopped());
11842
+ port.onMessage(message => {
11843
+ if (message.type === "repos") {
11844
+ activeRepos.clear();
11845
+ for (const repo of message.repos)
11846
+ activeRepos.add(repo);
11847
+ }
11848
+ else if (message.type === "stopping")
11849
+ closeCoding();
11850
+ else if (message.type === "sign-in-link") {
11851
+ let link = null;
11852
+ try {
11853
+ link = console_.server.mintSignInLink(message.account);
11854
+ }
11855
+ catch {
11856
+ link = null;
11857
+ }
11858
+ port.send({ type: "sign-in-link", id: message.id, link });
11859
+ }
11860
+ });
11861
+ // Cockpit B1: who this console is, for bare `toolroll` to prove before it attaches: a fresh secret in the database
11862
+ // (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
11863
+ // isn't offered; the console runs either way. The socket is taken FIRST: only the console that owns it publishes a
11864
+ // record. A second console on the same database (another port) never replaces the first one's record.
11865
+ let mintedSecret = null;
11866
+ const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
11867
+ if (instanceSocket.ok) {
11868
+ mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
11869
+ instanceSecret = mintedSecret;
11870
+ if (mintedSecret === null)
11871
+ progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
11872
+ }
11873
+ else
11874
+ progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
11875
+ port.send({ type: "ready", role: "console", build, pid: process.pid, url: console_.url, port: console_.port });
11876
+ await stopped;
11877
+ closeCoding();
11878
+ if (instanceSocket.ok)
11879
+ instanceSocket.close();
11880
+ if (mintedSecret !== null)
11881
+ clearInstance(store, mintedSecret);
11882
+ let closeProblem = null;
11883
+ await new Promise(done => console_.server.close(error => { closeProblem = error === undefined || error === null ? null : error.message; done(); }));
11884
+ port.send({ type: "closed", problem: closeProblem });
11885
+ return closeProblem === null ? EXIT.ok : EXIT.failed;
11886
+ }
11887
+ /** The worker role: one watch loop per proved repository (builds, checks, batch checks, follow-ups, reconcilers, the
11888
+ * storage sweep and checkout sizing), the project registry, and the service's one Telegram follower. It opens no
11889
+ * port and mints no console secret. */
11890
+ async function runUpWorkerRole(context, config, port, build) {
11891
+ const { store, clock } = context;
11892
+ const progress = (line) => port.send({ type: "log", line });
11893
+ const runnerName = config.runner;
11894
+ const runnerToken = config.token;
11895
+ const pool = config.pool;
11896
+ const registryPath = config.registryPath;
11897
+ const pinnedRepos = new Set(config.pinnedRepos);
11898
+ const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
11899
+ const proveRepo = async (input) => {
11900
+ const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
11901
+ if (top.code !== 0)
11902
+ return null;
11903
+ try {
11904
+ return realpathSync(top.stdout.trim());
11905
+ }
11906
+ catch {
11907
+ return null;
11908
+ }
11909
+ };
11910
+ // The proved set this worker builds; `up` relays each change to the console.
11911
+ const activeRepos = new Set(config.repos);
11912
+ const reportRepos = () => port.send({ type: "repos", repos: [...activeRepos] });
11913
+ // The historical signal shell: the first stop ends admission and starts the grace clock; the second — or the
11914
+ // clock — stops every live provider group (M6.12).
11915
+ let stopping = false;
11916
+ let fatal = null;
11917
+ let graceTimer;
11918
+ const followControllers = [];
11919
+ let resolveStopped = () => { };
11920
+ const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
11921
+ const hardStop = () => {
11922
+ const terminated = terminateLiveProviders();
11923
+ if (terminated > 0)
11924
+ progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
11925
+ };
11926
+ const stop = () => {
11927
+ if (stopping) {
11928
+ hardStop();
11929
+ return;
11930
+ }
11931
+ stopping = true;
11932
+ resolveStopped();
11933
+ for (const controller of followControllers)
11934
+ controller.abort();
11935
+ const building = store.liveClaimCount(runnerName, clock());
11936
+ if (building > 0) {
11937
+ progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(config.stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
11938
+ }
11939
+ graceTimer = setTimeout(hardStop, config.stopGraceMs);
11940
+ graceTimer.unref?.();
11941
+ };
11942
+ port.onStop(hard => {
11943
+ if (!stopping)
11944
+ stop();
11945
+ if (hard)
11946
+ hardStop();
11947
+ });
11948
+ const loopFlagsFor = (repo) => {
11949
+ const copy = new Map();
11950
+ // A bounded `up` must not advertise its child watches as an unbounded sweep service either.
11951
+ if (config.forMs !== null)
11952
+ copy.set("for", String(config.forMs));
11953
+ copy.set("runner", runnerName);
11954
+ copy.set("token", runnerToken);
11955
+ copy.set("repo", repo);
11956
+ copy.set("pool", pool);
11957
+ // The co-hosted console's stated origin rides into each loop's follower: a phone link must name THIS console, or not exist.
11958
+ if (config.publicUrl !== null)
11959
+ copy.set("public-url", config.publicUrl);
11960
+ return copy;
11961
+ };
11962
+ const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
11963
+ // Containment at start: one plain sentence, once, for a person at a terminal; the exact status line with --verbose,
11964
+ // in a service log (no terminal), and always when a required policy will refuse spawns.
11965
+ const containmentDetail = config.verbose || !config.terminal;
11966
+ let containmentSaid = false;
11967
+ const containmentLines = (effective) => {
11968
+ const notice = containmentNotice(effective);
11969
+ if (notice !== null && !containmentSaid)
11970
+ progress(notice);
11971
+ containmentSaid = true;
11972
+ return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
11973
+ };
11974
+ const loopResults = new Map();
11975
+ // One record of project passes for the whole service: fair scheduling across its projects (project-concurrency.ts).
11976
+ const projectPasses = new ProjectPasses();
11977
+ // Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
11978
+ const releasedRepos = new Set();
11979
+ const launchRepo = async (repo, bind, drain = false) => {
11980
+ if (loopResults.has(repo) || stopping)
11981
+ return;
11982
+ // A predecessor's watch on a project this start doesn't name (it was being removed): take it over, recovering what
11983
+ // the predecessor held, and let it go (round 1, finding 2).
11984
+ if (drain)
11985
+ releasedRepos.add(repo);
11986
+ if (bind) {
11987
+ const bound = addRunnerReposAuthed(store, { name: runnerName, token: runnerToken, repos: [repo] }, clock());
11988
+ if (!bound.ok) {
11989
+ fatal = `the builder identity changed while adding ${repo} (${bound.reason})`;
11990
+ stop();
11991
+ return;
11992
+ }
11993
+ activeRepos.add(repo);
11994
+ reportRepos();
11995
+ if (!store.listProjects().some(one => one.path === repo)) {
11996
+ store.upsertProject(repo, projectName(repo), clock());
11997
+ }
11998
+ }
11999
+ let markReady = () => { };
12000
+ const ready = new Promise(resolveReady => (markReady = resolveReady));
12001
+ const loop = runWatchLoop({
12002
+ flags: loopFlagsFor(repo),
12003
+ context,
12004
+ runner: runnerName,
12005
+ token: runnerToken,
12006
+ repo,
12007
+ progress: line => progress(`${prefix(repo)}${line}`),
12008
+ isStopping: () => stopping || releasedRepos.has(repo),
12009
+ onFollowController: controller => followControllers.push(controller),
12010
+ containmentLines,
12011
+ passes: projectPasses,
12012
+ serviceBridge: true,
12013
+ leaseWaitMs: config.leaseWaitMs,
12014
+ ...(drain ? { leaseWaitStopping: () => stopping } : {}),
12015
+ onReady: () => {
12016
+ markReady();
12017
+ if (bind)
12018
+ progress(`${prefix(repo)}builder connected — queued work can start`);
12019
+ },
12020
+ }).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
12021
+ loopResults.set(repo, loop);
12022
+ void loop.then(({ repo: endedRepo, result }) => {
12023
+ if (releasedRepos.has(endedRepo) && !stopping && !result.ok) {
12024
+ // A removed project's watch that never held its lease (or lost it) has recovered nothing: keep the project
12025
+ // bound so the next worker recovers it, and stop rather than strand its work (round 1, finding 2).
12026
+ fatal = `${prefix(endedRepo)}${result.detail}`;
12027
+ stop();
12028
+ markReady();
12029
+ return;
12030
+ }
12031
+ if (releasedRepos.has(endedRepo) && !stopping) {
12032
+ // A removed project's watch has stopped: only now does the builder let go of it.
12033
+ const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
12034
+ if (!unbound.ok) {
12035
+ fatal = `the builder identity changed while removing ${endedRepo} (${unbound.reason})`;
12036
+ stop();
12037
+ }
12038
+ loopResults.delete(endedRepo);
12039
+ releasedRepos.delete(endedRepo);
12040
+ progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
12041
+ markReady();
12042
+ return;
12043
+ }
12044
+ // A loop that dies while its siblings live must not leave a partial cockpit standing silently (finding 5).
12045
+ if (!result.ok && !stopping) {
12046
+ fatal = `${prefix(endedRepo)}${result.detail}`;
12047
+ stop();
12048
+ }
12049
+ markReady(); // acquisition failures must not strand startup readiness
12050
+ });
12051
+ await ready;
12052
+ };
12053
+ // Before any new work: every watch a predecessor left needing recovery on a project this start doesn't name — one
12054
+ // being removed when it died — is taken over (its incarnation recovered in that step) and let go. Only projects the
12055
+ // store still binds to this runner; the start's own projects recover in their own loops below (round 1, finding 2).
12056
+ const named = new Set(config.repos);
12057
+ const boundNow = new Set(store.getRunner(runnerName)?.runner.repos ?? []);
12058
+ const draining = [...new Set(predecessorWatches(store, runnerName, clock()).map(one => one.repo))].filter(repo => !named.has(repo) && boundNow.has(repo));
12059
+ for (const repo of draining)
12060
+ progress(`${projectName(repo)} was being removed when the previous worker stopped: recovering its work first`);
12061
+ await Promise.all(draining.map(repo => launchRepo(repo, false, true)));
12062
+ await Promise.all(config.repos.map(repo => launchRepo(repo, false)));
12063
+ // Telegram: one follower for the whole service, never one per project loop. Replies and notifications go out from
12064
+ // it, over the same transport in the same cycle. A token saved while the service runs connects within seconds; a
12065
+ // follower that dies is started again.
12066
+ const telegramController = new AbortController();
12067
+ followControllers.push(telegramController);
12068
+ if (stopping)
12069
+ telegramController.abort();
12070
+ const telegramSupervisor = (async () => {
12071
+ const pause = (ms) => new Promise(resolvePause => {
12072
+ const timer = setTimeout(done, ms);
12073
+ function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
12074
+ // An aborted signal never fires again: without this a stop waited the whole pause.
12075
+ if (telegramController.signal.aborted)
12076
+ done();
12077
+ else
12078
+ telegramController.signal.addEventListener("abort", done, { once: true });
12079
+ });
12080
+ while (!stopping && !telegramController.signal.aborted) {
12081
+ const follower = startTelegramFollower({
12082
+ context, publicUrl: config.publicUrl ?? undefined, signal: telegramController.signal, progress, label: "telegram:",
12083
+ died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
12084
+ });
12085
+ if (follower !== null)
12086
+ await follower;
12087
+ // Without a token (or on a fenced network) look again shortly; the fence line is said once.
12088
+ if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
12089
+ break;
12090
+ await pause(follower === null ? 2_000 : 5_000);
12091
+ }
12092
+ })();
12093
+ const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots).ceiling;
12094
+ const rejected = new Set();
12095
+ const registrySupervisor = (async () => {
12096
+ const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
12097
+ while (!stopping) {
12098
+ const loaded = await loadProjectRegistry(registryPath);
12099
+ if ("error" in loaded) {
12100
+ fatal = loaded.error;
12101
+ stop();
12102
+ break;
12103
+ }
12104
+ // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
12105
+ const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
12106
+ let removed = false;
12107
+ for (const repo of [...activeRepos]) {
12108
+ if (stopping || pinnedRepos.has(repo) || saved.has(repo))
12109
+ continue;
12110
+ activeRepos.delete(repo);
12111
+ removed = true;
12112
+ releasedRepos.add(repo);
12113
+ if (!loopResults.has(repo))
12114
+ releasedRepos.delete(repo);
12115
+ }
12116
+ if (removed)
12117
+ reportRepos();
12118
+ for (const candidate of loaded.repos) {
12119
+ if (stopping)
12120
+ break;
12121
+ const canonical = canonicalProject(candidate);
12122
+ // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
12123
+ if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
12124
+ continue;
12125
+ const approvedAdditions = context.additionalProjectRepos?.() ?? [];
12126
+ const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
12127
+ // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
12128
+ // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
12129
+ const allowed = canonical !== null &&
12130
+ ((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
12131
+ (await proveRepo(canonical)) === canonical;
12132
+ if (!allowed) {
12133
+ if (!rejected.has(candidate)) {
12134
+ rejected.add(candidate);
12135
+ progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
12136
+ }
12137
+ continue;
12138
+ }
12139
+ rejected.delete(candidate);
12140
+ await launchRepo(canonical, true);
12141
+ }
12142
+ if (!stopping)
12143
+ await sleep(500);
12144
+ }
12145
+ })().catch(error => {
12146
+ fatal = `the project registry stopped (${describe(error)})`;
12147
+ stop();
12148
+ });
12149
+ // Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
12150
+ // start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
12151
+ // halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
12152
+ // would be a fiction.
12153
+ if (context.agentRunner === undefined) {
12154
+ void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName))
12155
+ .then(observed => {
12156
+ if (!stopping)
12157
+ reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
12158
+ })
12159
+ .catch(() => {
12160
+ // A probe that cannot run leaves readiness unknown — never a fiction.
12161
+ });
12162
+ }
12163
+ try {
12164
+ notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
12165
+ }
12166
+ catch { /* A display fact; never blocks the worker. */ }
12167
+ recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
12168
+ // Ready once every project's loop holds its lease; a loop that failed to start ends this worker instead.
12169
+ if (fatal === null && !stopping)
12170
+ port.send({ type: "ready", role: "worker", build, pid: process.pid, url: null, port: null });
12171
+ await stopped;
12172
+ await registrySupervisor;
12173
+ await Promise.all([...loopResults.values()]);
12174
+ // The follower was aborted with the stop. A send or a chat turn it is still finishing keeps its own durable record;
12175
+ // the service does not wait past this for it.
12176
+ await new Promise(done => {
12177
+ const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
12178
+ const finish = () => { clearTimeout(timer); done(); };
12179
+ void telegramSupervisor.then(finish, finish);
12180
+ });
12181
+ if (graceTimer !== undefined)
12182
+ clearTimeout(graceTimer);
12183
+ if (fatal !== null) {
12184
+ port.send({ type: "fatal", detail: fatal });
12185
+ return EXIT.failed;
12186
+ }
12187
+ return EXIT.ok;
12188
+ }