toolroll 0.9.30 → 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 (61) hide show
  1. package/CHANGELOG.md +35 -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/flow-gallery-ui.d.ts +14 -9
  19. package/dist/flow-gallery-ui.js +71 -29
  20. package/dist/flow-gallery.d.ts +26 -2
  21. package/dist/flow-gallery.js +182 -7
  22. package/dist/lead-capabilities.d.ts +0 -7
  23. package/dist/lead-capabilities.js +11 -1
  24. package/dist/mate-doors.js +2 -2
  25. package/dist/mate.d.ts +6 -2
  26. package/dist/mate.js +63 -38
  27. package/dist/mcp-connect.d.ts +3 -1
  28. package/dist/mcp-connect.js +11 -6
  29. package/dist/never-index.d.ts +3 -0
  30. package/dist/never-index.js +21 -0
  31. package/dist/operate.js +86 -2
  32. package/dist/orphan-sweep.d.ts +63 -0
  33. package/dist/orphan-sweep.js +235 -0
  34. package/dist/result-follow-ups.d.ts +22 -2
  35. package/dist/result-follow-ups.js +44 -10
  36. package/dist/serve.js +52 -19
  37. package/dist/shared-deps.d.ts +18 -2
  38. package/dist/shared-deps.js +129 -15
  39. package/dist/staged-runtimes.d.ts +42 -0
  40. package/dist/staged-runtimes.js +191 -0
  41. package/dist/storage-sweep.d.ts +50 -0
  42. package/dist/storage-sweep.js +203 -0
  43. package/dist/storage-ui.d.ts +5 -0
  44. package/dist/storage-ui.js +30 -4
  45. package/dist/storage.d.ts +2 -0
  46. package/dist/storage.js +15 -3
  47. package/dist/store.d.ts +17 -2
  48. package/dist/store.js +37 -5
  49. package/dist/subscription-chat.d.ts +3 -1
  50. package/dist/subscription-chat.js +1 -0
  51. package/dist/surface.js +1 -0
  52. package/dist/task-status.d.ts +13 -0
  53. package/dist/task-status.js +37 -3
  54. package/dist/team-leads.js +6 -2
  55. package/dist/team-runtime.js +2 -1
  56. package/dist/test-temp.d.ts +20 -2
  57. package/dist/test-temp.js +53 -13
  58. package/dist/toolroll-update.js +2 -0
  59. package/dist/workspace-ui.js +3 -1
  60. package/dist/worktree.js +9 -0
  61. package/package.json +1 -1
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
@@ -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;
@@ -26,6 +26,21 @@ export const CHECKS_LABEL = "Project checks";
26
26
  export const PR_CI_LABEL = "PR CI";
27
27
  /** The one verb for opening a saved result, on every surface. */
28
28
  export const OPEN_RESULT = "Open result";
29
+ /** "T2", "T2 and T3", "T2, T3 and 2 more": short enough for one line. The full list is on the result. */
30
+ export function peerWords(peers, shown = 2) {
31
+ if (peers.length <= shown)
32
+ return peers.length <= 1 ? peers.join("") : `${peers.slice(0, -1).join(", ")} and ${peers.at(-1)}`;
33
+ return `${peers.slice(0, shown).join(", ")} and ${peers.length - shown} more`;
34
+ }
35
+ /** "Checked together with T2 on 1a2b3c4": every surface says a shared check the same way, naming the commit it tested. */
36
+ export function checkedTogetherWords(batch) {
37
+ const sha = short(batch.tested);
38
+ return batch.state === "together" && batch.peers.length > 0 && sha !== null ? `Checked together with ${peerWords(batch.peers)} on ${sha}` : null;
39
+ }
40
+ const BATCH_WHY = {
41
+ split: "Its batch check failed, so it was checked on its own.",
42
+ conflict: "It conflicted with another result when merged for a batch check, so it was checked on its own.",
43
+ };
29
44
  const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
30
45
  export function headlineOf(facts) {
31
46
  switch (facts.stage) {
@@ -64,8 +79,15 @@ function sentenceOf(headline, facts) {
64
79
  return "The report is ready to read. Read it, then mark it complete.";
65
80
  if (facts.checks?.level === "off" && facts.checks.status !== "passed")
66
81
  return "Checks are off for this project. Review the change, then mark it complete.";
67
- if (facts.checks?.status === "passed")
82
+ if (facts.checks?.status === "passed") {
83
+ // The Checks row names the others and the batch commit; the sentence doesn't repeat them.
84
+ const peers = facts.checks.batch?.state === "together" ? facts.checks.batch.peers.length : 0;
85
+ if (peers > 0)
86
+ return `Checks passed together with ${peers === 1 ? "1 other result" : `${peers} other results`}. Review the change, then mark it complete.`;
68
87
  return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} passed${sha === null ? "" : ` on ${sha}`}. Review the change, then mark it complete.`;
88
+ }
89
+ if (facts.checks?.batch?.state === "waiting")
90
+ return "Its full check runs with other results within 10 minutes. Review the change, then mark it complete.";
69
91
  // Never claim a check that isn't known to have passed.
70
92
  if (facts.checks == null)
71
93
  return "Review the change, then mark it complete.";
@@ -93,8 +115,19 @@ function detailsOf(headline, facts) {
93
115
  const checksHref = facts.links?.checks ?? facts.links?.result ?? null;
94
116
  const runChecks = facts.links?.runChecks === undefined ? null : { label: "Run checks", href: facts.links.runChecks };
95
117
  const quick = checks.level === "quick";
96
- if (checks.running != null)
118
+ const batch = checks.batch ?? null;
119
+ const together = batch === null ? null : checkedTogetherWords(batch);
120
+ const batchWhy = batch === null ? null : BATCH_WHY[batch.state] ?? null;
121
+ if (batch?.state === "waiting")
122
+ row("checks", CHECKS_LABEL, "Waiting to check with other results", "running", { href: checksHref });
123
+ else if (checks.running != null)
97
124
  row("checks", CHECKS_LABEL, `${checks.running === "quick" ? "Quick" : "Full"} checks running`, "running", { href: checksHref });
125
+ else if (checks.status === "passed" && together !== null)
126
+ row("checks", CHECKS_LABEL, together, "ok", { href: checksHref });
127
+ else if (checks.status === "passed" && batchWhy !== null)
128
+ row("checks", CHECKS_LABEL, `Passed on its own${sha === null ? "" : ` on ${sha}`}`, "ok", { href: checksHref, why: batchWhy });
129
+ else if (checks.status === "failed" && batchWhy !== null)
130
+ row("checks", CHECKS_LABEL, `Failed on its own${checks.exitCode === null ? "" : ` (exit ${checks.exitCode})`}`, problem(headline), headline === "Failed" ? { href: checksHref, why: batchWhy } : { action: { label: "See what failed", href: checksHref }, why: batchWhy });
98
131
  else if (checks.status === "passed")
99
132
  row("checks", CHECKS_LABEL, `${quick ? "Quick checks passed" : "Passed"}${sha === null ? "" : ` on ${sha}`}`, "ok", quick && runChecks !== null && headline !== "Complete" ? { href: checksHref, action: { label: "Run full checks", href: runChecks.href } } : { href: checksHref });
100
133
  else if (checks.status === "failed")
@@ -320,7 +353,8 @@ export function assignmentStatusFacts(assignment, options = {}) {
320
353
  const report = receipt?.completionKind === "research-report";
321
354
  const checks = receipt === null || !withResult ? null
322
355
  : { status: receipt.checks.status, exitCode: receipt.checks.exitCode, head: receipt.head,
323
- ...(receipt.checks.level == null ? {} : { level: receipt.checks.level }), ...(receipt.checks.running == null ? {} : { running: receipt.checks.running }) };
356
+ ...(receipt.checks.level == null ? {} : { level: receipt.checks.level }), ...(receipt.checks.running == null ? {} : { running: receipt.checks.running }),
357
+ ...(receipt.checks.batch == null ? {} : { batch: receipt.checks.batch }) };
324
358
  const publication = assignment.publication;
325
359
  const pullRequest = options.pullRequest !== undefined ? options.pullRequest
326
360
  : publication === null ? (withResult && !report ? { state: "none", number: null, url: null, ci: null, error: null } : null)
@@ -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.30",
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"