toolroll 0.9.75 → 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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -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 +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. 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,6 +228,9 @@ 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";
231
+ import { hardenedGit, lazyFetchSafeGit, noLazyFetch, PARTIAL_CLONE_REFUSAL } from "./teardown-git.js";
232
+ import { installedGitFloorProblem } from "./git-floor.js";
233
+ import { noticeGitIncidents } from "./git-incident.js";
227
234
  import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
228
235
  import { readPulls } from "./pulls.js";
229
236
  import { startMaintenance } from "./maintenance.js";
@@ -433,6 +440,8 @@ External trackers — build what a tracker nominates, under local approvals
433
440
  app; they start without a restart.
434
441
  --repo still adds an exact path;
435
442
  --no-open skips the browser;
443
+ the app and the builder run as two
444
+ processes (--single-process: one);
436
445
  --host 0.0.0.0 --allow-host name:port
437
446
  reaches a phone over a tailnet;
438
447
  --runner names the worker;
@@ -654,6 +663,8 @@ export const KEYS_ACTIONS = ["status", "set", "clear", "verify", "auth"];
654
663
  * tests): every value-taking flag any verb reads, and every boolean.
655
664
  * A --flag in neither set is a typo, refused by name.
656
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"]);
657
668
  export const OPERATE_VALUE_FLAGS = new Set([
658
669
  "key", "db", "runner", "ttl", "state", "on", "reason", "until", "id", "backend",
659
670
  "allow", "selector", "paths", "credentials", "repo", "token", "capacity",
@@ -704,7 +715,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
704
715
  "no-carry",
705
716
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
706
717
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
707
- "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",
708
719
  "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
709
720
  "html", "csv", "alerts-only",
710
721
  // project network: what its builds may reach (--open | --allowlist).
@@ -770,11 +781,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
770
781
  const value = equals === -1 ? argv[++index] : argument.slice(equals + 1);
771
782
  // A following --flag is not a value — consuming it would swallow a real
772
783
  // flag and leave this one holding a name-shaped lie.
773
- // Minted 32-byte base64url credentials may begin with two hyphens.
774
- // Accept that exact token shape without swallowing a following flag.
775
- // Explicit --name=value also carries arbitrary literal leading hyphens.
776
- const mintedToken = name === "token" && value !== undefined && /^[A-Za-z0-9_-]{43}$/.test(value);
777
- 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))))
778
789
  return { error: `--${name} needs a value` };
779
790
  // `flows import --param a=1 --param b=2`: each value kept, one per line.
780
791
  flags.set(name, name === "param" && typeof flags.get(name) === "string" ? `${flags.get(name)}\n${value}` : value);
@@ -941,6 +952,8 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
941
952
  ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
942
953
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
943
954
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
955
+ ...(options.upRole === undefined ? {} : { upRole: options.upRole }),
956
+ ...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
944
957
  ...(activeRemote() === null ? {} : { principal: activeRemote() }),
945
958
  });
946
959
  return await enter(run);
@@ -1310,11 +1323,11 @@ async function dispatch(command, positional, flags, context) {
1310
1323
  case "sync":
1311
1324
  return syncCommand(flags, context);
1312
1325
  case "serve":
1313
- return serveCommand(flags, context);
1326
+ return gitTooOld(command, context) ?? serveCommand(flags, context);
1314
1327
  case "watch":
1315
- return watchCommand(flags, context);
1328
+ return gitTooOld(command, context) ?? watchCommand(flags, context);
1316
1329
  case "up":
1317
- return upCommand(flags, context);
1330
+ return gitTooOld(command, context) ?? upCommand(flags, context);
1318
1331
  case "onboard":
1319
1332
  return onboardCommand(flags, context);
1320
1333
  case "daemon":
@@ -2220,7 +2233,7 @@ function flowIoOf(context, pool, git, base) {
2220
2233
  replies: { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
2221
2234
  ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
2222
2235
  // Check and update steps run outside a model: an approved command in a fresh copy of the card's work, or a comment on the issue it came from.
2223
- steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2236
+ steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? (git === run ? hardenedGit(run) : git), shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2224
2237
  dir: context.flowStepIo?.dir ?? dir, scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2225
2238
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }) },
2226
2239
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
@@ -3103,9 +3116,10 @@ async function tickCommand(flags, context) {
3103
3116
  buildBase = source.head;
3104
3117
  else {
3105
3118
  const descendant = exists.code === 0 ? branch : base;
3106
- 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 });
3107
3121
  if (ancestry.code !== 0)
3108
- 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";
3109
3123
  }
3110
3124
  }
3111
3125
  if (problem !== null) {
@@ -3905,6 +3919,8 @@ async function decideCommand(positional, flags, context) {
3905
3919
  */
3906
3920
  async function startConsole(options) {
3907
3921
  const { context } = options;
3922
+ // A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
3923
+ noticeGitIncidents(context.store, context.clock);
3908
3924
  const server = createDecisionServer({
3909
3925
  store: context.store,
3910
3926
  // Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
@@ -6041,6 +6057,8 @@ let staleStatusesRepaired = false;
6041
6057
  async function runWatchLoop(args) {
6042
6058
  const { context, flags, runner, token, repo, progress } = args;
6043
6059
  const { store } = context;
6060
+ // A git incident in this worker's builds raises an attention notification in its outbox (once per store).
6061
+ noticeGitIncidents(store, context.clock);
6044
6062
  const tickEveryMs = Number(text(flags, "tick-every") ?? 60_000);
6045
6063
  const bridgeEveryMs = Number(text(flags, "bridge-every") ?? 45_000);
6046
6064
  const reconcileEveryMs = Number(text(flags, "reconcile-every") ?? 5 * 60_000);
@@ -6050,7 +6068,19 @@ async function runWatchLoop(args) {
6050
6068
  }
6051
6069
  // The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
6052
6070
  const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
6053
- 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
+ }
6054
6084
  if (!lease.ok) {
6055
6085
  return {
6056
6086
  ok: false,
@@ -6650,8 +6680,9 @@ async function watchCommand(flags, context) {
6650
6680
  *
6651
6681
  * COMPOSITION ONLY: identities mint through the atomic doors (a first
6652
6682
  * approver only while none exists; a runner only while its name is idle),
6653
- * then the extracted console and one watch loop per repository run in this
6654
- * 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
6655
6686
  * loosens a ceremony: approval, budgets, publication, and every fence work
6656
6687
  * exactly as they do for the long-hand verbs.
6657
6688
  */
@@ -6722,7 +6753,7 @@ async function resolveUpApprover(context, flags, canPrompt) {
6722
6753
  // the insert (finding 27). A refused insert unlinks only when an
6723
6754
  // UNRELATED approver won (finding 32).
6724
6755
  const name = asFlag ?? process.env["USER"] ?? process.env["USERNAME"] ?? "operator";
6725
- const password = randomBytes(12).toString("base64url");
6756
+ const password = mintSecret(12);
6726
6757
  try {
6727
6758
  writeLoginFileDurably(loginFile, name, password);
6728
6759
  }
@@ -6954,6 +6985,9 @@ async function onboardCommand(flags, context) {
6954
6985
  }
6955
6986
  async function upCommand(flags, context) {
6956
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);
6957
6991
  const demoFence = refuseDemo(context, "up");
6958
6992
  if (demoFence !== null)
6959
6993
  return demoFence;
@@ -7152,292 +7186,133 @@ async function upCommand(flags, context) {
7152
7186
  finally {
7153
7187
  await new Promise(done => probe.close(() => done()));
7154
7188
  }
7155
- // 7. The console, on the just-released port. The tiny window between the
7156
- // probe closing and this bind can lose a race; that failure tears down
7157
- // 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.
7158
7192
  const pool = join(dirname(context.databaseFile), "worktrees");
7159
- // Mutated only by the registry supervisor below. The console reads this
7160
- // proved set on each request, so its project rail and unified chat move
7161
- // with the live builder instead of freezing at process start.
7162
- const activeRepos = new Set(repos);
7163
- // Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
7164
- let instanceSecret = null;
7165
- let console_;
7166
- try {
7167
- console_ = await startConsole({
7168
- instanceSecret: () => instanceSecret,
7169
- context,
7170
- host: hostFlag,
7171
- port,
7172
- ...(allowFlag === undefined ? {} : { allowedHosts: allowFlag.split(",").map(one => one.trim()).filter(one => one !== "") }),
7173
- ...(publicUrlFlag === undefined ? {} : { publicUrl: publicUrlFlag }),
7174
- localRunner: runnerName,
7175
- poolRoot: pool,
7176
- repos,
7177
- currentRepos: () => [...activeRepos],
7178
- admittedRepos: () => [...activeRepos],
7179
- upConsole: true,
7180
- registryPath,
7181
- projectRoots,
7182
- ...(text(flags, "public-url") === undefined ? {} : { publicUrl: text(flags, "public-url") }),
7183
- ...(text(flags, "editor") === undefined ? {} : { editorLinks: "vscode" }),
7184
- });
7185
- }
7186
- catch (error) {
7187
- retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7188
- return fail(write, json, "up", "port-busy", `${describe(error)} — the port was taken while starting; try again`, EXIT.refused);
7189
- }
7190
- // 8. The project supervisor: one watch loop per proved repository, plus a
7191
- // tiny registry poll that can add more while this process stays up. The
7192
- // poll is deliberately plain filesystem I/O (portable across macOS,
7193
- // 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);
7194
7197
  let stopping = false;
7195
7198
  let fatal = null;
7196
- let graceTimer;
7197
7199
  let runTimer;
7198
- const followControllers = [];
7199
7200
  let resolveStopped = () => { };
7200
7201
  const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
7201
- const stopGraceMs = Number(text(flags, "stop-grace") ?? 30_000);
7202
- const hardStop = () => {
7203
- const terminated = terminateLiveProviders();
7204
- if (terminated > 0)
7205
- progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
7206
- };
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;
7207
7206
  const stop = () => {
7208
7207
  if (stopping) {
7209
- hardStop();
7208
+ workerRole?.hardStop();
7210
7209
  return;
7211
7210
  }
7212
7211
  stopping = true;
7213
7212
  resolveStopped();
7214
- for (const controller of followControllers)
7215
- controller.abort();
7216
- // The coding owner record is released first, whatever else is still
7217
- // finishing: launchd kills a service that outlasts its exit window, and a
7218
- // killed process cannot release it. Its outcome is read again at close.
7219
- console_.server.closeCoding().catch(() => { });
7220
- const building = store.liveClaimCount(runnerName, clock());
7221
- if (building > 0) {
7222
- 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.`);
7223
- }
7224
- graceTimer = setTimeout(hardStop, stopGraceMs);
7225
- 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();
7226
7217
  };
7227
7218
  process.on("SIGINT", stop);
7228
7219
  process.on("SIGTERM", stop);
7229
- if (forGiven !== undefined) {
7230
- runTimer = setTimeout(stop, Number(forGiven));
7231
- runTimer.unref?.();
7232
- }
7233
- const loopFlagsFor = (repo) => {
7234
- const copy = new Map();
7235
- // A bounded `up` must not advertise its child watches as an unbounded sweep service either.
7236
- if (forGiven !== undefined)
7237
- copy.set("for", forGiven);
7238
- copy.set("runner", runnerName);
7239
- copy.set("token", runnerToken);
7240
- copy.set("repo", repo);
7241
- copy.set("pool", pool);
7242
- // The co-hosted console's stated origin rides into each loop's follower:
7243
- // a phone link must name THIS console, or not exist.
7244
- const publicUrl = text(flags, "public-url");
7245
- if (publicUrl !== undefined)
7246
- copy.set("public-url", publicUrl);
7247
- 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);
7248
7226
  };
7249
- const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
7250
- // Containment at start: one plain sentence, once, for a person at a
7251
- // terminal; the exact status line with --verbose, in a service log (no
7252
- // terminal), and always when a required policy will refuse spawns.
7253
- const containmentDetail = flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true);
7254
- let containmentSaid = false;
7255
- const containmentLines = (effective) => {
7256
- const notice = containmentNotice(effective);
7257
- if (notice !== null && !containmentSaid)
7258
- progress(notice);
7259
- containmentSaid = true;
7260
- 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,
7261
7230
  };
7262
- const loopResults = new Map();
7263
- // One record of project passes for the whole service: fair scheduling
7264
- // across its projects (project-concurrency.ts).
7265
- const projectPasses = new ProjectPasses();
7266
- // Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
7267
- const releasedRepos = new Set();
7268
- const launchRepo = async (repo, bind) => {
7269
- if (loopResults.has(repo) || stopping)
7270
- return;
7271
- if (bind) {
7272
- const bound = addRunnerReposAuthed(store, { name: runnerName, token: runnerToken, repos: [repo] }, clock());
7273
- if (!bound.ok) {
7274
- fatal = `the builder identity changed while adding ${repo} (${bound.reason})`;
7275
- stop();
7276
- 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);
7277
7242
  }
7278
- activeRepos.add(repo);
7279
- if (!store.listProjects().some(one => one.path === repo)) {
7280
- 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();
7281
7255
  }
7282
- }
7283
- let markReady = () => { };
7284
- const ready = new Promise(resolveReady => (markReady = resolveReady));
7285
- const loop = runWatchLoop({
7286
- flags: loopFlagsFor(repo),
7287
- context,
7288
- runner: runnerName,
7289
- token: runnerToken,
7290
- repo,
7291
- progress: line => progress(`${prefix(repo)}${line}`),
7292
- isStopping: () => stopping || releasedRepos.has(repo),
7293
- onFollowController: controller => followControllers.push(controller),
7294
- containmentLines,
7295
- passes: projectPasses,
7296
- serviceBridge: true,
7297
- onReady: () => {
7298
- markReady();
7299
- if (bind)
7300
- 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
+ }
7301
7292
  },
7302
- }).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
7303
- loopResults.set(repo, loop);
7304
- void loop.then(({ repo: endedRepo, result }) => {
7305
- if (releasedRepos.has(endedRepo) && !stopping) {
7306
- // A removed project's watch has stopped: only now does the builder let go of it.
7307
- const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
7308
- if (!unbound.ok) {
7309
- 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;
7310
7304
  stop();
7311
7305
  }
7312
- loopResults.delete(endedRepo);
7313
- releasedRepos.delete(endedRepo);
7314
- progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
7315
- markReady();
7316
- return;
7317
- }
7318
- // A loop that dies while its siblings live must not leave a partial
7319
- // cockpit standing silently (finding 5).
7320
- if (!result.ok && !stopping) {
7321
- fatal = `${prefix(endedRepo)}${result.detail}`;
7322
- stop();
7323
- }
7324
- markReady(); // acquisition failures must not strand startup readiness
7325
- });
7326
- await ready;
7327
- };
7328
- await Promise.all(repos.map(repo => launchRepo(repo, false)));
7329
- // Telegram: one follower for the whole service, never one per project
7330
- // loop. Replies and notifications go out from it, over the same transport
7331
- // in the same cycle (Oct 2: a reply sent by a project loop's own follower
7332
- // failed "fetch failed" while notifications went out). A token saved
7333
- // while the service runs connects within seconds; a follower that dies
7334
- // is started again.
7335
- const telegramController = new AbortController();
7336
- followControllers.push(telegramController);
7337
- const telegramSupervisor = (async () => {
7338
- const pause = (ms) => new Promise(resolvePause => {
7339
- const timer = setTimeout(done, ms);
7340
- function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
7341
- // An aborted signal never fires again: without this a stop waited the whole pause.
7342
- if (telegramController.signal.aborted)
7343
- done();
7344
- else
7345
- telegramController.signal.addEventListener("abort", done, { once: true });
7306
+ },
7346
7307
  });
7347
- while (!stopping && !telegramController.signal.aborted) {
7348
- const follower = startTelegramFollower({
7349
- context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
7350
- died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
7351
- });
7352
- if (follower !== null)
7353
- await follower;
7354
- // Without a token (or on a fenced network) look again shortly; the fence line is said once.
7355
- if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
7356
- break;
7357
- await pause(follower === null ? 2_000 : 5_000);
7358
- }
7359
- })();
7360
- const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
7361
- const rejected = new Set();
7362
- const registrySupervisor = (async () => {
7363
- const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
7364
- while (!stopping) {
7365
- const loaded = await loadProjectRegistry(registryPath);
7366
- if ("error" in loaded) {
7367
- fatal = loaded.error;
7368
- stop();
7369
- break;
7370
- }
7371
- // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
7372
- const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
7373
- for (const repo of [...activeRepos]) {
7374
- if (stopping || pinnedRepos.has(repo) || saved.has(repo))
7375
- continue;
7376
- activeRepos.delete(repo);
7377
- releasedRepos.add(repo);
7378
- if (!loopResults.has(repo))
7379
- releasedRepos.delete(repo);
7380
- }
7381
- for (const candidate of loaded.repos) {
7382
- if (stopping)
7383
- break;
7384
- const canonical = canonicalProject(candidate);
7385
- // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
7386
- if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
7387
- continue;
7388
- const approvedAdditions = context.additionalProjectRepos?.() ?? [];
7389
- const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
7390
- // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
7391
- // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
7392
- const allowed = canonical !== null &&
7393
- ((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
7394
- (await proveRepo(canonical)) === canonical;
7395
- if (!allowed) {
7396
- if (!rejected.has(candidate)) {
7397
- rejected.add(candidate);
7398
- progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
7399
- }
7400
- continue;
7401
- }
7402
- rejected.delete(candidate);
7403
- await launchRepo(canonical, true);
7404
- }
7405
- if (!stopping)
7406
- await sleep(500);
7308
+ const workerReady = await workerRole.ready;
7309
+ if (!workerReady.ok && fatal === null) {
7310
+ fatal = workerReady.detail;
7311
+ stop();
7407
7312
  }
7408
- })().catch(error => {
7409
- fatal = `the project registry stopped (${describe(error)})`;
7410
- stop();
7411
- });
7412
- // A runner with no projects still stays visibly alive while the UI waits
7413
- // for the first addition. Watch loops heartbeat during their passes; this
7414
- // machine-level pulse also covers the intentionally empty state.
7415
- // Provider readiness (v47): this machine's non-spending observations,
7416
- // recorded under its own runner name ONCE at start — so every route
7417
- // projection can say ready / unavailable / unknown per provider, and
7418
- // the dispatch gate can halt a leg this machine cannot run. There is no
7419
- // periodic readiness daemon: startup, an explicit `providers --report`,
7420
- // and the narrow pre-dispatch observation are the roads. Production
7421
- // only: a test's injected agent runner is a fake, and its readiness
7422
- // would be a fiction.
7423
- const observeReadiness = () => {
7424
- if (context.agentRunner !== undefined)
7425
- return;
7426
- 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))
7427
- .then(observed => {
7428
- if (!stopping)
7429
- reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
7430
- })
7431
- .catch(() => {
7432
- // A probe that cannot run leaves readiness unknown — never a fiction.
7433
- });
7434
- };
7435
- observeReadiness();
7436
- try {
7437
- notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
7438
7313
  }
7439
- catch { /* A display fact; never blocks the worker. */ }
7440
- 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.
7441
7316
  const runnerHeartbeat = setInterval(() => {
7442
7317
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
7443
7318
  if (!beat.ok && !stopping) {
@@ -7448,29 +7323,13 @@ async function upCommand(flags, context) {
7448
7323
  runnerHeartbeat.unref?.();
7449
7324
  // 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
7450
7325
  const approverPlan2 = approver;
7451
- const url = console_.url;
7452
7326
  const terminal = !agentRunning && (context.upSeams?.terminal ?? process.stdout.isTTY === true);
7453
7327
  const upHandoff = buildHandoff({
7454
7328
  url,
7455
7329
  login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
7456
7330
  });
7457
7331
  recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
7458
- // Cockpit B1: who this console is, for bare `toolroll` to prove before it attaches: a fresh secret in the database
7459
- // (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
7460
- // isn't offered; the console runs either way.
7461
- // The socket is taken FIRST: only the console that owns it publishes a record. A second console on the same database
7462
- // (another port) never replaces the first one's record; it runs, but isn't attached to.
7463
- let mintedSecret = null;
7464
- const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
7465
- if (instanceSocket.ok) {
7466
- mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
7467
- instanceSecret = mintedSecret;
7468
- if (mintedSecret === null)
7469
- progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
7470
- }
7471
- else
7472
- progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
7473
- if (fatal === null) {
7332
+ if (fatal === null && !stopping) {
7474
7333
  if (json) {
7475
7334
  write(envelopeJson({
7476
7335
  ok: true,
@@ -7498,7 +7357,13 @@ async function upCommand(flags, context) {
7498
7357
  // one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
7499
7358
  // saved beside the database, for later and for other devices, and is never printed either.
7500
7359
  const account = approverPlan2.approver;
7501
- 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
+ });
7502
7367
  const home = homedir();
7503
7368
  const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
7504
7369
  const signInWords = account === null
@@ -7517,35 +7382,26 @@ async function upCommand(flags, context) {
7517
7382
  }
7518
7383
  }
7519
7384
  }
7520
- // 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.
7521
7392
  await stopped;
7522
- await registrySupervisor;
7523
- await Promise.all([...loopResults.values()]);
7524
- // The follower was aborted with the stop. A send or a chat turn it is still
7525
- // finishing keeps its own durable record; the service does not wait past
7526
- // this for it.
7527
- await new Promise(done => {
7528
- const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
7529
- const finish = () => { clearTimeout(timer); done(); };
7530
- void telegramSupervisor.then(finish, finish);
7531
- });
7393
+ if (workerRole !== null)
7394
+ await workerRole.done;
7532
7395
  clearInterval(runnerHeartbeat);
7533
7396
  if (runTimer !== undefined)
7534
7397
  clearTimeout(runTimer);
7535
- if (graceTimer !== undefined)
7536
- clearTimeout(graceTimer);
7537
- process.removeListener("SIGINT", stop);
7538
- process.removeListener("SIGTERM", stop);
7398
+ dropSignals();
7539
7399
  retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7540
- if (instanceSocket.ok)
7541
- instanceSocket.close();
7542
- if (mintedSecret !== null)
7543
- clearInstance(store, mintedSecret);
7544
- let closeProblem = null;
7545
- await new Promise(done => console_.server.close(error => { closeProblem = error ?? null; done(); }));
7400
+ consoleRole.stop();
7401
+ const closed = await consoleRole.done;
7546
7402
  // A live terminal that couldn't be proved stopped (or any other close failure) is said, never "stopped".
7547
- if (closeProblem !== null && fatal === null)
7548
- fatal = closeProblem.message;
7403
+ if (closed.problem !== null && fatal === null)
7404
+ fatal = closed.problem;
7549
7405
  if (fatal !== null) {
7550
7406
  // The startup envelope (when json) already went out; the exit code is
7551
7407
  // the health signal (finding 11/20) — never a second envelope.
@@ -11726,3 +11582,607 @@ async function projectSandboxCommand(positional, flags, context) {
11726
11582
  "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11727
11583
  ]);
11728
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
+ }