toolroll 0.9.31 → 0.9.32

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 (55) hide show
  1. package/CHANGELOG.md +21 -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/desktop-update.d.ts +1 -1
  16. package/dist/exec.d.ts +2 -0
  17. package/dist/exec.js +11 -0
  18. package/dist/lead-capabilities.d.ts +0 -7
  19. package/dist/lead-capabilities.js +11 -1
  20. package/dist/mate-doors.js +2 -2
  21. package/dist/mate.d.ts +6 -2
  22. package/dist/mate.js +63 -38
  23. package/dist/never-index.d.ts +3 -0
  24. package/dist/never-index.js +21 -0
  25. package/dist/operate.js +86 -2
  26. package/dist/orphan-sweep.d.ts +63 -0
  27. package/dist/orphan-sweep.js +235 -0
  28. package/dist/result-follow-ups.d.ts +22 -2
  29. package/dist/result-follow-ups.js +44 -10
  30. package/dist/serve.js +26 -7
  31. package/dist/shared-deps.d.ts +18 -2
  32. package/dist/shared-deps.js +129 -15
  33. package/dist/staged-runtimes.d.ts +42 -0
  34. package/dist/staged-runtimes.js +191 -0
  35. package/dist/storage-sweep.d.ts +50 -0
  36. package/dist/storage-sweep.js +203 -0
  37. package/dist/storage-ui.d.ts +5 -0
  38. package/dist/storage-ui.js +30 -4
  39. package/dist/storage.d.ts +2 -0
  40. package/dist/storage.js +15 -3
  41. package/dist/store.d.ts +17 -2
  42. package/dist/store.js +37 -5
  43. package/dist/subscription-chat.d.ts +3 -1
  44. package/dist/subscription-chat.js +1 -0
  45. package/dist/surface.js +1 -0
  46. package/dist/task-status.d.ts +13 -0
  47. package/dist/task-status.js +37 -3
  48. package/dist/team-leads.js +6 -2
  49. package/dist/team-runtime.js +2 -1
  50. package/dist/test-temp.d.ts +20 -2
  51. package/dist/test-temp.js +53 -13
  52. package/dist/toolroll-update.js +2 -0
  53. package/dist/workspace-ui.js +3 -1
  54. package/dist/worktree.js +9 -0
  55. package/package.json +1 -1
package/dist/builder.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
2
2
  import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
3
+ import { queueBatchCheck } from "./batch-checks.js";
4
+ import { batchesFullCheck } from "./batch-policy.js";
3
5
  import { skillsContext } from "./project-skills.js";
4
6
  import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
5
7
  import { learningContext } from "./project-learning.js";
@@ -684,7 +686,7 @@ export async function build(store, request) {
684
686
  // copy links it instead of installing its own; a setup that is about to
685
687
  // run never reaches through an old link into a shared copy.
686
688
  const depsRoot = sharedDepsRootOf(store);
687
- const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
689
+ const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted, root: depsRoot });
688
690
  if (depsRoot !== null)
689
691
  detachShared(worktree, depsRoot);
690
692
  if (depsRoot !== null && sharing !== null) {
@@ -2175,6 +2177,11 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2175
2177
  const configured = chosenCheck?.command ?? null;
2176
2178
  if (repo !== null && chosenCheck !== null)
2177
2179
  recordRunCheckLevel(store, { id: runId, taskId: request.taskId, repo }, chosenCheck.level, checkChoice.from, now());
2180
+ // Batch checks (batch-checks.ts): with the project's batching on, a Full check waits to run once with
2181
+ // other results on a temporary batch commit. The build seals no check of its own; the batch's sealed
2182
+ // receipt is this result's check, and Merge waits for it.
2183
+ const batched = repo !== null && chosenCheck !== null && batchesFullCheck(store, repo, chosenCheck.level, configured);
2184
+ const gateCommand = batched ? null : configured;
2178
2185
  let verifyCommand;
2179
2186
  const checkLog = [];
2180
2187
  const checkOutcomes = [];
@@ -2210,6 +2217,10 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2210
2217
  else if (configured === null) {
2211
2218
  verifyCommand = { configured: false };
2212
2219
  }
2220
+ else if (batched && repo !== null) {
2221
+ verifyCommand = { configured: false };
2222
+ queueBatchCheck(store, { runId, taskId: request.taskId, repo, head: sealedHead, base: captured.baseRevision, command: configured }, now());
2223
+ }
2213
2224
  else if (!store.proveRunnerCustodyForSpawn(runId, now())) {
2214
2225
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
2215
2226
  recordCheckNote("Verification did not start: this worker no longer owned the build.");
@@ -2258,7 +2269,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2258
2269
  const live = linked === null ? null : store.liveWorktreeSetup(repo);
2259
2270
  if (linked === null || live === null)
2260
2271
  return null;
2261
- if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
2272
+ if (sharingFor({ repo, worktree, setup: live, root: depsRoot })?.key === linked && readyCopy(depsRoot, linked) !== null)
2262
2273
  return null;
2263
2274
  if (!store.proveRunnerCustodyForSpawn(runId, now()))
2264
2275
  return "custody-lost";
@@ -2497,7 +2508,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2497
2508
  }
2498
2509
  const { verdict, reasons, matrix, machineVerdict } = adjudicate({
2499
2510
  directAssessment: true,
2500
- ...(configured === null ? {} : { verificationCommand: configured.command }),
2511
+ ...(gateCommand === null ? {} : { verificationCommand: gateCommand.command }),
2501
2512
  proofArtifactPresent,
2502
2513
  proofParse,
2503
2514
  handoffPresent: handoffArtifact !== null,
@@ -5,7 +5,7 @@ import { TEXT_LIMITS } from "./text-limits.js";
5
5
  import { publicChatText } from "./chat-display.js";
6
6
  import { gateWords } from "./approval-policy.js";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
- import { verifiedAuthor } from "./store.js";
8
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
9
9
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
10
10
  import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
11
11
  import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
@@ -1025,7 +1025,7 @@ function savedActionContext(store, who, id, state) {
1025
1025
  session.ceilingDigest !== who.ceilingDigest ||
1026
1026
  proposal.ceilingDigest !== who.ceilingDigest ||
1027
1027
  (!shared && turn?.session !== session.id) ||
1028
- turn?.state !== "answered")
1028
+ !mateTurnKeepsProposals(turn))
1029
1029
  throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
1030
1030
  requireActor(store, who, payload.repo, payload.operation);
1031
1031
  return payload;
@@ -19,6 +19,10 @@ export type CheckSettingsView = {
19
19
  on: boolean;
20
20
  source: "project" | "hands-off" | "default";
21
21
  };
22
+ /** Batch checks (batch-checks.ts): off by default. */
23
+ batch?: {
24
+ on: boolean;
25
+ };
22
26
  said: string | null;
23
27
  problem: string | null;
24
28
  };
@@ -3,6 +3,7 @@
3
3
  * one. Every change takes the password and lands in the ledger; approving a
4
4
  * command restates exactly what will run, unattended, before the yes. */
5
5
  import { CHECK_LEVELS, CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS } from "./check-levels.js";
6
+ import { BATCH_HINT } from "./batch-policy.js";
6
7
  const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
8
  export function checkSettingsHtml(view) {
8
9
  const hidden = (act) => `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="${act}">`;
@@ -19,7 +20,7 @@ export function checkSettingsHtml(view) {
19
20
  (fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
20
21
  (view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
21
22
  if (!view.canChange) {
22
- return head + `<section class="card check-settings">${current}</section>` + reviewHtml(view, "") +
23
+ return head + `<section class="card check-settings">${current}</section>` + batchHtml(view, "") + reviewHtml(view, "") +
23
24
  `<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
24
25
  `<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
25
26
  }
@@ -45,7 +46,25 @@ export function checkSettingsHtml(view) {
45
46
  `<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
46
47
  const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
47
48
  (view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
48
- return head + levelForm + reviewHtml(view, password) + quick + full;
49
+ return head + levelForm + batchHtml(view, password) + reviewHtml(view, password) + quick + full;
50
+ }
51
+ /** Batch checks: one state and one sentence; turning it on restates exactly what happens, behind a disclosure with the password. */
52
+ function batchHtml(view, password) {
53
+ const batch = view.batch;
54
+ if (batch === undefined)
55
+ return "";
56
+ const act = batch.on ? "Turn off" : "Turn on";
57
+ const terms = batch.on ? `<p class="meta">Each result runs its own full check again. Results already waiting are checked now.</p>`
58
+ : `<ul class="check-terms"><li>When results finish within 10 minutes of each other, they're merged in a temporary checkout and the full check runs once.</li>` +
59
+ `<li>If it fails, the batch is split until the result that breaks it is found. Results that conflict are checked on their own.</li>` +
60
+ `<li>Nothing is merged into a real branch. Each result still lands on its own.</li></ul>`;
61
+ const change = password === "" ? ""
62
+ : `<details class="settings-more"><summary>${act}</summary><form method="post" action="/settings/checks" class="check-settings">` +
63
+ `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="batch">` +
64
+ `<input type="hidden" name="on" value="${batch.on ? "0" : "1"}">${terms}${password}<button type="submit">${act}</button></form></details>`;
65
+ return `<section class="card check-settings" data-batch-checks="${batch.on ? "on" : "off"}"><h2>Batch checks</h2>` +
66
+ `<p><strong>${batch.on ? "On" : "Off"}</strong> · ${escape(BATCH_HINT)}</p>` +
67
+ (batch.on && view.level !== "full" ? `<p class="meta">Only Full checks are batched.</p>` : "") + `${change}</section>`;
49
68
  }
50
69
  /** Automatic review: one state, one sentence, and its one change behind a disclosure (the password). */
51
70
  function reviewHtml(view, password) {
@@ -1,7 +1,7 @@
1
1
  import { type CheckoutCleanup } from "./storage.js";
2
2
  import type { Store, WorktreeRow } from "./store.js";
3
3
  import { type WorktreePool } from "./worktree.js";
4
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
4
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
5
5
  export declare const CANDIDATE_KEEP_MS: number;
6
6
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
7
7
  export declare const AUTO_MAX = 20;
@@ -43,6 +43,7 @@ type Status = {
43
43
  export type Deployed = {
44
44
  head: string | null;
45
45
  run: number | null;
46
+ at?: string | null;
46
47
  };
47
48
  export declare function deployedRelease(stateDir: string | null): Deployed | null;
48
49
  /**
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
9
  * still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
10
- * branch. A release candidate's checkout (a deploy installs from it) is kept only while it is the newest gate for its
11
- * release (for a week after it was done) or the one deployed; a superseded or cancelled gate goes at once, whatever
12
- * its task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
10
+ * branch. A release check's checkout (a deploy installs from it) goes once its task is complete, with its dependency
11
+ * install and journey output, except the one deployed now and the project's newest complete one still waiting for its
12
+ * deploy (no deploy since it was complete; at most a week). A superseded or cancelled gate goes at once, whatever its
13
+ * task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
13
14
  * in a clean-up by hand. Every removal is in the ledger.
14
15
  *
15
16
  * A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
@@ -23,7 +24,7 @@ import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
24
25
  import { worktreePath } from "./worktree.js";
25
26
  const DAY_MS = 86_400_000;
26
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
27
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
27
28
  export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
28
29
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
29
30
  export const AUTO_MAX = 20;
@@ -56,7 +57,7 @@ export function deployedRelease(stateDir) {
56
57
  continue;
57
58
  newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
58
59
  }
59
- return newest === null ? null : { head: newest.head, run: newest.run };
60
+ return newest === null ? null : { head: newest.head, run: newest.run, at: newest.at === "" ? null : newest.at };
60
61
  }
61
62
  /** The folder beside the database (where staged releases live), or null for a database in memory. */
62
63
  function stateDirOf(store) {
@@ -117,6 +118,19 @@ export function taskStatuses(store, now, deployed) {
117
118
  if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
118
119
  completedRef.set(ref, at);
119
120
  }
121
+ const completedAtOf = (row) => [completed.get(String(row["id"])), completedRef.get(Number(row["ref"]))].filter((one) => one !== undefined).sort().at(-1) ?? null;
122
+ // Per project, the newest complete gate (by when it was complete): the one a deploy may still be about to install.
123
+ // Once the project deployed after it was complete, it waits for nothing.
124
+ const deployedRepo = deployedRef === undefined ? (live?.head == null ? undefined : gates.find(row => String(row["candidate"]) === live.head)?.["repo"]) : refs.find(row => Number(row["ref"]) === deployedRef)?.["repo"];
125
+ const newestDone = new Map();
126
+ for (const row of gates) {
127
+ if (row["state"] !== "done")
128
+ continue;
129
+ const at = completedAtOf(row) ?? (row["updated_at"] === null ? "" : String(row["updated_at"]));
130
+ const seen = newestDone.get(String(row["repo"]));
131
+ if (seen === undefined || at > seen.at || (at === seen.at && Number(row["ref"]) > seen.ref))
132
+ newestDone.set(String(row["repo"]), { ref: Number(row["ref"]), at });
133
+ }
120
134
  const byRef = new Map();
121
135
  const byId = new Map();
122
136
  const parentOf = new Map();
@@ -139,11 +153,13 @@ export function taskStatuses(store, now, deployed) {
139
153
  else if (state === "cancelled")
140
154
  status = { keep: null, taskId: id, finishedAt: updated };
141
155
  else {
142
- const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
156
+ const completedAt = completedAtOf(row);
143
157
  if (gate) {
144
158
  const since = completedAt ?? updated ?? now.toISOString();
145
159
  const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
146
- status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
160
+ const deployedSince = live?.at != null && deployedRepo !== undefined && String(row["repo"]) === String(deployedRepo) && live.at >= since;
161
+ const waiting = newestDone.get(String(row["repo"]))?.ref === ref && !deployedSince && until > now.toISOString();
162
+ status = waiting ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since, atOnce: true };
147
163
  }
148
164
  else
149
165
  status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
@@ -353,7 +369,7 @@ export function whyWords(item) {
353
369
  case "in use": return "in use";
354
370
  case "task not finished": return "its task isn't finished";
355
371
  case "waiting for review": return "its result is waiting for review";
356
- case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
372
+ case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept for its deploy until ${item.dueAt.slice(0, 10)}`;
357
373
  case "has changes": return "has changes";
358
374
  case "has commits": return "has commits on no branch";
359
375
  case "unreadable": return "git couldn't read it";
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
- error?: never;
129
128
  active: boolean;
130
129
  phase: string;
131
130
  detail: string;
132
131
  running: boolean;
133
132
  canResume: boolean;
134
133
  canCancel: boolean;
134
+ error?: never;
135
135
  automaticRecovery?: never;
136
136
  recoveryAttempts?: never;
137
137
  recoveryRepairs?: never;
package/dist/exec.d.ts CHANGED
@@ -56,6 +56,8 @@ export type RunOptions = ProcessTreeObserver & {
56
56
  * timeoutMs there is deliberately no absolute wall-clock ceiling.
57
57
  */
58
58
  idleTimeoutMs?: number;
59
+ /** Ends a process-group run early, exactly as its timeout does: the whole group is killed and the result reads timed out. */
60
+ signal?: AbortSignal;
59
61
  maxBuffer?: number;
60
62
  /**
61
63
  * Extra environment, merged over the process's own. A capability probe is a
package/dist/exec.js CHANGED
@@ -526,6 +526,7 @@ export function run(file, args, options = {}) {
526
526
  return retryTransientSpawn(() => runBufferedGroup(file, args, {
527
527
  timeoutMs,
528
528
  maxBuffer,
529
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
529
530
  ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
530
531
  ...(cwd === undefined ? {} : { cwd }),
531
532
  ...(childEnv === undefined ? {} : { childEnv }),
@@ -610,6 +611,15 @@ function runBufferedGroup(file, args, bag) {
610
611
  timedOut = true;
611
612
  killGroup(child);
612
613
  }, bag.timeoutMs);
614
+ // The caller's deadline, when it owns one: the same group kill as the timer.
615
+ const aborted = () => {
616
+ timedOut = true;
617
+ killGroup(child);
618
+ };
619
+ if (bag.signal?.aborted === true)
620
+ aborted();
621
+ else
622
+ bag.signal?.addEventListener("abort", aborted, { once: true });
613
623
  child.stdout?.setEncoding("utf8");
614
624
  child.stdout?.on("data", (chunk) => {
615
625
  stdout += chunk;
@@ -638,6 +648,7 @@ function runBufferedGroup(file, args, bag) {
638
648
  return;
639
649
  settled = true;
640
650
  clearTimeout(timer);
651
+ bag.signal?.removeEventListener("abort", aborted);
641
652
  liveProviders.delete(child);
642
653
  // The OS object is settled BEFORE the answer: a root that exited is
643
654
  // not an empty container until the OS says so.
@@ -1,10 +1,3 @@
1
- /**
2
- * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
- * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
- * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
- * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
- * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
- */
8
1
  import type { Store } from "./store.js";
9
2
  import type { ChatControl } from "./chat-controls.js";
10
3
  import type { ProviderId } from "./provider.js";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
+ * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
+ * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
+ * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
+ * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
+ */
8
+ import { projectBatchChecks } from "./batch-policy.js";
1
9
  import { resolveRouteCandidates } from "./agentconfig.js";
2
10
  import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
3
11
  import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
@@ -136,7 +144,9 @@ function checksOf(store, repo) {
136
144
  return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
137
145
  if (!releaseCheck)
138
146
  return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
139
- return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set`, ok: true, next: null, link: null };
147
+ // Batch checks are changed in Settings → Projects → Checks (or `project checks --batch on|off`), never from chat.
148
+ const batched = level === "full" && projectBatchChecks(store, repo).on ? " · batched with results that finish together" : "";
149
+ return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set${batched}`, ok: true, next: null, link: null };
140
150
  }
141
151
  function workersOf(store, repos, now) {
142
152
  const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
@@ -15,7 +15,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
15
15
  * silent re-read.
16
16
  */
17
17
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
- import { verifiedAuthor } from "./store.js";
18
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
19
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
20
20
  import { TeamLeads } from './team-leads.js';
21
21
  import { fileTaskProposal } from "./proposal.js";
@@ -97,7 +97,7 @@ export function confirmMateProposal(store, who, proposalId, now, options) {
97
97
  return { ok: false, kind: proposal.kind, reason: "ceiling-changed", said: "the admitted projects changed since this was proposed — it cannot be confirmed" };
98
98
  }
99
99
  const turn = store.getMateTurn(proposal.turn);
100
- if (turn === null || turn.state !== "answered") {
100
+ if (!mateTurnKeepsProposals(turn) || proposal.state === "drafting") {
101
101
  return { ok: false, kind: proposal.kind, reason: "turn-not-answered", said: "this proposal's turn did not finish — it cannot be confirmed" };
102
102
  }
103
103
  if (proposal.kind === "cancel") {
package/dist/mate.d.ts CHANGED
@@ -93,6 +93,7 @@ export type MateTurnOutcome = {
93
93
  failed: MateFailure;
94
94
  message: string;
95
95
  unknownSpend: boolean;
96
+ saved?: boolean;
96
97
  };
97
98
  /** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
98
99
  export declare const MATE_REFUSAL_COPY: Record<MateRefusal, string>;
@@ -102,7 +103,7 @@ export declare const MATE_CHANNEL_COPY: Record<MateChannelProblem, string>;
102
103
  export declare const MATE_FAILURE_COPY: {
103
104
  readonly 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.";
104
105
  readonly 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.";
105
- readonly tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.";
106
+ readonly tooLong: string;
106
107
  readonly 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.";
107
108
  readonly 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.";
108
109
  readonly 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.";
@@ -111,7 +112,7 @@ export declare const MATE_FAILURE_COPY: {
111
112
  readonly 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.";
112
113
  readonly 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.";
113
114
  readonly 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.";
114
- readonly 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.";
115
+ readonly tooLongUnknownCost: string;
115
116
  readonly 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.";
116
117
  readonly 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.";
117
118
  readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
@@ -119,6 +120,9 @@ export declare const MATE_FAILURE_COPY: {
119
120
  };
120
121
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
121
122
  export declare function mateFailureText(reason: string | null): string;
123
+ /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
124
+ * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
125
+ export declare const MATE_ABORT_GRACE_MS = 5000;
122
126
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
123
127
  export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): MateHistoryMessage[];
124
128
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
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
+ }