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
@@ -32,6 +32,10 @@ function role(value) {
32
32
  refuse('invalid', 'Choose Viewer, Contributor or Manager.');
33
33
  return value;
34
34
  }
35
+ /** A failed turn's words for its message: none when the turn said why in the thread itself (it stopped at its deadline). */
36
+ function savedFailure(reason) {
37
+ return reason === 'timeout' || reason === 'crashed' ? null : String(reason ?? 'The saved turn failed.');
38
+ }
35
39
  function hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
36
40
  function stable(value) {
37
41
  if (Array.isArray(value))
@@ -461,7 +465,7 @@ export class TeamLeads {
461
465
  const uncertain = this.deliveryUncertain(Number(row['turn_id']));
462
466
  const cancelled = Number(row['stop_requested']) === 1 || !this.authorStillAllowed(row);
463
467
  const status = uncertain ? 'uncertain' : cancelled ? 'cancelled' : row['turn_state'] === 'answered' ? 'answered' : 'failed';
464
- const error = uncertain ? 'Delivery could not be confirmed. Inspect saved activity before continuing.' : cancelled ? 'Stopped or conversation access changed.' : status === 'failed' ? String(row['failure_reason'] ?? 'The saved turn failed.') : null;
468
+ const error = uncertain ? 'Delivery could not be confirmed. Inspect saved activity before continuing.' : cancelled ? 'Stopped or conversation access changed.' : status === 'failed' ? savedFailure(row['failure_reason']) : null;
465
469
  const result = this.db.prepare("UPDATE team_message SET status=?,error=?,generation=generation+1,revision=revision+1 WHERE message=? AND status='running' AND generation=?").run(status, error, Number(row['message']), Number(row['generation']));
466
470
  if (Number(result.changes) === 1) {
467
471
  changed++;
@@ -480,7 +484,7 @@ export class TeamLeads {
480
484
  for (const row of rows) {
481
485
  const turn = row['turn_id'] === null ? null : this.store.getMateTurn(Number(row['turn_id']));
482
486
  const status = turn?.state === 'answered' ? 'answered' : turn?.state === 'failed' && !this.deliveryUncertain(turn.id) ? 'failed' : 'uncertain';
483
- const error = status === 'uncertain' ? 'Delivery unconfirmed after the previous worker stopped. Inspect activity before continuing.' : status === 'failed' ? (turn?.failureReason ?? 'The saved turn failed.') : null;
487
+ const error = status === 'uncertain' ? 'Delivery unconfirmed after the previous worker stopped. Inspect activity before continuing.' : status === 'failed' ? savedFailure(turn?.failureReason) : null;
484
488
  this.db.prepare('UPDATE team_message SET status=?,generation=generation+1,revision=revision+1,error=? WHERE message=?').run(status, error, Number(row['message']));
485
489
  this.event(String(row['lead']), String(row['conversation']), status === 'uncertain' ? 'delivery-unconfirmed' : 'message-reconciled', String(row['author']), now);
486
490
  }
@@ -166,9 +166,10 @@ export function createTeamRuntime(options) {
166
166
  const state = admission(claim.actor, claim.conversationId);
167
167
  if (domain.defer(claim, result.message, clock()))
168
168
  waiting.set(claim.messageId, state.stamp);
169
+ // A turn that saved its outcome in the thread (stopped at its deadline) is said there, not again on the message.
169
170
  }
170
171
  else
171
- domain.finish(claim, { status: 'unknownSpend' in result && result.unknownSpend ? 'uncertain' : 'failed', error: result.message, ...('turn' in result ? { turnId: result.turn } : {}) }, clock());
172
+ domain.finish(claim, { status: 'unknownSpend' in result && result.unknownSpend ? 'uncertain' : 'failed', ...('saved' in result && result.saved ? {} : { error: result.message }), ...('turn' in result ? { turnId: result.turn } : {}) }, clock());
172
173
  }
173
174
  catch {
174
175
  // An exception may have followed native admission. Preserve an explicit
@@ -1,5 +1,13 @@
1
+ /** In a suite's temp root: who made it (scripts/suite-lifecycle.mjs). */
2
+ export declare const OWNER_FILE = ".toolroll-temp-owner";
3
+ export type TempOwner = {
4
+ pid: number;
5
+ startedAt: number;
6
+ };
1
7
  /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
2
8
  export declare const TEST_TEMP_PREFIXES: readonly string[];
9
+ /** Who made a temp root, from its owner file; null when it has none (older suites, scripts, Playwright). */
10
+ export declare function tempOwner(path: string): TempOwner | null;
3
11
  export declare function isTestTemp(name: string): boolean;
4
12
  /** Where they collect: the temp folder (and /tmp, where some scripts write). */
5
13
  export declare function tempRoots(): string[];
@@ -12,8 +20,18 @@ export declare function testTempFolders(roots: readonly string[], now: Date): {
12
20
  all: TempLeftover[];
13
21
  stale: TempLeftover[];
14
22
  };
15
- /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
16
- export declare function removeStaleTestTemp(roots: readonly string[], now: Date): {
23
+ export type RemoveOptions = {
24
+ /** Whether a temp root's owner (by its owner file) is still running: its root stays, however old it looks. */
25
+ ownerAlive?: (owner: TempOwner) => boolean;
26
+ /** At most this many go in one pass (a sweep after weeks can find 100,000); `more` says some were left. */
27
+ max?: number;
28
+ };
29
+ /**
30
+ * Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes (still a
31
+ * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone).
32
+ */
33
+ export declare function removeStaleTestTemp(roots: readonly string[], now: Date, options?: RemoveOptions): {
17
34
  removed: string[];
18
35
  failed: string[];
36
+ more: boolean;
19
37
  };
package/dist/test-temp.js CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
- * Test temp folders (`toolroll storage`, `storage clean`): what the unit tests, journeys and scripts make in the
3
- * system temp folder. The tests delete their own (each run gets one temp root that its global teardown removes:
4
- * test/temp-root.ts); a run that was killed, and older versions, leave folders behind. `toolroll storage` counts them
5
- * and `storage clean` removes the ones nothing has touched for a day.
2
+ * Test temp folders (`toolroll storage`, `storage clean`, the plane's daily storage sweep): what the unit tests,
3
+ * journeys and scripts make in the system temp folder. The suites delete their own on pass, failure, timeout and
4
+ * interrupt (scripts/suite-lifecycle.mjs, test/temp-root.ts); a run killed outright, a proof that keeps its output and
5
+ * older versions leave folders behind. `toolroll storage` counts them; the daily sweep (storage-sweep.ts) and
6
+ * `storage clean` remove the ones nothing has touched for a day, never one whose owner is still running.
6
7
  *
7
8
  * Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
8
9
  * so), and Playwright's own browser profiles.
9
10
  */
10
- import { lstatSync, readdirSync, rmSync } from "node:fs";
11
+ import { spawnSync } from "node:child_process";
12
+ import { lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
11
13
  import { tmpdir } from "node:os";
12
- import { join } from "node:path";
14
+ import { dirname, join, resolve } from "node:path";
13
15
  const DAY_MS = 86_400_000;
16
+ /** In a suite's temp root: who made it (scripts/suite-lifecycle.mjs). */
17
+ export const OWNER_FILE = ".toolroll-temp-owner";
14
18
  /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
15
19
  export const TEST_TEMP_PREFIXES = [
16
20
  "so-", "standing-orders-", "toolroll-", "no-wt", "epoch", "cancel-floor",
@@ -18,10 +22,21 @@ export const TEST_TEMP_PREFIXES = [
18
22
  "flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
19
23
  "proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
20
24
  "task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
21
- "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-", "lead-people-", "sizing-repo-",
25
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-", "lead-people-", "sizing-repo-", "batch-checks-", "deps-identity-",
22
26
  // Playwright's browser profiles and artifacts.
23
27
  "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
28
  ];
29
+ /** Who made a temp root, from its owner file; null when it has none (older suites, scripts, Playwright). */
30
+ export function tempOwner(path) {
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(join(path, OWNER_FILE), "utf8"));
33
+ const startedAt = typeof parsed.startedAt === "string" ? Date.parse(parsed.startedAt) : NaN;
34
+ return Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 1 && Number.isFinite(startedAt) ? { pid: Number(parsed.pid), startedAt } : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
25
40
  export function isTestTemp(name) {
26
41
  return TEST_TEMP_PREFIXES.some(prefix => name.startsWith(prefix));
27
42
  }
@@ -79,20 +94,45 @@ export function testTempFolders(roots, now) {
79
94
  }
80
95
  return { all, stale: all.filter(one => now.getTime() - one.touchedAt > DAY_MS) };
81
96
  }
82
- /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
83
- export function removeStaleTestTemp(roots, now) {
97
+ /**
98
+ * Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes (still a
99
+ * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone).
100
+ */
101
+ export function removeStaleTestTemp(roots, now, options = {}) {
84
102
  const removed = [];
85
103
  const failed = [];
86
- for (const one of testTempFolders(roots, now).stale) {
104
+ const stale = testTempFolders(roots, now).stale;
105
+ const max = options.max ?? Infinity;
106
+ const inside = new Set(roots.map(root => resolve(root)));
107
+ for (const one of stale) {
108
+ if (removed.length + failed.length >= max)
109
+ return { removed, failed, more: true };
87
110
  try {
88
- if (now.getTime() - (lstatSync(one.path).isDirectory() ? touchedAt(one.path) : lstatSync(one.path).mtimeMs) <= DAY_MS)
111
+ const stat = lstatSync(one.path);
112
+ if (stat.isSymbolicLink() || !inside.has(resolve(dirname(one.path))))
113
+ continue;
114
+ if (now.getTime() - (stat.isDirectory() ? touchedAt(one.path) : stat.mtimeMs) <= DAY_MS)
115
+ continue;
116
+ const owner = stat.isDirectory() ? tempOwner(one.path) : null;
117
+ if (owner !== null && (options.ownerAlive ?? (() => false))(owner))
89
118
  continue;
90
- rmSync(one.path, { recursive: true, force: true, maxRetries: 3 });
119
+ removeTree(one.path);
91
120
  removed.push(one.path);
92
121
  }
93
122
  catch {
94
123
  failed.push(one.path);
95
124
  }
96
125
  }
97
- return { removed, failed };
126
+ return { removed, failed, more: false };
127
+ }
128
+ /** rm -r; a tree a test made read-only (a shared dependency copy, a locked fixture) is made writable first. */
129
+ function removeTree(path) {
130
+ try {
131
+ rmSync(path, { recursive: true, force: true, maxRetries: 3 });
132
+ return;
133
+ }
134
+ catch { /* read-only inside */ }
135
+ if (process.platform !== "win32")
136
+ spawnSync("chmod", ["-R", "u+w", path], { timeout: 120_000 });
137
+ rmSync(path, { recursive: true, force: true, maxRetries: 3 });
98
138
  }
@@ -36,6 +36,7 @@ import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, re
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
38
  import { isNewer, REGISTRY } from "./releases.js";
39
+ import { markNeverIndex } from "./never-index.js";
39
40
  /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
40
41
  * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
41
42
  function sqlite() {
@@ -699,6 +700,7 @@ function newJournal(kind, o, now) {
699
700
  const id = randomUUID(), stageDir = join(resolve(o.stateDir), "staged-upgrades", `${kind === "update" ? "release" : "rollback"}-${o.to.version}-${id.slice(0, 8)}`);
700
701
  mkdirSync(stageDir, { recursive: true, mode: 0o700 });
701
702
  chmodSync(stageDir, 0o700);
703
+ markNeverIndex(dirname(stageDir));
702
704
  return { version: 1, id, kind, stateDir: resolve(o.stateDir), databaseFile: o.databaseFile, stageDir, from: o.from, to: o.to, when: o.when, at: o.at, actor: o.actor, phase: "scheduled", detail: "", steps: [], startedAt: now.toISOString(), updatedAt: now.toISOString() };
703
705
  }
704
706
  function assertNoneActive(stateDir) {
@@ -371,7 +371,9 @@ function sentenceOf(text) {
371
371
  * the saved log. Null when the log printed nothing. */
372
372
  export function lastErrorLineOf(log) {
373
373
  const lines = log.split("\n");
374
- const printed = (text) => text.trim() !== "" && !/^\$ /.test(text) && !/^\(exit -?[0-9]+\)$/.test(text.trim()) && !/^--- (stdout|stderr) ---$/.test(text.trim());
374
+ const printed = (text) => text.trim() !== "" && !/^\$ /.test(text) && !/^\(exit -?[0-9]+\)$/.test(text.trim()) && !/^--- (stdout|stderr) ---$/.test(text.trim())
375
+ // A section's own header ("=== Project check · attempt 1 ===") is the log's, never the check's output.
376
+ && !/^=== .* ===$/.test(text.trim());
375
377
  const stderr = lines.findIndex(text => text.trim() === "--- stderr ---");
376
378
  const pick = (from, to) => {
377
379
  let last = null;
package/dist/worktree.js CHANGED
@@ -31,6 +31,7 @@ import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
31
  import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
32
32
  import { parseHandoff } from "./decision.js";
33
33
  import { diskBytes } from "./storage.js";
34
+ import { markNeverIndex } from "./never-index.js";
34
35
  /**
35
36
  * The note a lease leaves in the checkout, naming the process holding it.
36
37
  *
@@ -337,6 +338,14 @@ export class WorktreePool {
337
338
  const branchExists = request.base !== undefined && request.reuseBranch === true &&
338
339
  (await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${request.branch}`], READ_ONLY)).code === 0;
339
340
  const fromBase = request.base !== undefined && !branchExists;
341
+ // Spotlight leaves the checkouts alone: the marker sits in the pool and the project's folder, never inside a
342
+ // checkout, where a build's `git add -A` would commit it (never-index.ts).
343
+ try {
344
+ mkdirSync(dirname(path), { recursive: true });
345
+ }
346
+ catch { /* git reports it */ }
347
+ markNeverIndex(this.options.root);
348
+ markNeverIndex(dirname(path));
340
349
  const add = await this.git(request.repo, [
341
350
  "worktree",
342
351
  "add",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.31",
3
+ "version": "0.9.32",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"