toolroll 0.9.31 → 0.9.33

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 (68) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/coding-context.js +2 -2
  16. package/dist/coding-handoff.js +2 -2
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/exec.d.ts +2 -0
  19. package/dist/exec.js +11 -0
  20. package/dist/guides.js +3 -0
  21. package/dist/knowledge-cli.d.ts +31 -0
  22. package/dist/knowledge-cli.js +35 -2
  23. package/dist/knowledge-ui.d.ts +1 -1
  24. package/dist/knowledge-ui.js +7 -0
  25. package/dist/lead-capabilities.d.ts +0 -7
  26. package/dist/lead-capabilities.js +11 -1
  27. package/dist/mate-doors.js +2 -2
  28. package/dist/mate.d.ts +6 -2
  29. package/dist/mate.js +63 -38
  30. package/dist/never-index.d.ts +3 -0
  31. package/dist/never-index.js +21 -0
  32. package/dist/operate.js +99 -5
  33. package/dist/orphan-sweep.d.ts +63 -0
  34. package/dist/orphan-sweep.js +235 -0
  35. package/dist/project-delete.js +1 -1
  36. package/dist/project-knowledge.d.ts +28 -3
  37. package/dist/project-knowledge.js +64 -25
  38. package/dist/project-learning.d.ts +33 -0
  39. package/dist/project-learning.js +105 -15
  40. package/dist/project-skills.js +27 -21
  41. package/dist/result-follow-ups.d.ts +22 -2
  42. package/dist/result-follow-ups.js +44 -10
  43. package/dist/serve.js +33 -11
  44. package/dist/shared-deps.d.ts +18 -2
  45. package/dist/shared-deps.js +129 -15
  46. package/dist/staged-runtimes.d.ts +42 -0
  47. package/dist/staged-runtimes.js +191 -0
  48. package/dist/storage-sweep.d.ts +50 -0
  49. package/dist/storage-sweep.js +203 -0
  50. package/dist/storage-ui.d.ts +5 -0
  51. package/dist/storage-ui.js +30 -4
  52. package/dist/storage.d.ts +2 -0
  53. package/dist/storage.js +15 -3
  54. package/dist/store.d.ts +17 -2
  55. package/dist/store.js +37 -5
  56. package/dist/subscription-chat.d.ts +3 -1
  57. package/dist/subscription-chat.js +1 -0
  58. package/dist/surface.js +2 -1
  59. package/dist/task-status.d.ts +13 -0
  60. package/dist/task-status.js +37 -3
  61. package/dist/team-leads.js +6 -2
  62. package/dist/team-runtime.js +2 -1
  63. package/dist/test-temp.d.ts +20 -2
  64. package/dist/test-temp.js +53 -13
  65. package/dist/toolroll-update.js +2 -0
  66. package/dist/workspace-ui.js +3 -1
  67. package/dist/worktree.js +9 -0
  68. package/package.json +1 -1
package/dist/mate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { mateToolLabel } from "./mate-progress.js";
3
3
  import { createHash } from "node:crypto";
4
+ import { mateTimeoutNotice } from "./store.js";
4
5
  import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
5
6
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
6
7
  import { MATE_MAX_CALLS_PER_STEP, MATE_MAX_STEPS, MATE_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeMateRequest, credentialKeyOf, isDirectChatProvider, mateWorstCaseForPrice, performMateRequest, priceForConfig, settleForPrice, subscriptionCredentialKey, } from "./converse.js";
@@ -56,7 +57,7 @@ export const MATE_CHANNEL_COPY = {
56
57
  export const MATE_FAILURE_COPY = {
57
58
  stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.",
58
59
  ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.",
59
- tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.",
60
+ tooLong: mateTimeoutNotice(false),
60
61
  secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.",
61
62
  secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.",
62
63
  paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.",
@@ -65,7 +66,7 @@ export const MATE_FAILURE_COPY = {
65
66
  unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.",
66
67
  signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.",
67
68
  refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.",
68
- tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
69
+ tooLongUnknownCost: mateTimeoutNotice(false, true),
69
70
  lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
70
71
  unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
71
72
  empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
@@ -74,7 +75,8 @@ export const MATE_FAILURE_COPY = {
74
75
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
75
76
  export function mateFailureText(reason) {
76
77
  switch (reason) {
77
- case "timeout": return MATE_FAILURE_COPY.tooLong;
78
+ case "timeout":
79
+ case "crashed": return MATE_FAILURE_COPY.tooLong;
78
80
  case "malformed-reply": return MATE_FAILURE_COPY.unreadable;
79
81
  case "secret-refused": return MATE_FAILURE_COPY.secretReply;
80
82
  case "latched": return MATE_FAILURE_COPY.paused;
@@ -84,6 +86,16 @@ export function mateFailureText(reason) {
84
86
  default: return "The lead's reply didn't finish. Nothing it proposed was kept. Send your message again.";
85
87
  }
86
88
  }
89
+ /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
90
+ * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
91
+ export const MATE_ABORT_GRACE_MS = 5_000;
92
+ const LATE = Symbol("late");
93
+ /** The work's own result, or LATE once `ms` passes: the turn's clock, not the work, decides when the person hears back. */
94
+ function settleBy(work, ms) {
95
+ let timer;
96
+ const late = new Promise(resolve => { timer = setTimeout(() => resolve(LATE), Math.max(0, ms)); });
97
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
98
+ }
87
99
  const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
88
100
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
89
101
  export function historyFor(store, thread, queuedMessageId) {
@@ -291,6 +303,20 @@ export async function runMateTurn(input) {
291
303
  store.dropMateAsk(turnId);
292
304
  return { ok: false, turn: turnId, failed, message, unknownSpend };
293
305
  };
306
+ /** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
307
+ * the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
308
+ const timedOut = (unknownSpend) => {
309
+ now = clock();
310
+ const message = mateTimeoutNotice(proposals > 0, unknownSpend);
311
+ const saved = store.finalizeMateTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
312
+ store.dropMateAsk(turnId);
313
+ if (!saved)
314
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
315
+ return { ok: false, turn: turnId, failed: "timeout", message, unknownSpend, saved: true };
316
+ };
317
+ const deadlineLeft = () => TURN_WALL_CLOCK_MS - (clock().getTime() - turnStartedAt);
318
+ /** The channel's standing, bounded by the turn's deadline: a lookup that never answers cannot hold the turn open. */
319
+ const revalidated = async () => input.revalidate === undefined ? { ok: true } : settleBy(input.revalidate(), deadlineLeft() + MATE_ABORT_GRACE_MS);
294
320
  /** The turn's own row, re-read: still running under our generation, or someone ended it under us. */
295
321
  const stillOurs = () => {
296
322
  const row = store.getMateTurn(turnId);
@@ -301,6 +327,8 @@ export async function runMateTurn(input) {
301
327
  const guard = (channel) => {
302
328
  if (!stillOurs())
303
329
  return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
330
+ if (channel === LATE)
331
+ return timedOut(false);
304
332
  if (!channel.ok)
305
333
  return fail("revoked", MATE_CHANNEL_COPY[channel.reason] ?? MATE_CHANNEL_COPY["access-changed"], false);
306
334
  const standing = reproveApprover(store, who);
@@ -328,13 +356,13 @@ export async function runMateTurn(input) {
328
356
  return claim;
329
357
  };
330
358
  while (steps < maxSteps) {
331
- const blocked = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
359
+ const blocked = guard(await revalidated());
332
360
  if (blocked !== null)
333
361
  return blocked;
334
362
  now = clock();
335
363
  const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
336
364
  if (remainingMs <= 0)
337
- return fail("timeout", MATE_FAILURE_COPY.tooLong, false);
365
+ return timedOut(false);
338
366
  const request = direct ? composeDirect(input.key) : composeSubscription();
339
367
  // Tool results join the outbound body: scanned again before every dispatch.
340
368
  const outbound = typeof request === "string" ? request : request.body;
@@ -354,39 +382,34 @@ export async function runMateTurn(input) {
354
382
  progress({ kind: "step", turn: turnId, step: steps });
355
383
  const requestBytes = Buffer.byteLength(outbound, "utf8");
356
384
  let result;
385
+ // The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
386
+ // harness's whole process group, and a provider that still has not settled MATE_ABORT_GRACE_MS later is left behind.
387
+ // Whatever it says after that is never read; the turn has already ended with its notice.
388
+ const controller = new AbortController();
389
+ const abortAt = setTimeout(() => controller.abort(), remainingMs);
390
+ const settle = (work) => settleBy(work, remainingMs + MATE_ABORT_GRACE_MS)
391
+ .then(value => value === LATE || controller.signal.aborted ? { ok: false, problem: "timeout" } : value)
392
+ .finally(() => clearTimeout(abortAt));
357
393
  if (direct) {
358
- const controller = new AbortController();
359
- const timer = setTimeout(() => controller.abort(), remainingMs);
360
- try {
361
- result = await performMateRequest(request, directProvider, controller.signal, fetcher);
362
- }
363
- catch {
364
- result = { ok: false, problem: "network" };
365
- }
366
- finally {
367
- clearTimeout(timer);
368
- }
394
+ result = await settle(performMateRequest(request, directProvider, controller.signal, fetcher)
395
+ .catch(() => ({ ok: false, problem: "network" })));
369
396
  }
370
397
  else {
371
398
  const runner = input.subscriptionRunner ?? performSubscriptionMateRequest;
372
- try {
373
- result = await runner({
374
- provider: subscriptionProvider,
375
- model: config.model,
376
- system: MATE_CONTRACT,
377
- dataDocument: document,
378
- history,
379
- tools: MATE_TOOL_SCHEMAS,
380
- timeoutMs: remainingMs,
381
- // The reply as it is written; text that looks like a secret is
382
- // never shown (the finished reply is scanned again before saving).
383
- ...(input.onProgress === undefined ? {} : { onText: (text) => { if (scanForSecrets(text).length === 0)
384
- progress({ kind: "text", turn: turnId, step: steps, text }); } }),
385
- });
386
- }
387
- catch {
388
- result = { ok: false, problem: "provider-error" };
389
- }
399
+ result = await settle((async () => runner({
400
+ provider: subscriptionProvider,
401
+ model: config.model,
402
+ system: MATE_CONTRACT,
403
+ dataDocument: document,
404
+ history,
405
+ tools: MATE_TOOL_SCHEMAS,
406
+ timeoutMs: remainingMs,
407
+ signal: controller.signal,
408
+ // The reply as it is written; text that looks like a secret is
409
+ // never shown (the finished reply is scanned again before saving).
410
+ ...(input.onProgress === undefined ? {} : { onText: (text) => { if (!controller.signal.aborted && scanForSecrets(text).length === 0)
411
+ progress({ kind: "text", turn: turnId, step: steps, text }); } }),
412
+ }))().catch(() => ({ ok: false, problem: "provider-error" })));
390
413
  }
391
414
  now = clock();
392
415
  const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
@@ -400,7 +423,9 @@ export async function runMateTurn(input) {
400
423
  : result.problem === "malformed-reply"
401
424
  ? MATE_FAILURE_COPY.unreadable
402
425
  : MATE_FAILURE_COPY.signIn;
403
- return fail(result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
426
+ if (result.problem === "timeout")
427
+ return timedOut(false);
428
+ return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
404
429
  }
405
430
  if (result.problem.startsWith("status-")) {
406
431
  // The provider ANSWERED with an error: nothing billed for this step.
@@ -409,7 +434,7 @@ export async function runMateTurn(input) {
409
434
  }
410
435
  if (result.problem === "timeout") {
411
436
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
412
- return fail("timeout", MATE_FAILURE_COPY.tooLongUnknownCost, true);
437
+ return timedOut(true);
413
438
  }
414
439
  if (result.problem === "network") {
415
440
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
@@ -437,7 +462,7 @@ export async function runMateTurn(input) {
437
462
  // After the wait (finding 5): the row may have been failed under us by
438
463
  // a revocation or the sweep — then nothing the model said runs; and the
439
464
  // approver must still stand before any tool runs as them.
440
- const changed = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
465
+ const changed = guard(await revalidated());
441
466
  if (changed !== null)
442
467
  return changed;
443
468
  if (answer.calls.length === 0) {
@@ -470,7 +495,7 @@ export async function runMateTurn(input) {
470
495
  history.push({ role: "assistant", text: answer.text, calls: answer.calls });
471
496
  for (const [index, call] of answer.calls.entries()) {
472
497
  if (index > 0) {
473
- const changed = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
498
+ const changed = guard(await revalidated());
474
499
  if (changed !== null)
475
500
  return changed;
476
501
  }
@@ -0,0 +1,3 @@
1
+ export declare const NEVER_INDEX = ".metadata_never_index";
2
+ /** Put the marker in `dir` (an existing folder). False when it couldn't be written. */
3
+ export declare function markNeverIndex(dir: string): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Spotlight leaves Toolroll's churn alone: a `.metadata_never_index` file in a folder tells macOS not to index it or
3
+ * what is inside. Toolroll puts one in the folders it fills and empties all day — the checkouts folder and each
4
+ * project's folder in it (never inside a checkout, where a build's `git add -A` would commit it), shared dependency
5
+ * installs, staged runtimes and test temp roots. Measured Oct 4: fseventsd at 15 GB with 107k test temp folders.
6
+ *
7
+ * A marker that can't be written is only noted: it never decides whether anything is removed.
8
+ */
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ export const NEVER_INDEX = ".metadata_never_index";
12
+ /** Put the marker in `dir` (an existing folder). False when it couldn't be written. */
13
+ export function markNeverIndex(dir) {
14
+ try {
15
+ writeFileSync(join(dir, NEVER_INDEX), "", { flag: "a" });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
package/dist/operate.js CHANGED
@@ -3,6 +3,8 @@ import { leadNameOf } from "./lead-identity.js";
3
3
  import { maybeTriggerRepair } from "./dispose.js";
4
4
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
5
5
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
6
+ import { runBatchChecks } from "./batch-checks.js";
7
+ import { BATCH_HINT, BATCH_WINDOW_MS, projectBatchChecks, setProjectBatchChecks } from "./batch-policy.js";
6
8
  import { buildReviewPass } from "./build-review.js";
7
9
  import { buildReviewLines, buildReviewOf } from "./review-switch.js";
8
10
  import { parseProtectedPaths } from "./approval-policy.js";
@@ -11,6 +13,7 @@ import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } f
11
13
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
12
14
  import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
13
15
  import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
16
+ import { dailyStorageSweep, lastSweep, nextSweepAt, saveSweep, storageSweepOff as storageSweepOffNow, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
14
17
  import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
15
18
  import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
16
19
  import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
@@ -45,7 +48,7 @@ import { loadSlackCredentials } from "./slack-api.js";
45
48
  import { followSlack } from "./slack.js";
46
49
  import { validateScopeText } from "./task-text.js";
47
50
  import { runMemoryCommand } from "./memory-cli.js";
48
- import { runKnowledgeCommand } from "./knowledge-cli.js";
51
+ import { runKnowledgeApply, runKnowledgeCommand } from "./knowledge-cli.js";
49
52
  import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
50
53
  import { runAssignmentCommand } from "./assignment-adapters.js";
51
54
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
@@ -996,6 +999,8 @@ async function dispatch(command, positional, flags, context) {
996
999
  return projectDeleteCommand(positional, flags, context);
997
1000
  if (positional[0] === "concurrency")
998
1001
  return projectConcurrencyCommand(positional, flags, context);
1002
+ if (positional[0] === "checks")
1003
+ return projectChecksCommand(positional, flags, context);
999
1004
  if (positional[0] === "demo")
1000
1005
  return projectDemoCommand(positional, flags, context);
1001
1006
  return runProjectCommand(positional, flags, context);
@@ -1004,8 +1009,18 @@ async function dispatch(command, positional, flags, context) {
1004
1009
  if (positional[0] === "claim" && currentActor()?.lead === true && !flags.has("token-env") && !flags.has("token-file"))
1005
1010
  return leadClaimCommand(positional, flags, context);
1006
1011
  return runAssignmentCommand(positional, flags, context);
1007
- case "knowledge":
1008
- return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
1012
+ case "knowledge": {
1013
+ if (positional[0] !== "apply")
1014
+ return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
1015
+ if (positional.length !== 1)
1016
+ return fail(context.write, context.json, "knowledge apply", "usage", "knowledge apply takes no query.", EXIT.usage);
1017
+ // A person's password, never the lead's token: the lead may not carry knowledge forward.
1018
+ const acting = currentActor()?.lead === true ? null : await askCredentials(flags, context);
1019
+ const repo = text(flags, "repo");
1020
+ const lead = acting !== null && parseLeadToken(acting.token) !== null;
1021
+ const verified = acting === null || lead ? null : authenticateApprover(context.store, acting.name, acting.token, repo === undefined ? null : resolve(repo));
1022
+ return runKnowledgeApply(flags, { ...context, now: context.clock(), actor: verified !== null && verified.ok ? acting.name : null });
1023
+ }
1009
1024
  case "models": {
1010
1025
  const acting = await askCredentials(flags, context);
1011
1026
  const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
@@ -4541,7 +4556,18 @@ async function reconcileCommand(flags, context) {
4541
4556
  retention = null; /* the next pass tries again */
4542
4557
  }
4543
4558
  const retained = retention === null ? [] : retention.counts.filter(one => one.count > 0);
4559
+ // The daily storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
4560
+ // checkouts, extra staged runtimes and dependency installs unused for a week; saved for Settings → Storage.
4561
+ let storageSwept = null;
4562
+ try {
4563
+ storageSwept = await dailyStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots() }, clock);
4564
+ }
4565
+ catch {
4566
+ storageSwept = null; /* the next pass tries again */
4567
+ }
4568
+ const swept = storageSwept === null ? [] : storageSwept.parts.filter(one => one.count > 0);
4544
4569
  const nothing = retained.length === 0 &&
4570
+ swept.length === 0 &&
4545
4571
  recovered.length === 0 &&
4546
4572
  reaped.length === 0 &&
4547
4573
  adoption.adopted.length === 0 &&
@@ -4562,6 +4588,7 @@ async function reconcileCommand(flags, context) {
4562
4588
  checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
4563
4589
  liveViewsSwept: liveSwept.removed.length,
4564
4590
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
4591
+ storageSweep: storageSwept,
4565
4592
  }, () => nothing
4566
4593
  ? ["Nothing to reconcile. Everything is where it should be."]
4567
4594
  : [
@@ -4575,6 +4602,7 @@ async function reconcileCommand(flags, context) {
4575
4602
  ...(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))}.`]),
4576
4603
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4577
4604
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4605
+ ...(storageSwept === null || swept.length === 0 ? [] : [`Storage sweep: ${storageSweepWords(storageSwept.parts)}`]),
4578
4606
  ]);
4579
4607
  }
4580
4608
  // ---- gaps -----------------------------------------------------------------
@@ -7658,11 +7686,15 @@ async function runWatchLoop(args) {
7658
7686
  run: async () => {
7659
7687
  if (store.isDemo())
7660
7688
  return;
7689
+ // Batch checks first: a cohort that is ready runs one check for all its results.
7690
+ const batched = await runBatchChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
7691
+ if (batched > 0)
7692
+ progress(`watch: settled ${batched} batch-checked result(s)`);
7661
7693
  const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
7662
7694
  if (ran > 0)
7663
7695
  progress(`watch: ran ${ran} follow-up check(s)`);
7664
7696
  // A finished check may send a build's card down its failure path: now, not on the next beat.
7665
- if (ran > 0)
7697
+ if (ran + batched > 0)
7666
7698
  moveCards(store, repo, context.clock(), context.evidenceRoot);
7667
7699
  },
7668
7700
  });
@@ -12231,6 +12263,45 @@ async function projectConcurrencyCommand(positional, flags, context) {
12231
12263
  ...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
12232
12264
  ]);
12233
12265
  }
12266
+ /** `project checks [--batch on|off] --repo <p>`: whether the project's results share one full check when they
12267
+ * finish close together (batch-checks.ts). Off by default; changing it is an approver's act, in the ledger. */
12268
+ async function projectChecksCommand(positional, flags, context) {
12269
+ const command = "project checks";
12270
+ const allowed = new Set(["repo", "batch", "as", "token", "token-file", "token-env", "db", "json"]);
12271
+ for (const name of flags.keys())
12272
+ if (!allowed.has(name))
12273
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project checks option.`, EXIT.usage);
12274
+ const repoFlag = text(flags, "repo");
12275
+ const wanted = flags.get("batch");
12276
+ if (positional.length > 1 || repoFlag === undefined || (wanted !== undefined && wanted !== "on" && wanted !== "off")) {
12277
+ return fail(context.write, context.json, command, "usage", "Use project checks [--batch on|off] --repo <project path>.", EXIT.usage);
12278
+ }
12279
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
12280
+ const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
12281
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
12282
+ if (repo === undefined)
12283
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
12284
+ const level = projectCheckLevel(context.store, repo).level;
12285
+ const words = (on) => [
12286
+ `${repo}: batch checks are ${on ? "on" : "off"}. ${on ? BATCH_HINT : "Each result runs its own full check."}`,
12287
+ ...(on && level !== "full" ? [`Checks are ${CHECK_LEVEL_WORDS[level]} for this project, so nothing is batched until they are Full.`] : []),
12288
+ ];
12289
+ if (wanted === undefined) {
12290
+ const current = projectBatchChecks(context.store, repo);
12291
+ return succeed(context.write, context.json, command, { repo, batch: current.on, windowMinutes: current.windowMs / 60_000, level, setBy: current.setBy }, () => words(current.on));
12292
+ }
12293
+ const acting = await askCredentials(flags, context);
12294
+ const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token, repo);
12295
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
12296
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes batch checks: pass --as and --token (or use the remembered login).", EXIT.refused);
12297
+ }
12298
+ const on = wanted === "on";
12299
+ const changed = setProjectBatchChecks(context.store, repo, on, acting.name, context.clock());
12300
+ return succeed(context.write, context.json, command, { repo, batch: on, before: changed.before, changed: changed.changed, windowMinutes: BATCH_WINDOW_MS / 60_000, level }, () => [
12301
+ ...words(on),
12302
+ ...(changed.changed && !on ? ["Results already waiting are checked now."] : []),
12303
+ ]);
12304
+ }
12234
12305
  /** `project demo [<url>|off] --repo <p>` (review 827): the project's own demo or dev server, which its scouts may
12235
12306
  * screenshot. Changing it is an approver's act. */
12236
12307
  async function projectDemoCommand(positional, flags, context) {
@@ -12625,17 +12696,33 @@ async function storageCommand(positional, flags, context) {
12625
12696
  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 }));
12626
12697
  };
12627
12698
  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)" : ""}`;
12699
+ // What the latest sweep (automatic, or a clean-up by hand) did and when (storage-sweep.ts).
12700
+ const sweepFacts = () => {
12701
+ const last = lastSweep(store);
12702
+ const automatic = last?.source === "automatic" ? last : lastSweep(store, "automatic");
12703
+ return { last, lastAutomatic: automatic, next: nextSweepAt(store) };
12704
+ };
12705
+ const sweepLines = (facts) => {
12706
+ const when = (at) => new Date(at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
12707
+ const one = facts.last;
12708
+ const head = one === null ? (facts.next === null && storageSweepOffNow() ? "Automatic sweep: off." : "Automatic sweep: not run yet; it runs once a day.")
12709
+ : `Last swept ${when(one.at)}${one.source === "manual" ? ` by ${one.actor}` : " automatically"}: ${storageSweepWords(one.parts)}`;
12710
+ const auto = one !== null && one.source === "manual" && facts.lastAutomatic !== null ? [`Last automatic sweep ${when(facts.lastAutomatic.at)}: ${storageSweepWords(facts.lastAutomatic.parts)}`] : [];
12711
+ return [head, ...auto, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
12712
+ };
12628
12713
  if (action === undefined) {
12629
12714
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12630
12715
  const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
12631
12716
  const { staleBytes: _, ...temp } = leftovers(false);
12632
12717
  const shared = sharedFacts(sharedUse(depsRoot, checkoutRows()));
12633
- return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared }, () => [
12718
+ const sweep = sweepFacts();
12719
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared, sweep }, () => [
12634
12720
  `${report.folder}: ${bytesWords(report.total)}`,
12635
12721
  ...report.lines.map(one => ` ${one.what.padEnd(19)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12636
12722
  ...(shared.length === 0 ? [] : ["Shared dependencies:", ...shared.map(sharedLine)]),
12637
12723
  ...summary(plan),
12638
12724
  leftoverLine({ ...temp, staleBytes: 0 }),
12725
+ ...sweepLines(sweep),
12639
12726
  ]);
12640
12727
  }
12641
12728
  if (action === "cleanup") {
@@ -12692,6 +12779,13 @@ async function storageCommand(positional, flags, context) {
12692
12779
  for (const debris of unused.debris)
12693
12780
  removeShared(debris);
12694
12781
  const sharedFreed = sharedRemoved.reduce((sum, one) => sum + one.bytes, 0);
12782
+ // Saved like an automatic sweep, so Storage shows what this clean-up did and when.
12783
+ const parts = [
12784
+ { kind: "checkouts", count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: done.removed.slice(0, 20).map(one => one.path) },
12785
+ { kind: "test temp", count: swept.removed.length, bytes: temp.staleBytes, failed: swept.failed.length, items: swept.removed.slice(0, 20) },
12786
+ { kind: "dependencies", count: sharedRemoved.length, bytes: sharedFreed, failed: unused.copies.length - sharedRemoved.length, items: sharedRemoved.slice(0, 20).map(one => one.path) },
12787
+ ];
12788
+ saveSweep(store, { at: context.clock().toISOString(), source: "manual", actor, parts });
12695
12789
  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 }, () => [
12696
12790
  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.`,
12697
12791
  ...done.removed.map(itemLine),
@@ -0,0 +1,63 @@
1
+ import { type ProcessRow, type StopOptions } from "./run-closeout.js";
2
+ import type { Store } from "./store.js";
3
+ import { type TempOwner } from "./test-temp.js";
4
+ import type { WorktreePool } from "./worktree.js";
5
+ /** Where a process runs from: its working folder and executable, when the OS says. */
6
+ export type Located = {
7
+ cwd?: string;
8
+ exe?: string;
9
+ };
10
+ /** Working folder and executable of each pid: /proc on Linux, lsof on macOS. Throws when the OS can't be asked. */
11
+ export declare function locateProcesses(pids: readonly number[]): Map<number, Located>;
12
+ /** A folder an orphan may be stopped from: a let-go checkout (only what was born before it was let go) or a temp root. */
13
+ export type SweepRoot = {
14
+ path: string;
15
+ kind: "checkout" | "test temp";
16
+ bornBefore?: number;
17
+ repo?: string | null;
18
+ taskId?: string | null;
19
+ };
20
+ /** The root `located` runs from inside of, if any (its working folder first, then its executable). */
21
+ export declare function rootOf(located: Located | undefined, roots: readonly SweepRoot[]): SweepRoot | null;
22
+ export type OrphanTarget = {
23
+ row: ProcessRow;
24
+ root: SweepRoot;
25
+ orphan: number;
26
+ };
27
+ /**
28
+ * Which processes go: each orphan (parent 1, no terminal, not protected) running from inside a root — for a checkout,
29
+ * born before it was let go — and every process descending from one that goes (none at a terminal or protected).
30
+ */
31
+ export declare function orphanTargets(census: readonly ProcessRow[], located: ReadonlyMap<number, Located>, roots: readonly SweepRoot[], guard: {
32
+ pids: ReadonlySet<number>;
33
+ groups: ReadonlySet<number>;
34
+ }): OrphanTarget[];
35
+ /** Whether a temp root's owner (pid and start) is still the running process. */
36
+ export declare function ownerRunning(census: readonly ProcessRow[]): (owner: TempOwner) => boolean;
37
+ /**
38
+ * The roots orphans may be stopped from now: every checkout let go with no live run on its task and not held, and
39
+ * every test temp root whose owner is gone or that nothing touched for a day.
40
+ */
41
+ export declare function sweepRoots(store: Store, pool: WorktreePool | null, census: readonly ProcessRow[], tempRoots: readonly string[], now: Date): SweepRoot[];
42
+ export type OrphanStop = {
43
+ pid: number;
44
+ name: string;
45
+ where: string;
46
+ kind: SweepRoot["kind"];
47
+ ranMs: number;
48
+ killed: boolean;
49
+ };
50
+ export type OrphanSweep = {
51
+ stopped: OrphanStop[];
52
+ failed: string[];
53
+ };
54
+ export type OrphanOptions = StopOptions & {
55
+ actor?: string;
56
+ pool?: WorktreePool | null;
57
+ tempRoots?: readonly string[];
58
+ locate?: (pids: readonly number[]) => Map<number, Located>;
59
+ self?: number;
60
+ };
61
+ /** Stop the orphans Toolroll left (see the top of this file), each in the ledger. */
62
+ export declare function sweepOrphans(store: Store, now: () => Date, options?: OrphanOptions): Promise<OrphanSweep>;
63
+ export declare function durationWords(ms: number): string;