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
@@ -0,0 +1,50 @@
1
+ import { type Located } from "./orphan-sweep.js";
2
+ import { type ProcessRow } from "./run-closeout.js";
3
+ import type { Store } from "./store.js";
4
+ import type { WorktreePool } from "./worktree.js";
5
+ export declare const SWEEP_EVERY_MS = 86400000;
6
+ /** A sweep that failed somewhere, or left work for later, runs again this soon. */
7
+ export declare const RETRY_MS: number;
8
+ export type SweepKind = "test temp" | "orphans" | "checkouts" | "runtimes" | "dependencies";
9
+ export type SweepPart = {
10
+ kind: SweepKind;
11
+ count: number;
12
+ bytes: number;
13
+ failed: number;
14
+ more?: boolean;
15
+ problem?: string;
16
+ items: string[];
17
+ };
18
+ export type SweepRecord = {
19
+ at: string;
20
+ source: "automatic" | "manual";
21
+ actor: string;
22
+ parts: SweepPart[];
23
+ };
24
+ export type SweepDeps = {
25
+ databaseFile: string;
26
+ pool: WorktreePool | null;
27
+ tempRoots: readonly string[];
28
+ actor?: string;
29
+ census?: () => ProcessRow[];
30
+ locate?: (pids: readonly number[]) => Map<number, Located>;
31
+ inUse?: () => string[];
32
+ graceMs?: number;
33
+ };
34
+ export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
35
+ export declare function storageSweepDue(store: Store, now: Date): boolean;
36
+ /** The daily sweep, when it's due and on: one worker takes it. Null when it wasn't this pass's to run. */
37
+ export declare function dailyStorageSweep(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord | null>;
38
+ /** One sweep, now, every part; saved and in the ledger. Each part's failure is its own: the others still run. */
39
+ export declare function sweepStorage(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord>;
40
+ /** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
41
+ export declare function saveSweep(store: Store, record: SweepRecord): SweepRecord;
42
+ /** The latest sweep (of `source`, or either), or null when there has been none. */
43
+ export declare function lastSweep(store: Store, source?: SweepRecord["source"]): SweepRecord | null;
44
+ /** When the next automatic sweep is due, or null when it is off. */
45
+ export declare function nextSweepAt(store: Store): string | null;
46
+ export declare function partWords(one: Pick<SweepPart, "kind" | "count">): string;
47
+ /** "Removed 120 test temp folders and 2 finished checkouts, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
48
+ export declare function sweepWords(parts: readonly SweepPart[]): string;
49
+ /** The details a disclosure shows: each part's paths, problems and what's left for later, one line each. */
50
+ export declare function sweepDetails(parts: readonly SweepPart[]): string[];
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The daily storage sweep: the plane cleans up after itself without anyone running a command. Once a day (one worker
3
+ * takes the day: the cursor moves in the same transaction) it removes test temp folders nothing touched for a day
4
+ * (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
5
+ * setting lets go (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
6
+ * and removes shared dependency installs no checkout has used for a week (shared-deps.ts).
7
+ *
8
+ * The everyday work stays where it happens: suites remove their own temp roots and processes, a run's processes stop
9
+ * when it ends and a finished checkout goes on the worker's next pass. This is the net for what slipped through.
10
+ *
11
+ * What each sweep did is saved (the latest few) and in the ledger: `toolroll storage` and Settings → Storage show it.
12
+ * A clean-up by hand (`storage clean --yes`) is saved the same way. A part that failed, or had more than one pass
13
+ * takes, brings the next sweep forward to an hour later. `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off
14
+ * (the unit tests do, so a test never sweeps the machine it runs on).
15
+ */
16
+ import { dirname } from "node:path";
17
+ import { cleanCheckouts } from "./checkout-cleanup.js";
18
+ import { durationWords, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
19
+ import { processCensus } from "./run-closeout.js";
20
+ import { pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
21
+ import { pruneStagedRuntimes, runtimesInUse, stagePlan } from "./staged-runtimes.js";
22
+ import { bytesWords, diskBytes } from "./storage.js";
23
+ import { removeStaleTestTemp } from "./test-temp.js";
24
+ const CURSOR = "storage-sweep";
25
+ export const SWEEP_EVERY_MS = 86_400_000;
26
+ /** A sweep that failed somewhere, or left work for later, runs again this soon. */
27
+ export const RETRY_MS = 60 * 60_000;
28
+ /** Sweeps kept. */
29
+ const KEEP = 30;
30
+ /** Test temp folders one sweep removes at most (Oct 4 found 107,418). */
31
+ const TEMP_MAX = 20_000;
32
+ /** Paths saved per part, for the disclosure. */
33
+ const ITEMS_MAX = 20;
34
+ export function storageSweepOff(env = process.env) {
35
+ return (env["TOOLROLL_STORAGE_SWEEP"] ?? "").trim().toLowerCase() === "off";
36
+ }
37
+ export function storageSweepDue(store, now) {
38
+ return now.getTime() - store.serviceCursor(CURSOR) >= SWEEP_EVERY_MS;
39
+ }
40
+ /** The daily sweep, when it's due and on: one worker takes it. Null when it wasn't this pass's to run. */
41
+ export async function dailyStorageSweep(store, deps, now) {
42
+ if (storageSweepOff())
43
+ return null;
44
+ const at = now();
45
+ const mine = store.transact(() => {
46
+ if (!storageSweepDue(store, at))
47
+ return false;
48
+ store.setServiceCursor(CURSOR, at.getTime(), at);
49
+ return true;
50
+ });
51
+ if (!mine)
52
+ return null;
53
+ const record = await sweepStorage(store, deps, now);
54
+ // Something failed or was left for later: the next sweep comes an hour from now, not tomorrow.
55
+ if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined)) {
56
+ store.setServiceCursor(CURSOR, Math.max(0, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
57
+ }
58
+ return record;
59
+ }
60
+ const capped = (items) => items.slice(0, ITEMS_MAX);
61
+ const failure = (error) => error instanceof Error ? error.message : String(error);
62
+ /** One sweep, now, every part; saved and in the ledger. Each part's failure is its own: the others still run. */
63
+ export async function sweepStorage(store, deps, now) {
64
+ const actor = deps.actor ?? "worker";
65
+ const read = deps.census ?? (() => processCensus());
66
+ const parts = [];
67
+ const part = async (kind, work) => {
68
+ try {
69
+ parts.push({ kind, ...await work() });
70
+ }
71
+ catch (error) {
72
+ parts.push({ kind, count: 0, bytes: 0, failed: 0, problem: failure(error), items: [] });
73
+ }
74
+ };
75
+ await part("test temp", async () => {
76
+ // A root whose owner still runs stays, however old it looks; with no process list, every owned root stays.
77
+ let alive = () => true;
78
+ try {
79
+ alive = ownerRunning(read());
80
+ }
81
+ catch { /* owned roots stay */ }
82
+ const done = removeStaleTestTemp(deps.tempRoots, now(), { ownerAlive: alive, max: TEMP_MAX });
83
+ return { count: done.removed.length, bytes: 0, failed: done.failed.length, ...(done.more ? { more: true } : {}), items: capped(done.removed) };
84
+ });
85
+ await part("orphans", async () => {
86
+ const done = await sweepOrphans(store, now, {
87
+ actor, pool: deps.pool, tempRoots: deps.tempRoots, census: read,
88
+ ...(deps.locate === undefined ? {} : { locate: deps.locate }), ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }),
89
+ });
90
+ return {
91
+ count: done.stopped.length, bytes: 0, failed: 0, ...(done.failed.length === 0 ? {} : { problem: done.failed.join("; ") }),
92
+ items: capped(done.stopped.map(one => `${one.name} (${one.pid}), ${durationWords(one.ranMs)}, in ${one.where}`)),
93
+ };
94
+ });
95
+ if (deps.pool !== null) {
96
+ const pool = deps.pool;
97
+ await part("checkouts", async () => {
98
+ const done = await cleanCheckouts(store, pool, now, { manual: false, actor });
99
+ return { count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: capped(done.removed.map(one => one.path)) };
100
+ });
101
+ }
102
+ const stateDir = dirname(deps.databaseFile);
103
+ await part("runtimes", async () => {
104
+ const inUse = deps.inUse ?? (() => runtimesInUse());
105
+ const plan = stagePlan(stateDir, inUse(), now());
106
+ const sizes = diskBytes(plan.go.map(one => one.path));
107
+ const done = pruneStagedRuntimes(stateDir, now(), { inUse });
108
+ return {
109
+ count: done.removed.length, bytes: done.removed.reduce((sum, path) => sum + (sizes.get(path) ?? 0), 0), failed: done.failed.length,
110
+ ...(done.refused === null ? {} : { problem: `kept every staged runtime: ${done.refused}` }), items: capped(done.removed),
111
+ };
112
+ });
113
+ await part("dependencies", async () => {
114
+ const root = sharedDepsRoot(deps.databaseFile);
115
+ const checkouts = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
116
+ const candidates = sharedUse(root, checkouts()).filter(one => one.checkouts.length === 0 && now().getTime() - Date.parse(one.usedAt) > SHARED_UNUSED_MS);
117
+ const sizes = diskBytes(candidates.map(one => one.dir));
118
+ const done = pruneUnusedShared(root, checkouts, now());
119
+ return { count: done.removed.length, bytes: done.removed.reduce((sum, one) => sum + (sizes.get(one.dir) ?? 0), 0), failed: done.failed.length, items: capped(done.removed.map(one => one.dir)) };
120
+ });
121
+ return saveSweep(store, { at: now().toISOString(), source: "automatic", actor, parts });
122
+ }
123
+ /** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
124
+ export function saveSweep(store, record) {
125
+ store.transact(() => {
126
+ store.handle.prepare("INSERT INTO storage_sweep (at, source, actor, result) VALUES (?, ?, ?, ?)").run(record.at, record.source, record.actor, JSON.stringify(record.parts));
127
+ store.handle.prepare("DELETE FROM storage_sweep WHERE id NOT IN (SELECT id FROM storage_sweep ORDER BY id DESC LIMIT ?)").run(KEEP);
128
+ });
129
+ const did = record.parts.some(one => one.count > 0);
130
+ const failed = record.parts.some(one => one.failed > 0 || one.problem !== undefined);
131
+ store.recordAction({ at: record.at, actor: record.actor, repo: null, taskId: null, runId: null, action: "storage sweep",
132
+ outcome: failed ? "partial" : did ? "removed" : "nothing due", source: record.source === "automatic" ? "policy" : "request", detail: sweepWords(record.parts) });
133
+ return record;
134
+ }
135
+ function parseParts(text) {
136
+ try {
137
+ const parsed = JSON.parse(text);
138
+ return Array.isArray(parsed) ? parsed.filter((one) => typeof one === "object" && one !== null && typeof one.kind === "string") : [];
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
144
+ /** The latest sweep (of `source`, or either), or null when there has been none. */
145
+ export function lastSweep(store, source) {
146
+ const row = store.handle.prepare(`SELECT at, source, actor, result FROM storage_sweep${source === undefined ? "" : " WHERE source = ?"} ORDER BY id DESC LIMIT 1`)
147
+ .get(...(source === undefined ? [] : [source]));
148
+ if (row === undefined)
149
+ return null;
150
+ return { at: String(row["at"]), source: row["source"] === "manual" ? "manual" : "automatic", actor: String(row["actor"]), parts: parseParts(String(row["result"])) };
151
+ }
152
+ /** When the next automatic sweep is due, or null when it is off. */
153
+ export function nextSweepAt(store) {
154
+ if (storageSweepOff())
155
+ return null;
156
+ const last = store.serviceCursor(CURSOR);
157
+ return last === 0 ? null : new Date(last + SWEEP_EVERY_MS).toISOString();
158
+ }
159
+ const NOUNS = {
160
+ "test temp": ["test temp folder", "test temp folders"],
161
+ orphans: ["leftover process", "leftover processes"],
162
+ checkouts: ["finished checkout", "finished checkouts"],
163
+ runtimes: ["staged runtime", "staged runtimes"],
164
+ dependencies: ["unused dependency install", "unused dependency installs"],
165
+ };
166
+ /** What a part looks at, for "Couldn't check …". */
167
+ const CHECKED = { "test temp": "test temp folders", orphans: "processes", checkouts: "checkouts", runtimes: "staged runtimes", dependencies: "dependency installs" };
168
+ export function partWords(one) {
169
+ return `${one.count.toLocaleString("en-US")} ${NOUNS[one.kind][one.count === 1 ? 0 : 1]}`;
170
+ }
171
+ function list(items) {
172
+ return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
173
+ }
174
+ /** "Removed 120 test temp folders and 2 finished checkouts, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
175
+ export function sweepWords(parts) {
176
+ const removed = parts.filter(one => one.kind !== "orphans" && one.count > 0);
177
+ const stopped = parts.find(one => one.kind === "orphans" && one.count > 0);
178
+ const bytes = removed.reduce((sum, one) => sum + one.bytes, 0);
179
+ const failed = parts.reduce((sum, one) => sum + one.failed, 0);
180
+ const problems = parts.filter(one => one.problem !== undefined).map(one => one.kind);
181
+ const said = [
182
+ ...(removed.length === 0 ? [] : [`Removed ${list(removed.map(partWords))}${bytes > 0 ? `, about ${bytesWords(bytes)}` : ""}.`]),
183
+ ...(stopped === undefined ? [] : [`Stopped ${partWords(stopped)}.`]),
184
+ ...(failed === 0 ? [] : [`${failed} couldn't be removed.`]),
185
+ ...(problems.length === 0 ? [] : [`Couldn't check ${list(problems.map(kind => CHECKED[kind]))}.`]),
186
+ ];
187
+ return said.length === 0 ? "Nothing was due." : said.join(" ");
188
+ }
189
+ /** The details a disclosure shows: each part's paths, problems and what's left for later, one line each. */
190
+ export function sweepDetails(parts) {
191
+ const lines = [];
192
+ for (const one of parts) {
193
+ if (one.problem !== undefined)
194
+ lines.push(`${NOUNS[one.kind][1][0].toUpperCase()}${NOUNS[one.kind][1].slice(1)}: ${one.problem}`);
195
+ if (one.more === true)
196
+ lines.push(`More ${NOUNS[one.kind][1]} are left for the next sweep.`);
197
+ for (const item of one.items)
198
+ lines.push(item);
199
+ if (one.items.length > 0 && one.count > one.items.length)
200
+ lines.push(`… and ${(one.count - one.items.length).toLocaleString("en-US")} more ${NOUNS[one.kind][1]}`);
201
+ }
202
+ return lines;
203
+ }
@@ -1,8 +1,13 @@
1
1
  import { type CheckoutPlan } from "./checkout-cleanup.js";
2
+ import { type SweepRecord } from "./storage-sweep.js";
2
3
  export declare const STORAGE_CSS: string;
3
4
  export type StorageView = {
4
5
  plan: CheckoutPlan;
5
6
  csrf: string;
7
+ sweep?: {
8
+ last: SweepRecord | null;
9
+ off: boolean;
10
+ };
6
11
  };
7
12
  export declare function storageHtml(view: StorageView, notice: {
8
13
  said?: string | null;
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
3
- * preview (what goes, what stays and why, with paths) sits behind the password, and when a finished task's clean
4
- * checkout goes by itself. An instance operator's page.
3
+ * preview (what goes, what stays and why, with paths) sits behind the password, what the last storage sweep removed
4
+ * and when (its paths in a disclosure), and when a finished task's clean checkout goes by itself. An instance
5
+ * operator's page.
5
6
  */
6
7
  import { homedir } from "node:os";
7
8
  import { basename } from "node:path";
8
9
  import { previewDigest, whyWords } from "./checkout-cleanup.js";
9
10
  import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
11
+ import { sweepDetails, sweepWords } from "./storage-sweep.js";
10
12
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
13
  const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
12
14
  export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
@@ -25,7 +27,31 @@ export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:a
25
27
  `.storage form{display:grid;gap:10px;margin:0}.storage label{display:grid;gap:4px;font-size:.8125rem;font-weight:500;max-width:20rem}` +
26
28
  `.storage select,.storage input[type=password]{width:100%;box-sizing:border-box;min-height:2.125rem}.storage form button{justify-self:start;white-space:nowrap}` +
27
29
  `.storage form.setting button[type=submit],.storage .preview form.remove button[type=submit]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
28
- `@media (max-width:900px){.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
30
+ `.storage .sweep{display:grid;gap:4px}.storage .sweep p{margin:0}.storage .sweep .problem{color:var(--destructive)}` +
31
+ `.storage details.sweep-details>summary{cursor:pointer;min-height:2.25rem;display:inline-flex;align-items:center;font-size:.8125rem;color:var(--muted-foreground)}` +
32
+ `.storage details.sweep-details ul{margin:4px 0 0;padding-left:18px;font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem;color:var(--muted-foreground);overflow-wrap:anywhere}` +
33
+ `@media (max-width:900px){.storage details.sweep-details>summary{min-height:44px}.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
34
+ /** Paths under the home folder as ~/…, as the checkout list shows them. */
35
+ function homeAsTilde(line) {
36
+ const home = homedir();
37
+ return home === "" || home === "/" ? line : line.split(`${home}/`).join("~/");
38
+ }
39
+ /** "Oct 4, 3:12 AM" in this computer's time. */
40
+ function whenWords(at) {
41
+ const date = new Date(at);
42
+ return Number.isNaN(date.getTime()) ? at : date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
43
+ }
44
+ /** One line: when the last sweep ran and what it did; its paths and problems behind Details. */
45
+ function sweepHtml(sweep) {
46
+ const last = sweep.last;
47
+ if (last === null)
48
+ return `<p class="meta" data-last-sweep="">${sweep.off ? "Automatic sweep is off." : "Toolroll sweeps leftovers once a day. It hasn't run yet."}</p>`;
49
+ const who = last.source === "manual" ? `Cleaned up by ${e(last.actor)}` : "Swept";
50
+ const details = sweepDetails(last.parts);
51
+ const problem = last.parts.some(one => one.failed > 0 || one.problem !== undefined);
52
+ return `<div class="sweep" data-last-sweep="${e(last.at)}"><p${problem ? ' class="problem"' : ""}>${who} <time datetime="${e(last.at)}">${e(whenWords(last.at))}</time>. ${e(sweepWords(last.parts))}</p>` +
53
+ (details.length === 0 ? "" : `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => `<li>${e(homeAsTilde(line))}</li>`).join("")}</ul></details>`) + `</div>`;
54
+ }
29
55
  function item(one, csrf) {
30
56
  const discard = one.why === "has changes"
31
57
  ? `<details class="discard"><summary>Discard</summary><form method="post" action="/settings/storage/discard"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="path" value="${e(one.path)}">` +
@@ -58,7 +84,7 @@ export function storageHtml(view, notice) {
58
84
  const options = CLEANUP_CHOICES.map(one => `<option value="${one.value}"${one.value === plan.cleanup ? " selected" : ""}>${e(one.label)}</option>`).join("");
59
85
  return `<section class="storage">${note}` +
60
86
  `<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${e(bytesWords(plan.totalBytes))}</h2><p class="meta">${e(facts)}</p>` +
61
- `<p data-clean-bytes="${plan.freeBytes}">${e(frees)}</p>${preview}</div>` +
87
+ `<p data-clean-bytes="${plan.freeBytes}">${e(frees)}</p>${view.sweep === undefined ? "" : sweepHtml(view.sweep)}${preview}</div>` +
62
88
  `<h2>Automatic cleanup</h2>` +
63
89
  `<form method="post" action="/settings/storage" class="setting">${hidden}` +
64
90
  `<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label>` +
package/dist/storage.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Store } from "./store.js";
2
2
  /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
3
3
  export declare const CHECKOUT_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS checkout_cleanup_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
4
+ /** What each storage sweep or clean-up by hand did (storage-sweep.ts): the latest few. */
5
+ export declare const STORAGE_SWEEP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS storage_sweep (\n id INTEGER PRIMARY KEY,\n at TEXT NOT NULL,\n source TEXT NOT NULL CHECK (source IN ('automatic', 'manual')),\n actor TEXT NOT NULL,\n result TEXT NOT NULL\n);\n";
4
6
  export type CheckoutCleanup = "finished" | "2d" | "7d" | "never";
5
7
  export declare const DEFAULT_CLEANUP: CheckoutCleanup;
6
8
  export declare const CLEANUP_CHOICES: readonly {
package/dist/storage.js CHANGED
@@ -3,7 +3,9 @@
3
3
  * beside the database, by kind, and what storage retention takes care of.
4
4
  * Build checkouts and staged releases are the big ones; the worker removes a
5
5
  * finished task's clean checkout as the checkout cleanup setting says
6
- * (checkout-cleanup.ts), and a deploy keeps its last few releases.
6
+ * (checkout-cleanup.ts), and the daily storage sweep (storage-sweep.ts) takes
7
+ * what slips through: stale test temp folders, leftover processes, extra
8
+ * staged runtimes and dependency installs unused for a week.
7
9
  */
8
10
  import { spawnSync } from "node:child_process";
9
11
  import { lstatSync, readdirSync } from "node:fs";
@@ -18,6 +20,16 @@ CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
18
20
  updated_at TEXT NOT NULL
19
21
  );
20
22
  `;
23
+ /** What each storage sweep or clean-up by hand did (storage-sweep.ts): the latest few. */
24
+ export const STORAGE_SWEEP_SCHEMA = `
25
+ CREATE TABLE IF NOT EXISTS storage_sweep (
26
+ id INTEGER PRIMARY KEY,
27
+ at TEXT NOT NULL,
28
+ source TEXT NOT NULL CHECK (source IN ('automatic', 'manual')),
29
+ actor TEXT NOT NULL,
30
+ result TEXT NOT NULL
31
+ );
32
+ `;
21
33
  export const DEFAULT_CLEANUP = "finished";
22
34
  export const CLEANUP_CHOICES = [
23
35
  { value: "finished", label: "When its task is complete or cancelled", days: 0 },
@@ -124,8 +136,8 @@ export function storageReport(store, databaseFile, checkoutNote) {
124
136
  const lines = [
125
137
  { what: "Database", bytes: take(name => name.startsWith(base)) },
126
138
  { what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length, ...(checkoutNote === undefined ? {} : { note: checkoutNote }) },
127
- { what: "Shared dependencies", bytes: take(name => name === DEPS_FOLDER), count: sharedCopies(join(folder, DEPS_FOLDER)).length, note: "one install per lockfile, linked into checkouts" },
128
- { what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "a deploy keeps the release it installed, the one before and the newest few" },
139
+ { what: "Shared dependencies", bytes: take(name => name === DEPS_FOLDER), count: sharedCopies(join(folder, DEPS_FOLDER)).length, note: "one install per lockfile, linked into checkouts; one unused for a week goes" },
140
+ { what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "kept: the one running, the one before it and the newest" },
129
141
  { what: "Evidence", bytes: take(name => name === "evidence"), note: "the audit record of every run; kept" },
130
142
  { what: "Backups", bytes: take(name => name === "backups") },
131
143
  ];
package/dist/store.d.ts CHANGED
@@ -21,6 +21,14 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
21
21
  import { type CheckoutCleanup } from "./storage.js";
22
22
  import { type LedgerChainReport } from "./ledger-chain.js";
23
23
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
24
+ /** A lead reply stopped at its deadline, in the thread as plain words: what happened, what it means, one next step. The
25
+ * proposals it made before then are kept; the cost line only when an unknown cost paused chat. */
26
+ export declare function mateTimeoutNotice(keptProposals: boolean, unknownCost?: boolean): string;
27
+ /** Whether a turn's proposals stand: it answered, or it stopped at its deadline after its completed tool calls proposed them. */
28
+ export declare function mateTurnKeepsProposals(turn: {
29
+ state: string;
30
+ failureReason: string | null;
31
+ } | null): boolean;
24
32
  /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
25
33
  * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
26
34
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
@@ -6916,12 +6924,17 @@ export declare class Store {
6916
6924
  tokensIn: number;
6917
6925
  tokensOut: number;
6918
6926
  failureReason?: string;
6919
- /** Answered turns: the assistant row, written in the same transaction as the promotion. */
6927
+ /** Answered turns: the assistant row, written in the same transaction as the promotion. A timed-out turn's notice. */
6920
6928
  message?: {
6921
6929
  text: string;
6922
6930
  activity: string;
6923
6931
  };
6932
+ /** A timed-out turn keeps what its completed, validated tool calls proposed (its drafts go pending with its notice). */
6933
+ keepProposals?: boolean;
6924
6934
  }, now: Date): boolean;
6935
+ /** A turn stopped at its deadline: the proposals its completed tool calls drafted (each validated when drafted) go pending, as an
6936
+ * answered turn's do; anything the provider sent after them was never acted on. Every other failure still deletes its drafts. */
6937
+ private keepTimedOutMateProposals;
6925
6938
  /** Revocation's companion for a loop in flight (finding 5): every live
6926
6939
  * turn of the approver fails NOW, charged its whole reservation, its
6927
6940
  * generation moved so the returning loop's terminal CAS loses. */
@@ -6929,7 +6942,9 @@ export declare class Store {
6929
6942
  /** The crash sweep for turns: past the deadline, failed. The session is
6930
6943
  * charged the settled sum of the steps that answered — unless any step
6931
6944
  * is unproven (dispatched and never settled, or latched), in which case
6932
- * the WHOLE reservation is charged (finding 1). Drafts are deleted. */
6945
+ * the WHOLE reservation is charged (finding 1). Like a turn that stopped
6946
+ * at its own deadline, it keeps the proposals its completed tool calls
6947
+ * drafted and says, in the thread, that the reply took too long. */
6933
6948
  sweepStaleMateTurns(now: Date): number;
6934
6949
  /** A task_ref by its row id: the external id and repo — for decisions, which hang off runs. */
6935
6950
  refById(id: number): {
package/dist/store.js CHANGED
@@ -71,7 +71,7 @@ import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
71
71
  import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
72
72
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
73
73
  import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
74
- import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
74
+ import { CHECKOUT_CLEANUP_SCHEMA, STORAGE_SWEEP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
75
75
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
76
76
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
77
77
  import { configBase, envValue, namedPath } from "./names.js";
@@ -253,6 +253,19 @@ CREATE TABLE IF NOT EXISTS project_mute (
253
253
  PRIMARY KEY (account, repo)
254
254
  );
255
255
  `;
256
+ /** A lead reply stopped at its deadline, in the thread as plain words: what happened, what it means, one next step. The
257
+ * proposals it made before then are kept; the cost line only when an unknown cost paused chat. */
258
+ export function mateTimeoutNotice(keptProposals, unknownCost = false) {
259
+ return [
260
+ unknownCost ? "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused." : "The reply took too long and was stopped.",
261
+ keptProposals ? "What it proposed is below." : null,
262
+ unknownCost ? "Confirm the cost on the Chat page to turn chat back on." : "Send your message again, or ask for less at once.",
263
+ ].filter(part => part !== null).join(" ");
264
+ }
265
+ /** Whether a turn's proposals stand: it answered, or it stopped at its deadline after its completed tool calls proposed them. */
266
+ export function mateTurnKeepsProposals(turn) {
267
+ return turn !== null && (turn.state === "answered" || turn.state === "failed" && (turn.failureReason === "timeout" || turn.failureReason === "crashed"));
268
+ }
256
269
  /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
257
270
  * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
258
271
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
@@ -3992,6 +4005,8 @@ function initializeStore(db, file) {
3992
4005
  // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
3993
4006
  widenRetentionSchema(db);
3994
4007
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
4008
+ // The daily storage sweep's record (no version bump: a new table, nothing carried over).
4009
+ db.exec(STORAGE_SWEEP_SCHEMA);
3995
4010
  db.exec(BACKUP_SCHEMA);
3996
4011
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3997
4012
  db.exec(POLICY_SCHEMA);
@@ -20282,12 +20297,23 @@ export class Store {
20282
20297
  }
20283
20298
  }
20284
20299
  else {
20285
- this.discardMateProposals(id);
20300
+ if (outcome.keepProposals === true)
20301
+ this.keepTimedOutMateProposals(id);
20302
+ else
20303
+ this.discardMateProposals(id);
20286
20304
  this.discardMateTurnEvidence(id);
20305
+ if (outcome.message !== undefined) {
20306
+ this.appendMateMessage({ thread: Number(before["thread"]), turn: id, role: "assistant", text: outcome.message.text, activity: outcome.message.activity }, now);
20307
+ }
20287
20308
  }
20288
20309
  return true;
20289
20310
  });
20290
20311
  }
20312
+ /** A turn stopped at its deadline: the proposals its completed tool calls drafted (each validated when drafted) go pending, as an
20313
+ * answered turn's do; anything the provider sent after them was never acted on. Every other failure still deletes its drafts. */
20314
+ keepTimedOutMateProposals(turn) {
20315
+ return Number(this.db.prepare("UPDATE mate_proposal SET state = 'pending' WHERE turn = ? AND state = 'drafting'").run(turn).changes);
20316
+ }
20291
20317
  /** Revocation's companion for a loop in flight (finding 5): every live
20292
20318
  * turn of the approver fails NOW, charged its whole reservation, its
20293
20319
  * generation moved so the returning loop's terminal CAS loses. */
@@ -20309,12 +20335,14 @@ export class Store {
20309
20335
  /** The crash sweep for turns: past the deadline, failed. The session is
20310
20336
  * charged the settled sum of the steps that answered — unless any step
20311
20337
  * is unproven (dispatched and never settled, or latched), in which case
20312
- * the WHOLE reservation is charged (finding 1). Drafts are deleted. */
20338
+ * the WHOLE reservation is charged (finding 1). Like a turn that stopped
20339
+ * at its own deadline, it keeps the proposals its completed tool calls
20340
+ * drafted and says, in the thread, that the reply took too long. */
20313
20341
  sweepStaleMateTurns(now) {
20314
20342
  return this.transact(() => {
20315
20343
  this.sweepStaleChatTurns(now);
20316
20344
  const rows = this.db
20317
- .prepare("SELECT id, session, reserved_microusd AS reserved FROM mate_turn WHERE state IN ('queued','running') AND deadline_at < ?")
20345
+ .prepare("SELECT id, session, thread, reserved_microusd AS reserved FROM mate_turn WHERE state IN ('queued','running') AND deadline_at < ?")
20318
20346
  .all(now.toISOString());
20319
20347
  for (const row of rows) {
20320
20348
  const id = Number(row["id"]);
@@ -20329,8 +20357,12 @@ export class Store {
20329
20357
  .prepare("UPDATE mate_turn SET state = 'failed', failure_reason = 'crashed', generation = generation + 1, settled_microusd = ?, finished_at = ? WHERE id = ?")
20330
20358
  .run(spent, now.toISOString(), id);
20331
20359
  this.db.prepare("UPDATE mate_session SET spent_microusd = spent_microusd + ? WHERE id = ?").run(spent, Number(row["session"]));
20332
- this.discardMateProposals(id);
20360
+ const kept = this.keepTimedOutMateProposals(id);
20333
20361
  this.discardMateTurnEvidence(id);
20362
+ const latched = this.db.prepare("SELECT 1 AS hit FROM chat_turn WHERE mate_turn = ? AND unknown_spend = 1 AND acknowledged_at IS NULL LIMIT 1").get(id) !== undefined;
20363
+ const open = this.db.prepare("SELECT 1 AS hit FROM mate_thread WHERE id = ? AND closed_at IS NULL").get(Number(row["thread"])) !== undefined;
20364
+ if (open)
20365
+ this.appendMateMessage({ thread: Number(row["thread"]), turn: id, role: "assistant", text: mateTimeoutNotice(kept > 0, latched), activity: null }, now);
20334
20366
  }
20335
20367
  return rows.length;
20336
20368
  });
@@ -10,6 +10,8 @@ export type SubscriptionMateRequest = {
10
10
  history: readonly MateHistoryMessage[];
11
11
  tools: readonly MateToolSchema[];
12
12
  timeoutMs: number;
13
+ /** The turn's own deadline: aborting it ends the harness's whole process group. */
14
+ signal?: AbortSignal;
13
15
  /** The reply as it is written, cumulative, when the harness can stream it
14
16
  * (Claude). Display only: the finished answer is still parsed whole. */
15
17
  onText?: (text: string) => void;
@@ -23,7 +25,7 @@ export type SubscriptionMateRunner = (request: SubscriptionMateRequest) => Promi
23
25
  }>;
24
26
  type CommandRunner = (file: string, args: readonly string[], options: Parameters<typeof run>[2]) => Promise<ExecResult>;
25
27
  /** The model-visible request. Tool results are already redacted by mateView. */
26
- export declare function composeSubscriptionMatePrompt(request: Omit<SubscriptionMateRequest, "provider" | "model" | "timeoutMs">): string;
28
+ export declare function composeSubscriptionMatePrompt(request: Omit<SubscriptionMateRequest, "provider" | "model" | "timeoutMs" | "signal">): string;
27
29
  /** Strictly turn the harness's structured output into the provider-neutral answer. */
28
30
  export declare function parseSubscriptionMateAnswer(text: string, usage?: {
29
31
  tokensIn?: unknown;
@@ -224,6 +224,7 @@ export async function performSubscriptionMateRequest(request, runner = run) {
224
224
  cwd: dir,
225
225
  stdin: prompt,
226
226
  timeoutMs: request.timeoutMs,
227
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
227
228
  // Events repeat the answer (deltas, the message, the result line).
228
229
  maxBuffer: streaming ? RESPONSE_CAP_BYTES * 16 : RESPONSE_CAP_BYTES,
229
230
  omitEnv: ALL_CREDENTIAL_ENV,
package/dist/surface.js CHANGED
@@ -114,7 +114,7 @@ export const COMMAND_GUIDE = [
114
114
  mutation: spec.mutation ? 'keyed' : 'none', inputSchema: spec.inputSchema,
115
115
  flags: Object.entries(sessionCliFlags(spec)).map(([name, arity]) => ({ name, takesValue: arity === 'value', meaning: name === 'key' ? 'keep the same key when reconciling; inspect after unknown delivery, never blind-retry' : `see session ${spec.operation} --help` })),
116
116
  })),
117
- ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
117
+ ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
118
118
  ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
119
119
  // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
120
120
  ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags,
@@ -338,6 +338,7 @@ export const COMMAND_GUIDE = [
338
338
  operator("runner retire", "retire a worker"),
339
339
  operator("runner bind", "replace which repositories a worker may build in"),
340
340
  operator("runner capacity", "set how many tasks a worker runs at once; running work carries on, the new number applies at the next claim"),
341
+ operator("project checks", "show or set a project's batch checks (--batch on|off, off by default): results that finish within 10 minutes share one full check on a temporary batch commit; nothing is merged into a real branch"),
341
342
  operator("project concurrency", "set how many of a project's tasks build at once (default 2, never past the worker's capacity); running work carries on"),
342
343
  operator("coordinator mint", "mint the MCP filing credential — repo-bound, rate-limited, token shown once"),
343
344
  operator("coordinator revoke", "revoke an MCP filing credential"),
@@ -53,7 +53,20 @@ export type ChecksFact = {
53
53
  level?: "quick" | "full" | "off" | null;
54
54
  /** A follow-up check (Run checks) waiting or running on this commit. */
55
55
  running?: "quick" | "full" | null;
56
+ /** A batch check (batch-checks.ts): waiting for its batch, or how it was checked. */
57
+ batch?: ChecksBatch | null;
56
58
  };
59
+ /** `waiting`: the result waits to be checked with others. Otherwise how its check ran: `together` on the
60
+ * temporary batch commit `tested` with `peers`; `split` (its batch failed), `conflict` or `alone` on its own commit. */
61
+ export type ChecksBatch = {
62
+ state: "waiting" | "together" | "split" | "conflict" | "alone";
63
+ tested: string | null;
64
+ peers: string[];
65
+ };
66
+ /** "T2", "T2 and T3", "T2, T3 and 2 more": short enough for one line. The full list is on the result. */
67
+ export declare function peerWords(peers: readonly string[], shown?: number): string;
68
+ /** "Checked together with T2 on 1a2b3c4": every surface says a shared check the same way, naming the commit it tested. */
69
+ export declare function checkedTogetherWords(batch: ChecksBatch): string | null;
57
70
  export type PullRequestFact = {
58
71
  state: "none" | "opening" | "open" | "merged" | "closed" | "failed";
59
72
  number: number | null;