toolroll 0.9.7 → 0.9.9

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 (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -1,13 +1,18 @@
1
1
  import { maybeTriggerRepair } from "./dispose.js";
2
2
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
3
3
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
4
+ import { buildReviewPass } from "./build-review.js";
5
+ import { buildReviewLines, buildReviewOf } from "./review-switch.js";
4
6
  import { parseProtectedPaths } from "./approval-policy.js";
5
7
  import { rulesSummary } from "./approval-rules-ui.js";
6
8
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
7
9
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
8
- import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
9
- import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
10
- import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
10
+ import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
11
+ import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
12
+ import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
13
+ import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
14
+ import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
15
+ import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
11
16
  import { checkPolicy, parseList, policyParts } from "./policy.js";
12
17
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
13
18
  import { spendCsv } from "./spend-ui.js";
@@ -71,6 +76,7 @@ import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./
71
76
  */
72
77
  import { homedir, hostname, tmpdir } from "node:os";
73
78
  import { basename, dirname, join, resolve } from "node:path";
79
+ import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
74
80
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
75
81
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
76
82
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -191,7 +197,7 @@ import { runModelsCommand } from "./models-cli.js";
191
197
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
192
198
  import { beads } from "./beads.js";
193
199
  import { githubIssues } from "./issues.js";
194
- import { installationStatus, renderInstallationStatus, renderTaskWait, taskWaitSnapshot } from "./lead-status.js";
200
+ import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
195
201
  import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
196
202
  import { installMethod } from "./install-method.js";
197
203
  import { PACKAGE_VERSION } from "./version.js";
@@ -225,8 +231,8 @@ export const OPERATE_HELP = `toolroll — operating the queue
225
231
  how many of a project's tasks build at once (default 2, never past the
226
232
  worker's capacity); an approver changes it with <n>
227
233
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
228
- toolroll storage where the disk goes: database, build checkouts, releases, evidence
229
- toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
234
+ toolroll storage where the disk goes: database, build checkouts, releases, evidence, leftover test temp folders
235
+ toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay) and test temp folders older than a day; --yes removes them
230
236
  toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
231
237
  toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
232
238
  toolroll integrations [--json] which integrations work: Connected, Not set up or Broken, and what to do (--saved: no new checks)
@@ -239,9 +245,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
239
245
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
240
246
  toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
241
247
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
242
- toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
248
+ toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
243
249
  toolroll backup now|list back the database up now; list backups and how the last ones went
244
250
  toolroll restore <file> [--dry-run] put a backup back (Toolroll stopped; the current database is kept)
251
+ toolroll review show|on|off --repo <path> one automatic review of each finished build; only HIGH findings send it back
245
252
  toolroll policy show|set the organisation policy; an instance operator sets it with --providers claude,codex|any,
246
253
  --models <m,…>|any, --tools <t,…>|any, --ceiling safe|standard|escalated
247
254
  toolroll ledger verify check the action ledger's hash chain (--checkpoint <n:hash> to compare a copied head)
@@ -804,6 +811,7 @@ export async function runOperate(command, argv, write, options = {}) {
804
811
  // it matters.
805
812
  evidenceRoot: join(dirname(file), "evidence"),
806
813
  databaseFile: file,
814
+ ...(options.tempRoots === undefined ? {} : { tempRoots: options.tempRoots }),
807
815
  // The bot token's file home. Never a column; see telegram.ts.
808
816
  telegramTokenFile: join(dirname(file), "telegram-token"),
809
817
  ...(options.desktopIdentity === undefined ? {} : { desktopIdentity: options.desktopIdentity }),
@@ -932,6 +940,8 @@ async function dispatch(command, positional, flags, context) {
932
940
  return storageCommand(positional, flags, context);
933
941
  case "integrations":
934
942
  return integrationsCommand(positional, flags, context);
943
+ case "review":
944
+ return reviewSwitchCommand(positional, flags, context);
935
945
  case "monitoring": {
936
946
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
937
947
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -992,6 +1002,8 @@ async function dispatch(command, positional, flags, context) {
992
1002
  return tickCommand(flags, context);
993
1003
  case "reconcile":
994
1004
  return reconcileCommand(flags, context);
1005
+ case "run":
1006
+ return runCommand(positional, flags, context);
995
1007
  case "cap":
996
1008
  return capCommand(positional, flags, context);
997
1009
  case "gaps":
@@ -4277,19 +4289,20 @@ async function tickCommand(flags, context) {
4277
4289
  broke++;
4278
4290
  }
4279
4291
  }
4280
- // Retire queued model-review work without creating a run or a passing verdict.
4281
- // Reauthenticate inside the write transaction and touch only admitted projects.
4282
- if (!buildsOnly)
4283
- store.transact(() => {
4284
- const current = authenticate(store, runner, token);
4285
- if (!current.ok)
4286
- return;
4287
- for (const request of store.openReviewRequests()) {
4288
- if (request.repo !== null && current.runner.repos.includes(request.repo)) {
4289
- store.consumeReviewRequest(request.id, "model-review-retired", clock());
4290
- }
4291
- }
4292
+ // The one automatic review each finished build gets when its project's
4293
+ // switch is on, before the result is handed on below. Other queued review
4294
+ // asks close unrun. Reauthenticated first; only admitted projects.
4295
+ if (!buildsOnly && authenticate(store, runner, token).ok) {
4296
+ const reviewed = await buildReviewPass(store, {
4297
+ runner, token, repos: auth.runner.repos, clock,
4298
+ root: context.evidenceRoot,
4299
+ ...(text(flags, "incarnation") === undefined ? {} : { watchIncarnation: text(flags, "incarnation") }),
4300
+ ...(context.agentRunner === undefined ? {} : { agent: context.agentRunner }),
4301
+ shouldStop: () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true,
4292
4302
  });
4303
+ for (const one of reviewed)
4304
+ dispatched.push({ id: `review of run ${one.run}`, outcome: one.outcome === "not-reviewed" ? "not-reviewed" : "reviewed", reason: one.detail });
4305
+ }
4293
4306
  // Existing worker pass owns durable lead handoffs; reads never create events.
4294
4307
  if (!buildsOnly)
4295
4308
  syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
@@ -4327,7 +4340,7 @@ async function tickCommand(flags, context) {
4327
4340
  routines,
4328
4341
  });
4329
4342
  }
4330
- if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held")) {
4343
+ if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held" || one.outcome === "reviewed" || one.outcome === "not-reviewed")) {
4331
4344
  return succeed(write, json, "tick", { considered, dispatched, routines, ...flows }, summary);
4332
4345
  }
4333
4346
  if (considered === 0) {
@@ -4389,7 +4402,21 @@ async function reconcileCommand(flags, context) {
4389
4402
  }
4390
4403
  }
4391
4404
  const recovered = recoverDead(store, clock());
4405
+ // A run that ended, whatever its outcome, leaves nothing running: what is still in its process groups is stopped
4406
+ // (SIGTERM, then SIGKILL after 10 s) and the ledger names it. Never the live service.
4407
+ const closedRuns = await closeOutRuns(store, clock, { repo });
4408
+ // A finished run's processes that have since gone get their exits recorded,
4409
+ // so its task stops waiting on a person by itself.
4410
+ store.recordFinishedRunExits(clock());
4411
+ // A witness reserved for a spawn that never made a process (a crash or a
4412
+ // thrown spawn) settles once its run's process groups are proven gone.
4413
+ store.settleUnspawnedWitnesses(clock());
4392
4414
  store.settleQuiescentStops(clock());
4415
+ // An update whose updater ended while letting work finish must not leave new work paused.
4416
+ try {
4417
+ releaseStalledUpdate(dirname(context.databaseFile), clock());
4418
+ }
4419
+ catch { /* status and the console say it */ }
4393
4420
  for (const one of recovered) {
4394
4421
  for (const leaseId of one.claims) {
4395
4422
  // Lease ids are unique forever, so each recovery is its own episode.
@@ -4441,7 +4468,11 @@ async function reconcileCommand(flags, context) {
4441
4468
  }
4442
4469
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
4443
4470
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
4471
+ // Completing or cancelling a task stops anything still running from its checkout first.
4472
+ const closedCheckouts = await closeOutCheckouts(store, clock, finishedCheckouts(store, clock(), repo));
4444
4473
  const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
4474
+ // What stays for review or for its changes drops its dependencies and build output; the next run's setup restores them.
4475
+ const slimmed = await slimKeptCheckouts(store, worktrees, clock, { actor: "worker", repo });
4445
4476
  // Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
4446
4477
  let retention = null;
4447
4478
  try {
@@ -4457,6 +4488,9 @@ async function reconcileCommand(flags, context) {
4457
4488
  adoption.adopted.length === 0 &&
4458
4489
  adoption.forgotten.length === 0 &&
4459
4490
  pruned.removed.length === 0 &&
4491
+ closedRuns.length === 0 &&
4492
+ closedCheckouts.length === 0 &&
4493
+ slimmed.slimmed.length === 0 &&
4460
4494
  liveSwept.removed.length === 0;
4461
4495
  return succeed(write, json, "reconcile", {
4462
4496
  recovered,
@@ -4465,6 +4499,8 @@ async function reconcileCommand(flags, context) {
4465
4499
  forgotten: adoption.forgotten,
4466
4500
  checkoutsRemoved: pruned.removed.map(one => one.path),
4467
4501
  checkoutsKept: pruned.kept,
4502
+ processesStopped: [...closedRuns.map(one => ({ run: one.runId, pids: one.done.stopped.map(row => row.pid) })), ...closedCheckouts.map(one => ({ checkout: one.path, pids: one.done.stopped.map(row => row.pid) }))],
4503
+ checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
4468
4504
  liveViewsSwept: liveSwept.removed.length,
4469
4505
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
4470
4506
  }, () => nothing
@@ -4475,6 +4511,9 @@ async function reconcileCommand(flags, context) {
4475
4511
  ...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
4476
4512
  ...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
4477
4513
  ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
4514
+ ...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
4515
+ ...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
4516
+ ...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
4478
4517
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4479
4518
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4480
4519
  ]);
@@ -5045,13 +5084,18 @@ async function planTaskCommand(positional, flags, context) {
5045
5084
  }
5046
5085
  /** The retired command is a typed refusal, never a new provider request. */
5047
5086
  async function reviewTaskCommand(positional, flags, context) {
5048
- const { store, write, json, clock } = context;
5087
+ const { store, write, json } = context;
5049
5088
  const [runText] = positional;
5050
5089
  const runId = Number(runText ?? "");
5051
5090
  if (runText === undefined || !Number.isInteger(runId) || runId < 1) {
5052
5091
  return fail(write, json, "task review", "usage", "`toolroll task review <run-id>`", EXIT.usage);
5053
5092
  }
5054
- return fail(write, json, "task review", "model-review-retired", `Separate model review has been removed. Inspect run ${runId}'s saved work and checks, then give feedback or request a revision.`, EXIT.refused);
5093
+ // Read-only: the run's one automatic review, when it had one.
5094
+ const review = buildReviewOf(store, runId);
5095
+ if (review !== null) {
5096
+ return succeed(write, json, "task review", { run: runId, review }, () => [`Run ${runId}`, ...buildReviewLines(review)]);
5097
+ }
5098
+ return fail(write, json, "task review", "model-review-retired", `Run ${runId} has no automatic review. Manual review requests have been removed; a project's builds are reviewed automatically when its switch is on (\`toolroll review on --repo <path>\`). Inspect the saved work and checks, then give feedback or request a revision.`, EXIT.refused);
5055
5099
  }
5056
5100
  /**
5057
5101
  * `toolroll incident list|resolve <id>` — the parks that never became
@@ -7243,6 +7287,8 @@ async function daemonCommand(positional, flags, context) {
7243
7287
  // ---- the watch loop --------------------------------------------------------
7244
7288
  const WATCH_LEASE_MS = 90_000;
7245
7289
  const WATCH_HEARTBEAT_MS = 30_000;
7290
+ /** The start repair runs once per process, not once per project loop. */
7291
+ let staleStatusesRepaired = false;
7246
7292
  async function runWatchLoop(args) {
7247
7293
  const { context, flags, runner, token, repo, progress } = args;
7248
7294
  const { store } = context;
@@ -7280,6 +7326,14 @@ async function runWatchLoop(args) {
7280
7326
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7281
7327
  recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7282
7328
  args.onReady?.();
7329
+ // Once per worker start, before any pass: statuses an older build left
7330
+ // stale (unrecorded exits of finished runs) are re-derived and clear.
7331
+ if (!staleStatusesRepaired) {
7332
+ staleStatusesRepaired = true;
7333
+ const repaired = repairStaleStatuses(store, new Date());
7334
+ if (repaired.exitsRecorded > 0)
7335
+ progress(`watch: recorded ${repaired.exitsRecorded} finished process exit(s) left open by an earlier version`);
7336
+ }
7283
7337
  // A false renewal is FATAL (arc 2 finding 15): the lease or the
7284
7338
  // credential is gone, and admitting one more pass would be work done for
7285
7339
  // an authority this process no longer holds.
@@ -7776,7 +7830,8 @@ async function watchCommand(flags, context) {
7776
7830
  token,
7777
7831
  repo,
7778
7832
  progress,
7779
- isStopping: () => stopping,
7833
+ // An injected fence (tests) stops admission exactly as a signal does.
7834
+ isStopping: () => stopping || context.shouldStop?.() === true,
7780
7835
  onFollowController: controller => {
7781
7836
  followAbort = controller;
7782
7837
  },
@@ -8820,6 +8875,10 @@ async function bridgeCommand(positional, flags, context) {
8820
8875
  const runFor = text(flags, "for");
8821
8876
  const timer = runFor === undefined ? null : setTimeout(() => controller.abort(), Number(runFor));
8822
8877
  timer?.unref?.();
8878
+ // An injected fence (tests) ends the follow exactly as a signal does.
8879
+ const fence = context.shouldStop === undefined ? null : setInterval(() => { if (context.shouldStop?.() === true)
8880
+ controller.abort(); }, 50);
8881
+ fence?.unref?.();
8823
8882
  if (!json)
8824
8883
  write(`Following bot ${source.botId} — taps apply as they arrive. Ctrl-C stops it.`);
8825
8884
  try {
@@ -8849,6 +8908,8 @@ async function bridgeCommand(positional, flags, context) {
8849
8908
  finally {
8850
8909
  if (timer !== null)
8851
8910
  clearTimeout(timer);
8911
+ if (fence !== null)
8912
+ clearInterval(fence);
8852
8913
  process.removeListener("SIGINT", stop);
8853
8914
  process.removeListener("SIGTERM", stop);
8854
8915
  }
@@ -9512,10 +9573,13 @@ async function statusCommand(positional, flags, context) {
9512
9573
  const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9513
9574
  const brokenLine = integrationsBrokenLine(brokenIntegrations);
9514
9575
  const integrations = brokenIntegrations.length === 0 ? {} : { integrations: { broken: brokenIntegrations.map(one => ({ key: one.key, name: one.name, fix: one.action.kind === "fix" ? one.action.words : null })) } };
9576
+ // An update waiting, on what, and the action: before anything else, as it holds up new work.
9577
+ const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
9578
+ const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
9515
9579
  const projects = projectBuildsStatus(context);
9516
9580
  const projectsLine = projects.length === 0 ? null
9517
9581
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9518
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9582
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting }, () => [...(waiting === null ? [] : [waiting.words]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9519
9583
  }
9520
9584
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9521
9585
  function projectBuildsStatus(context) {
@@ -10147,6 +10211,9 @@ function showTask(positional, context) {
10147
10211
  // v50: the latest build's bounded review history — every root attempt
10148
10212
  // in order, the open request, and the one state they add up to.
10149
10213
  review: latestFinished === null ? null : store.reviewRetryStateOf(latestFinished.id),
10214
+ // The one automatic review of the latest build: HIGH findings, suggested
10215
+ // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10216
+ automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
10150
10217
  dispatch: diagnoseTaskDispatch(store, id, now),
10151
10218
  // v52: the exact-run control the console shows — Stop, Stopping,
10152
10219
  // Paused (resume), or the review-retry door — and every stop on record.
@@ -10199,7 +10266,7 @@ function showTask(positional, context) {
10199
10266
  : detail.control.kind === "paused"
10200
10267
  ? [` control: paused — run #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} (${detail.control.stop.settlement ?? "?"})${detail.control.committed ? "; its commit is on the branch" : ""}; work preserved${detail.control.worktree === null ? "" : ` in ${detail.control.worktree}`} — \`task resume ${task.id} --run ${detail.control.run} --as <you> --token <t>\``]
10201
10268
  : [` control: review #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} — inspect its saved result and give feedback; separate model review has been removed`]),
10202
- ...reviewStatusLines(detail.review, latestFinished?.id ?? null),
10269
+ ...(detail.automaticReview === null ? reviewStatusLines(detail.review, latestFinished?.id ?? null) : buildReviewLines(detail.automaticReview)),
10203
10270
  ...(scope === null
10204
10271
  ? [" no scope — nothing will build this until one is written and approved"]
10205
10272
  : describeScope(scope, readiness)),
@@ -11662,6 +11729,48 @@ async function resumeTaskCommand(positional, flags, context) {
11662
11729
  : ` Not starting yet — ${resumed.gate.summary}: ${resumed.gate.detail}`,
11663
11730
  ]);
11664
11731
  }
11732
+ /**
11733
+ * `toolroll run settle <id> --why "<text>" --as <you> --token <t>`: an
11734
+ * approver's last resort for a finished run whose process witnesses can be
11735
+ * proven neither exited nor alive (a spawn interrupted before its pid was
11736
+ * written). Refuses while anything of the run is alive; the reason is kept
11737
+ * in the ledger against the approver's name. Signals nothing.
11738
+ */
11739
+ async function runCommand(positional, flags, context) {
11740
+ const { store, write, json, clock } = context;
11741
+ const [action, id, ...extra] = positional;
11742
+ const command = "run settle";
11743
+ const usage = "`toolroll run settle <run-id> --why \"<reason>\" --as <you> --token <t>`";
11744
+ if (action !== "settle")
11745
+ return fail(write, json, "run", "usage", usage, EXIT.usage);
11746
+ const runId = Number(id ?? "");
11747
+ const why = text(flags, "why")?.trim();
11748
+ if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
11749
+ return fail(write, json, command, "usage", usage, EXIT.usage);
11750
+ if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why)) {
11751
+ return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
11752
+ }
11753
+ const run = store.getRun(runId);
11754
+ if (run === null)
11755
+ return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
11756
+ const acting = await askCredentials(flags, context);
11757
+ if (acting === null) {
11758
+ return fail(write, json, command, "usage", "settling takes `--as <you> --token <t>` — it is an approver's act and is recorded against your name", EXIT.usage);
11759
+ }
11760
+ const authenticated = authenticateApprover(store, acting.name, acting.token);
11761
+ const repo = store.refById(run.taskRef)?.repo ?? null;
11762
+ if (!authenticated.ok || !store.accountCanAccess(acting.name, repo)) {
11763
+ return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
11764
+ }
11765
+ const settled = store.settleRunWitnessesByApprover({ runId, by: acting.name, why }, clock());
11766
+ if (!settled.ok)
11767
+ return fail(write, json, command, settled.reason, settled.detail, EXIT.refused, { run: runId });
11768
+ return succeed(write, json, command, { run: runId, witnesses: settled.witnesses, repeated: settled.repeated }, () => [
11769
+ settled.repeated
11770
+ ? `Run #${runId} has no unsettled process witness; nothing changed.`
11771
+ : `Settled run #${runId}: ${settled.witnesses.length} process witness${settled.witnesses.length === 1 ? "" : "es"} recorded as ended, with your reason in the ledger.`,
11772
+ ]);
11773
+ }
11665
11774
  // ---- shared ---------------------------------------------------------------
11666
11775
  /**
11667
11776
  * `project rules --repo <path>` shows a project's approval rules (v102);
@@ -12026,9 +12135,11 @@ async function retentionCommand(positional, flags, context) {
12026
12135
  const label = (kind) => RETENTION_KINDS.find(one => one.kind === kind).label;
12027
12136
  if (action === undefined || action === "show") {
12028
12137
  const periods = store.retentionPeriods();
12138
+ const chosen = store.retentionChosen();
12139
+ const defaulted = RETENTION_KINDS.map(one => one.kind).filter(kind => !(kind in chosen));
12029
12140
  const last = lastSweepAt(store);
12030
- return succeed(context.write, context.json, command, { periods, lastSweep: last }, () => [
12031
- ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodWords(periods[one.kind]).padEnd(9)} ${one.label}`),
12141
+ return succeed(context.write, context.json, command, { periods, defaulted, lastSweep: last }, () => [
12142
+ ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodLabel(one.kind, periods[one.kind], one.kind in chosen).toLowerCase().padEnd(18)} ${one.label}`),
12032
12143
  last === null ? "No sweep yet; the worker runs one a day." : `Last sweep ${last.slice(0, 16).replace("T", " ")} UTC.`,
12033
12144
  ]);
12034
12145
  }
@@ -12044,11 +12155,11 @@ async function retentionCommand(positional, flags, context) {
12044
12155
  return fail(context.write, context.json, command, "usage", "Use retention show, retention preview or retention set <kind> <period>.", EXIT.usage);
12045
12156
  const [kind, period, ...extra] = rest;
12046
12157
  if (kind === undefined || !isRetentionKind(kind) || period === undefined || extra.length > 0) {
12047
- return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 90d or 1y, or forever>.`, EXIT.usage);
12158
+ return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 1d, 90d or 1y, or forever>.`, EXIT.usage);
12048
12159
  }
12049
12160
  const days = parsePeriod(period);
12050
12161
  if (days === undefined)
12051
- return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} days to 10 years (90d, 12w, 1y).`, EXIT.usage);
12162
+ return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} day to 10 years (1d, 90d, 12w, 1y).`, EXIT.usage);
12052
12163
  const acting = await askCredentials(flags, context);
12053
12164
  const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
12054
12165
  if (acting === null || verified === null || !verified.ok || !store.isInstanceOperator(acting.name)) {
@@ -12082,13 +12193,35 @@ async function storageCommand(positional, flags, context) {
12082
12193
  `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
12083
12194
  ];
12084
12195
  const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
12196
+ // Test temp folders the tests, journeys and scripts left (a killed run, an older version): those nothing touched for a day go.
12197
+ const roots = context.tempRoots ?? tempRoots();
12198
+ // Counting is quick; sizing thousands of folders isn't, so only a clean-up measures them.
12199
+ const leftovers = (measure) => {
12200
+ const found = testTempFolders(roots, context.clock());
12201
+ const sizes = measure ? diskBytes(found.stale.map(one => one.path)) : new Map();
12202
+ return { count: found.all.length, stale: found.stale.length, staleBytes: [...sizes.values()].reduce((sum, one) => sum + one, 0) };
12203
+ };
12204
+ const leftoverLine = (temp) => temp.count === 0 ? "Test temp folders: none." :
12205
+ `Test temp folders: ${temp.count}; ${temp.stale === 0 ? "none older than a day." : `${temp.stale} older than a day: toolroll storage clean removes them.`}`;
12206
+ // Shared dependencies (shared-deps.ts): one install per lockfile, linked into checkouts.
12207
+ const depsRoot = sharedDepsRoot(context.databaseFile);
12208
+ const checkoutRows = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
12209
+ const sharedFacts = (copies) => {
12210
+ const sizes = diskBytes(copies.map(one => one.dir));
12211
+ return copies.map(one => ({ key: one.key, repo: one.repo, path: one.dir, bytes: sizes.get(one.dir) ?? 0, checkouts: one.checkouts.length, usedBy: one.checkouts, retired: one.retired, node: one.node, createdAt: one.createdAt, usedAt: one.usedAt }));
12212
+ };
12213
+ const sharedLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${projectName(one.repo)} ${one.key.slice(0, 8)}: ${one.checkouts === 1 ? "1 checkout uses it" : `${one.checkouts} checkouts use it`}${one.retired ? " (changed after install; never linked again)" : ""}`;
12085
12214
  if (action === undefined) {
12086
12215
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12087
12216
  const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
12088
- return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
12217
+ const { staleBytes: _, ...temp } = leftovers(false);
12218
+ const shared = sharedFacts(sharedUse(depsRoot, checkoutRows()));
12219
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared }, () => [
12089
12220
  `${report.folder}: ${bytesWords(report.total)}`,
12090
- ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12221
+ ...report.lines.map(one => ` ${one.what.padEnd(19)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12222
+ ...(shared.length === 0 ? [] : ["Shared dependencies:", ...shared.map(sharedLine)]),
12091
12223
  ...summary(plan),
12224
+ leftoverLine({ ...temp, staleBytes: 0 }),
12092
12225
  ]);
12093
12226
  }
12094
12227
  if (action === "cleanup") {
@@ -12110,20 +12243,48 @@ async function storageCommand(positional, flags, context) {
12110
12243
  return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
12111
12244
  if (!flags.has("yes")) {
12112
12245
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12113
- return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
12114
- ...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
12246
+ const temp = leftovers(true);
12247
+ // A shared copy goes when no checkout uses it, counting the checkouts this clean-up would remove.
12248
+ const leaving = new Set(plan.go.map(one => one.path));
12249
+ const unused = unusedShared(depsRoot, checkoutRows().filter(row => !leaving.has(row.path)), context.clock());
12250
+ const sharedGo = sharedFacts(unused.copies);
12251
+ const sharedBytes = sharedGo.reduce((sum, one) => sum + one.bytes, 0);
12252
+ const anything = plan.go.length > 0 || temp.stale > 0 || sharedGo.length > 0;
12253
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes, testTemp: temp, sharedGo, sharedFreeBytes: sharedBytes }, () => [
12254
+ ...(plan.go.length === 0 ? [anything ? "No checkouts to clean up." : "Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
12255
+ ...(sharedGo.length === 0 ? [] : [`Would remove ${sharedGo.length} shared dependency install${sharedGo.length === 1 ? "" : "s"} no checkout uses, about ${bytesWords(sharedBytes)}:`, ...sharedGo.map(sharedLine)]),
12115
12256
  ...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
12116
- plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
12257
+ ...(temp.stale === 0 ? [] : [`Would remove ${temp.stale} test temp folder${temp.stale === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12258
+ anything ? "Nothing was removed. Run toolroll storage clean --yes to remove them." : "Nothing was removed.",
12117
12259
  ]);
12118
12260
  }
12119
12261
  const actor = await operator();
12120
12262
  if (actor === null)
12121
12263
  return refused("cleans up checkouts");
12122
12264
  const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
12123
- return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
12124
- done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
12265
+ const temp = leftovers(true);
12266
+ const swept = removeStaleTestTemp(roots, context.clock());
12267
+ if (swept.removed.length > 0) {
12268
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: null, taskId: null, runId: null, action: "test temp folders removed", outcome: "removed", source: "request",
12269
+ detail: `${swept.removed.length} older than a day, about ${bytesWords(temp.staleBytes)}${swept.failed.length === 0 ? "" : `; ${swept.failed.length} couldn't be removed`}` });
12270
+ }
12271
+ // After the checkouts go: a shared copy none of the remaining ones uses goes too.
12272
+ const unused = unusedShared(depsRoot, checkoutRows(), context.clock());
12273
+ const sharedRemoved = sharedFacts(unused.copies).filter(one => removeShared(one.path));
12274
+ for (const one of sharedRemoved) {
12275
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: one.repo, taskId: null, runId: null, action: "shared dependencies removed", outcome: "removed", source: "request",
12276
+ detail: `${one.key} (about ${bytesWords(one.bytes)}); no checkout used it` });
12277
+ }
12278
+ for (const debris of unused.debris)
12279
+ removeShared(debris);
12280
+ const sharedFreed = sharedRemoved.reduce((sum, one) => sum + one.bytes, 0);
12281
+ return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed, testTempRemoved: swept.removed.length, testTempFailed: swept.failed, sharedRemoved, sharedFreedBytes: sharedFreed }, () => [
12282
+ done.removed.length === 0 ? (swept.removed.length === 0 && sharedRemoved.length === 0 ? "Nothing to clean up." : "No checkouts to clean up.") : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
12125
12283
  ...done.removed.map(itemLine),
12284
+ ...(sharedRemoved.length === 0 ? [] : [`Removed ${sharedRemoved.length} shared dependency install${sharedRemoved.length === 1 ? "" : "s"} no checkout used, about ${bytesWords(sharedFreed)}.`, ...sharedRemoved.map(sharedLine)]),
12126
12285
  ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
12286
+ ...(swept.removed.length === 0 ? [] : [`Removed ${swept.removed.length} test temp folder${swept.removed.length === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12287
+ ...(swept.failed.length === 0 ? [] : [`Couldn't remove ${swept.failed.length} test temp folder${swept.failed.length === 1 ? "" : "s"}: ${swept.failed.slice(0, 3).join(", ")}${swept.failed.length > 3 ? " …" : ""}`]),
12127
12288
  ]);
12128
12289
  }
12129
12290
  if (action === "discard") {
@@ -12148,6 +12309,40 @@ async function storageCommand(positional, flags, context) {
12148
12309
  function checkoutSummary(plan) {
12149
12310
  return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
12150
12311
  }
12312
+ /** `review [show] | on | off --repo <path>`: the project's one automatic review
12313
+ * of finished builds. Changing it takes an approver of that project; the
12314
+ * policy log keeps before → after. */
12315
+ async function reviewSwitchCommand(positional, flags, context) {
12316
+ const { store, write, json } = context;
12317
+ const [action = "show", ...extra] = positional;
12318
+ const command = `review ${action}`;
12319
+ if (extra.length > 0 || !["show", "on", "off"].includes(action))
12320
+ return fail(write, json, command, "usage", "Use toolroll review show|on|off --repo <path>.", EXIT.usage);
12321
+ const allowed = new Set(["repo", "db", "json", ...(action === "show" ? [] : ["as", "token", "token-file", "token-env"])]);
12322
+ for (const name of flags.keys())
12323
+ if (!allowed.has(name))
12324
+ return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
12325
+ const given = text(flags, "repo");
12326
+ if (given === undefined)
12327
+ return fail(write, json, command, "usage", "Name the project: toolroll review " + action + " --repo <path>.", EXIT.usage);
12328
+ const repo = canonicalProject(given) ?? resolve(given);
12329
+ const words = (on, source) => `${projectName(repo)}: automatic review is ${on ? "on" : "off"}${source === "hands-off" ? " (on while the hands-off mode lasts)" : source === "default" ? " (the default)" : ""}.`;
12330
+ if (action === "show") {
12331
+ const state = store.reviewSwitch(repo, context.clock());
12332
+ return succeed(write, json, command, { repo, ...state }, () => [words(state.on, state.source)]);
12333
+ }
12334
+ const acting = await askCredentials(flags, context);
12335
+ if (acting === null)
12336
+ return fail(write, json, command, "usage", `Sign in first, or pass --as <you> --token <t>: toolroll review ${action} --repo <path>.`, EXIT.usage);
12337
+ const verified = authenticateApprover(store, acting.name, acting.token, repo);
12338
+ if (!verified.ok)
12339
+ return fail(write, json, command, verified.reason, `Only an approver of ${projectName(repo)} can change its review switch.`, EXIT.refused);
12340
+ const changed = store.setReviewSwitch(repo, action === "on", acting.name, context.clock());
12341
+ return succeed(write, json, command, { repo, ...changed.after, before: changed.before }, () => [
12342
+ words(changed.after.on, changed.after.source),
12343
+ changed.after.on ? "Each finished build whose check passes gets one read-only review; only HIGH findings send it back, once." : "Finished builds go straight to you.",
12344
+ ]);
12345
+ }
12151
12346
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
12152
12347
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
12153
12348
  async function policyCommand(positional, flags, context) {
@@ -148,6 +148,10 @@ export function reviewPlane(store, now, canSee = () => true) {
148
148
  for (const row of tasks) {
149
149
  if (!canSee(str(row["repo"])))
150
150
  continue;
151
+ // A version a later revision replaced never waits on its own.
152
+ const family = store.taskFamilyOf(String(row["id"]), null, true);
153
+ if (family !== null && family.current.id !== String(row["id"]))
154
+ continue;
151
155
  const snapshot = taskWaitSnapshot(store, String(row["id"]), now);
152
156
  if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
153
157
  continue;
@@ -69,6 +69,14 @@ export function witnessedRunner(store, runId, clock, runner) {
69
69
  store.recordRunProcess(runId, pid, clock(), options.processGroup === true, witnesses.at(-1));
70
70
  options.onSpawn?.(pid);
71
71
  },
72
+ // The spawn made no process (ENOENT, EAGAIN, a throw): its reservation
73
+ // settles as never started now, not only if the transport returns.
74
+ onSpawnFailed: () => {
75
+ const witness = witnesses.at(-1);
76
+ if (witness !== undefined)
77
+ store.finishUnspawnedProcess(witness, clock());
78
+ options.onSpawnFailed?.();
79
+ },
72
80
  onContainer: info => {
73
81
  native = true;
74
82
  const witness = witnesses.at(-1);
@@ -95,6 +95,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
95
95
  readonly items: readonly string[];
96
96
  readonly gaps: readonly string[];
97
97
  readonly priorSupport: "eligible" | "invalid" | "none";
98
+ readonly assets?: string;
98
99
  };
99
100
  }[];
100
101
  readonly machineVerdict: import("./store.js").ProofVerdictRow["verdict"] | null;
@@ -48,9 +48,9 @@ export type LearningEvent = {
48
48
  };
49
49
  export declare const learningSha: (text: string) => string;
50
50
  /** Path plus the physical shared Git database: replacing a repository does not inherit its lessons. */
51
- export declare function learningIdentity(repo: string): string;
51
+ export declare function learningIdentity(repo: string, seen?: Map<string, string>): string;
52
52
  /** Compare the applicable code and configuration, not model confidence or a claimed passing command. */
53
- export declare function learningFingerprint(repo: string, head: string, paths: readonly string[]): string;
53
+ export declare function learningFingerprint(repo: string, head: string, paths: readonly string[], seen?: Map<string, string>): string;
54
54
  export declare function parseLearning(value: unknown): LearningCandidate[];
55
55
  /** A write problem is optional: preserve core completion, leave a visible diagnostic, retry on the next read/admission. */
56
56
  export declare function learningFailure(store: Store, repo: string, run: number | null, now: Date): void;
@@ -60,7 +60,7 @@ export declare function queueLearning(store: Store, source: number, reviewer: nu
60
60
  sha256: string;
61
61
  }[], now: Date, assessment?: unknown): void;
62
62
  /** Validate after core commit; both suggestions commit or neither. */
63
- export declare function recoverLearning(store: Store, root: string, repo: string, now?: Date): void;
63
+ export declare function recoverLearning(store: Store, root: string, repo: string, now?: Date, seen?: Map<string, string>): void;
64
64
  export declare function learningView(store: Store, root: string, repo: string, actor: string, before?: number): {
65
65
  repo: string;
66
66
  identity: string;