toolroll 0.9.61 → 0.9.62

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.
@@ -7,6 +7,7 @@ import { gateWords } from "../approval-policy.js";
7
7
  import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml } from "../assignment-ui.js";
8
8
  import {} from "../assignment.js";
9
9
  import { holdOwnerWords } from "../board.js";
10
+ import { baseStandingWords } from "../task-base.js";
10
11
  import {} from "../browser-workspace.js";
11
12
  import { permissionPlainWords } from "../chat-decide.js";
12
13
  import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
@@ -2108,6 +2109,8 @@ export function taskBodyParts(data) {
2108
2109
  work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
2109
2110
  if (data.repo !== null)
2110
2111
  work.push({ label: "Project", parts: [projectName(data.repo)] });
2112
+ if (data.base != null)
2113
+ work.push({ label: "Base", parts: [baseStandingWords(data.base)] });
2111
2114
  work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
2112
2115
  : data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
2113
2116
  if (scope !== null)
@@ -104,6 +104,7 @@ import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as
104
104
  import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
105
105
  import { ssoSettingsHtml } from "../sso-ui.js";
106
106
  import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
107
+ import { sweepSchedule } from "../storage-sweep-schedule.js";
107
108
  import { storageHtml } from "../storage-ui.js";
108
109
  import { landedSummary } from "../landed-cleanup.js";
109
110
  import { bytesWords, parseCleanup } from "../storage.js";
@@ -302,7 +303,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
302
303
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
303
304
  const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/project"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
304
305
  const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
305
- const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml(projectViewOf(chosen, who), notice));
306
+ const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml({ ...projectViewOf(chosen, who), remoteUrl: store.projectRemote(chosen) }, notice));
306
307
  return sendScreen(response, 200, screen("Project", html `<p><a href="/settings">Settings</a></p><h1>Project</h1>${selector}${content}`, { chrome: chromeFor(chosen || null, "settings") }));
307
308
  }
308
309
  // v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
@@ -398,7 +399,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
398
399
  if (databaseFile === null)
399
400
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
400
401
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
401
- const sweep = { last: lastSweep(store), off: storageSweepOff() };
402
+ const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
402
403
  return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store) }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
403
404
  }
404
405
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
@@ -1,3 +1,4 @@
1
+ import { taskBaseOf } from "../task-base.js";
1
2
  import { assignmentPresentationOf, historicalAssessmentReason } from '../assignment-presentation.js';
2
3
  import { html, joinHtml } from "../html.js";
3
4
  import { assignmentStatusOf, assignmentWithEvidence } from '../assignment-ui.js';
@@ -482,6 +483,7 @@ export function createTaskViews(runtime) {
482
483
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
483
484
  })(),
484
485
  steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
486
+ base: taskBaseOf(runtime.store, taskId),
485
487
  steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
486
488
  // "publishes as" reads ONLY publicationGrantFor(repo) — the grant
487
489
  // the publisher would act under — never listGrants(), which is
@@ -29,6 +29,13 @@ export type SweepSupervisor = {
29
29
  };
30
30
  export declare const SWEEP_TIMEOUT_MS: number;
31
31
  export declare const SWEEP_KILL_GRACE_MS = 5000;
32
+ /** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
33
+ export declare const SWEEP_NICE = 10;
34
+ /** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
35
+ export declare function sweepLaunch(platform?: NodeJS.Platform, exists?: (path: string) => boolean): {
36
+ execPath: string;
37
+ prefix: string[];
38
+ };
32
39
  /** Owns one child without lending its lifetime to a reconcile pass or its database connection. */
33
40
  export declare function createSweepSupervisor(options?: {
34
41
  timeoutMs?: number;
@@ -1,5 +1,7 @@
1
1
  /** The storage child's bounded IPC protocol. No paths or process arguments are written to logs. */
2
2
  import { fork } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { setPriority } from "node:os";
3
5
  import { resolve } from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
7
  const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -23,13 +25,28 @@ export function sweepResponse(value, request) {
23
25
  }
24
26
  export const SWEEP_TIMEOUT_MS = 20 * 60_000;
25
27
  export const SWEEP_KILL_GRACE_MS = 5_000;
28
+ /** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
29
+ export const SWEEP_NICE = 10;
30
+ const TASKPOLICY = "/usr/sbin/taskpolicy";
31
+ /** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
32
+ export function sweepLaunch(platform = process.platform, exists = existsSync) {
33
+ return platform === "darwin" && exists(TASKPOLICY) ? { execPath: TASKPOLICY, prefix: ["-b", process.execPath] } : { execPath: process.execPath, prefix: [] };
34
+ }
26
35
  /** Only source checkouts need tsx; installed builds use the sibling child and the same Node. */
27
36
  function forkSweep() {
28
37
  const source = import.meta.url.endsWith(".ts");
38
+ const launch = sweepLaunch();
29
39
  const child = fork(fileURLToPath(new URL(source ? "./storage-sweep-child.ts" : "./storage-sweep-child.js", import.meta.url)), [], {
30
- execArgv: source ? ["--import", "tsx"] : [], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
40
+ execPath: launch.execPath, execArgv: [...launch.prefix, ...(source ? ["--import", "tsx"] : [])], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
31
41
  });
32
- // A one-shot CLI may exit after reconcile; the child's parent-loss guard then ends it.
42
+ // Before the request is sent, so every rm/du the child starts inherits it.
43
+ if (child.pid !== undefined) {
44
+ try {
45
+ setPriority(child.pid, SWEEP_NICE);
46
+ }
47
+ catch { /* the default priority still works */ }
48
+ }
49
+ // The service never waits on the child to exit; stop() and the child's parent-loss guard end it.
33
50
  child.unref();
34
51
  child.channel?.unref();
35
52
  return child;
@@ -165,7 +182,7 @@ export function createSweepSupervisor(options = {}) {
165
182
  },
166
183
  };
167
184
  }
168
- // All watch loops in this process share a flight. Other workers arbitrate the daily claim in SQLite.
185
+ // All watch loops in this process share a flight. Other services arbitrate the claim in SQLite.
169
186
  const supervisors = new Map();
170
187
  export function storageSweepSupervisor(databaseFile) {
171
188
  const key = resolve(databaseFile);
@@ -0,0 +1,30 @@
1
+ import { type SweepDeps, type SweepRecord } from "./storage-sweep.js";
2
+ import { type SweepSupervisor } from "./storage-sweep-process.js";
3
+ import { type Store } from "./store.js";
4
+ /** How often the service looks again at a due sweep. */
5
+ export declare const SWEEP_CHECK_MS: number;
6
+ /** After this long without an automatic sweep, it runs even while work is running or waiting. */
7
+ export declare const SWEEP_FORCE_MS: number;
8
+ export type SweepBusy = "run" | "claim" | "ready";
9
+ /** What keeps a due sweep waiting now, or null when nothing is running or waiting. */
10
+ export declare function sweepBusy(store: Store, now: Date): SweepBusy | null;
11
+ /** Whether a long-lived Toolroll service holds a current lease. Rows from earlier services don't count. */
12
+ export declare function sweepServiceRunning(store: Store, now: Date): boolean;
13
+ export type SweepState = "off" | "sweeping" | "no-service" | "recent" | "retry" | "busy" | "forced" | "due";
14
+ export type SweepSchedule = {
15
+ state: SweepState;
16
+ /** The latest automatic sweep's recorded time, or null when there has been none. */
17
+ lastAt: string | null;
18
+ /** When the next sweep is due, or null when it is off or due now. */
19
+ nextAt: string | null;
20
+ busy: SweepBusy | null;
21
+ };
22
+ /** The sweep's schedule from what is recorded now; reads only. */
23
+ export declare function sweepSchedule(store: Store, now: Date, env?: NodeJS.ProcessEnv): SweepSchedule;
24
+ /** Why the sweep is waiting or when it is next due, after "Automatic sweep: ". `when` formats a time for the reader. */
25
+ export declare function sweepScheduleWords(schedule: SweepSchedule, when: (at: string) => string): string;
26
+ /**
27
+ * The service's claim and launch. A one-shot caller has no `owner` and never claims. A hung child never holds
28
+ * reconciliation or the caller's store open; the result is saved by the flight's own connection.
29
+ */
30
+ export declare function idleStorageSweep(store: Store, deps: SweepDeps, now: () => Date, supervisor?: SweepSupervisor): SweepRecord | null;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * When the automatic storage sweep (storage-sweep.ts) starts, and what `toolroll storage` and Settings → Storage say
3
+ * about it. A sweep is due a day after the last one (an hour after one that failed somewhere). Once due, the
4
+ * long-lived Toolroll service looks at most every 15 minutes and starts it only while nothing is running or waiting:
5
+ * no open run, no live claim and no task a worker could claim now. Work held by an approval, a hold or a provider limit
6
+ * is not waiting to start, so it never defers the sweep. 48 hours after the last automatic sweep it runs anyway: its
7
+ * scans run in a low-priority child, and every removal asks again whether live work uses the folder.
8
+ *
9
+ * Only the service claims a sweep (it passes its owner): a one-shot `toolroll reconcile` exits before a child would
10
+ * finish, and a claim it made would be lost until the next day. The idle gates are asked again inside the transaction
11
+ * that claims the sweep, so work that started since the first look still defers it.
12
+ */
13
+ import { diagnoseTaskDispatch, diagnosisIsDispatchable } from "./dispatch.js";
14
+ import { lastSweep, RETRY_MS, saveSweep, storageSweepOff, sweepFailure, SWEEP_CURSOR, SWEEP_EVERY_MS } from "./storage-sweep.js";
15
+ import { storageSweepSupervisor, SWEEP_KILL_GRACE_MS, SWEEP_TIMEOUT_MS } from "./storage-sweep-process.js";
16
+ import { openStoreNoMigrate } from "./store.js";
17
+ /** How often the service looks again at a due sweep. */
18
+ export const SWEEP_CHECK_MS = 15 * 60_000;
19
+ /** After this long without an automatic sweep, it runs even while work is running or waiting. */
20
+ export const SWEEP_FORCE_MS = 48 * 3_600_000;
21
+ /** What keeps a due sweep waiting now, or null when nothing is running or waiting. */
22
+ export function sweepBusy(store, now) {
23
+ const db = store.handle;
24
+ if (db.prepare("SELECT 1 FROM run WHERE finished_at IS NULL LIMIT 1").get() !== undefined)
25
+ return "run";
26
+ if (db.prepare("SELECT 1 FROM claim WHERE released_at IS NULL AND expires_at > ? LIMIT 1").get(now.toISOString()) !== undefined)
27
+ return "claim";
28
+ // Ready means a worker could claim it now: the same answer the task page gives, not merely queued.
29
+ if (store.listReady(now).some(ref => diagnosisIsDispatchable(diagnoseTaskDispatch(store, ref.externalId, now))))
30
+ return "ready";
31
+ return null;
32
+ }
33
+ /** Whether a long-lived Toolroll service holds a current lease. Rows from earlier services don't count. */
34
+ export function sweepServiceRunning(store, now) {
35
+ return store.handle.prepare("SELECT 1 FROM watch_lease WHERE owner GLOB 'toolroll-service:*' AND expires_at > ? LIMIT 1").get(now.toISOString()) !== undefined;
36
+ }
37
+ const dueAt = (store) => store.serviceCursor(SWEEP_CURSOR) + SWEEP_EVERY_MS;
38
+ /** Count from the last result, or the durable installation time before the first result. */
39
+ function forcedFrom(store, last) {
40
+ if (last !== null)
41
+ return Date.parse(last.at);
42
+ const installed = store.installationFact("installed-at");
43
+ return installed === null ? null : Date.parse(installed);
44
+ }
45
+ /** The sweep's schedule from what is recorded now; reads only. */
46
+ export function sweepSchedule(store, now, env = process.env) {
47
+ const last = lastSweep(store, "automatic");
48
+ const lastAt = last?.at ?? null;
49
+ if (storageSweepOff(env))
50
+ return { state: "off", lastAt, nextAt: null, busy: null };
51
+ const cursor = store.serviceCursor(SWEEP_CURSOR);
52
+ // Results record completion time, which may be later than the claim. An older result cannot finish a new claim.
53
+ if (cursor !== 0 && now.getTime() - cursor < SWEEP_TIMEOUT_MS + SWEEP_KILL_GRACE_MS && now.getTime() >= cursor && (lastAt === null || Date.parse(lastAt) < cursor)) {
54
+ return { state: "sweeping", lastAt, nextAt: null, busy: null };
55
+ }
56
+ const due = dueAt(store);
57
+ const nextAt = cursor === 0 || due <= now.getTime() ? null : new Date(due).toISOString();
58
+ if (!sweepServiceRunning(store, now))
59
+ return { state: "no-service", lastAt, nextAt, busy: null };
60
+ if (nextAt !== null) {
61
+ // Completion can follow the claim: elapsed sweep time alone does not mean a retry.
62
+ const retry = last !== null && Date.parse(last.at) >= cursor && last.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined);
63
+ return { state: retry ? "retry" : "recent", lastAt, nextAt, busy: null };
64
+ }
65
+ const from = forcedFrom(store, last);
66
+ if (from !== null && now.getTime() - from >= SWEEP_FORCE_MS)
67
+ return { state: "forced", lastAt, nextAt: null, busy: null };
68
+ const busy = sweepBusy(store, now);
69
+ return { state: busy === null ? "due" : "busy", lastAt, nextAt: null, busy };
70
+ }
71
+ const BUSY_WORDS = { run: "work is running", claim: "a worker is starting a task", ready: "a task is ready to start" };
72
+ /** Why the sweep is waiting or when it is next due, after "Automatic sweep: ". `when` formats a time for the reader. */
73
+ export function sweepScheduleWords(schedule, when) {
74
+ switch (schedule.state) {
75
+ case "off": return "off.";
76
+ case "sweeping": return "sweeping now.";
77
+ case "no-service": return "waiting: no Toolroll service running.";
78
+ case "recent": return `swept recently, next due ${when(schedule.nextAt)}.`;
79
+ case "retry": return `retry due ${when(schedule.nextAt)}.`;
80
+ case "busy": return `due, waiting: ${BUSY_WORDS[schedule.busy]}.`;
81
+ case "forced": return "over 48 hours since the last sweep; it runs at the next check even if work is running.";
82
+ case "due": return "due; it starts within 15 minutes while nothing is running.";
83
+ }
84
+ }
85
+ // The last look per flight, so a due sweep is looked at no more than every 15 minutes.
86
+ const looked = new WeakMap();
87
+ /**
88
+ * The service's claim and launch. A one-shot caller has no `owner` and never claims. A hung child never holds
89
+ * reconciliation or the caller's store open; the result is saved by the flight's own connection.
90
+ */
91
+ export function idleStorageSweep(store, deps, now, supervisor = storageSweepSupervisor(deps.databaseFile)) {
92
+ if (deps.owner === undefined || storageSweepOff() || supervisor.running)
93
+ return null;
94
+ const at = now();
95
+ // Older installations have no creation fact. Anchor once on their first service pass; never backdate a claim.
96
+ if (store.installationFact("installed-at") === null)
97
+ store.recordInstallationFact("installed-at", at.toISOString(), at);
98
+ if (at.getTime() < dueAt(store))
99
+ return null;
100
+ const seen = looked.get(supervisor);
101
+ if (seen !== undefined && at.getTime() - seen < SWEEP_CHECK_MS && at.getTime() >= seen)
102
+ return null;
103
+ looked.set(supervisor, at.getTime());
104
+ const ready = () => {
105
+ if (at.getTime() < dueAt(store))
106
+ return false;
107
+ const from = forcedFrom(store, lastSweep(store, "automatic"));
108
+ return (from !== null && at.getTime() - from >= SWEEP_FORCE_MS) || sweepBusy(store, at) === null;
109
+ };
110
+ if (!ready())
111
+ return null;
112
+ // This handle belongs to the flight, not to the pass that has already returned by the time it finishes.
113
+ const connection = openStoreNoMigrate(deps.databaseFile);
114
+ if (!connection.ok)
115
+ throw new Error("Sweep result store unavailable");
116
+ const results = connection.store;
117
+ let completed = null;
118
+ let owned = false;
119
+ try {
120
+ // Asked again where the cursor moves: work that started since the first look still defers the sweep.
121
+ const mine = store.transact(() => {
122
+ if (!ready())
123
+ return false;
124
+ store.setServiceCursor(SWEEP_CURSOR, at.getTime(), at);
125
+ return true;
126
+ });
127
+ if (!mine)
128
+ return null;
129
+ const request = { version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor: deps.actor ?? "worker", at: at.toISOString() };
130
+ owned = supervisor.start(request, result => {
131
+ const record = result.ok ? result.record : sweepFailure(request.at, request.actor, deps.pool !== null, result.reason);
132
+ try {
133
+ results.transact(() => {
134
+ if (!results.schemaCurrent())
135
+ throw new Error("Sweep result store changed");
136
+ saveSweep(results, record);
137
+ // Only this claim can reschedule itself. A later claim must never be overwritten by a late callback.
138
+ if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined) && results.serviceCursor(SWEEP_CURSOR) === at.getTime()) {
139
+ results.setServiceCursor(SWEEP_CURSOR, Math.max(1, now().getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
140
+ }
141
+ });
142
+ completed = record;
143
+ }
144
+ catch {
145
+ // No exception/path from a process or database is safe to copy into a log.
146
+ console.error("Could not save the storage sweep result.");
147
+ }
148
+ finally {
149
+ results.close();
150
+ }
151
+ }, deps.owner);
152
+ if (!owned) {
153
+ store.transact(() => {
154
+ if (store.serviceCursor(SWEEP_CURSOR) === at.getTime())
155
+ store.setServiceCursor(SWEEP_CURSOR, Math.max(1, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
156
+ });
157
+ }
158
+ return completed;
159
+ }
160
+ finally {
161
+ if (!owned)
162
+ results.close();
163
+ }
164
+ }
@@ -1,8 +1,9 @@
1
1
  import { type Located } from "./orphan-sweep.js";
2
2
  import { type ProcessRow } from "./run-closeout.js";
3
- import { type Store } from "./store.js";
3
+ import type { Store } from "./store.js";
4
4
  import { type WorktreePool } from "./worktree.js";
5
- import { type SweepSupervisor, type SweepExecutor } from "./storage-sweep-process.js";
5
+ import { type SweepCompletion, type SweepExecutor } from "./storage-sweep-process.js";
6
+ export declare const SWEEP_CURSOR = "storage-sweep";
6
7
  export declare const SWEEP_EVERY_MS = 86400000;
7
8
  /** A sweep that failed somewhere, or left work for later, runs again this soon. */
8
9
  export declare const RETRY_MS: number;
@@ -36,9 +37,9 @@ export type SweepDeps = {
36
37
  graceMs?: number;
37
38
  };
38
39
  export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
39
- export declare function storageSweepDue(store: Store, now: Date): boolean;
40
- /** Claim and launch only. A hung child never holds reconciliation or the caller's store open. */
41
- export declare function dailyStorageSweep(store: Store, deps: SweepDeps, now: () => Date, supervisor?: SweepSupervisor): SweepRecord | null;
40
+ export declare function sweepFailure(at: string, actor: string, withPool: boolean, reason: Exclude<SweepCompletion, {
41
+ ok: true;
42
+ }>["reason"]): SweepRecord;
42
43
  /**
43
44
  * What live work uses now. Live work: an open run, a current (unexpired, unreleased) lease, or a checkout leased, held
44
45
  * by a process or whose task has either. It uses its checkouts, the shared install each links, and every folder its
@@ -1,7 +1,7 @@
1
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
2
+ * The storage sweep: the plane cleans up after itself without anyone running a command. About once a day, when nothing
3
+ * is running or waiting (storage-sweep-schedule.ts: one service takes it, the cursor moves in the same transaction),
4
+ * it removes test temp folders nothing touched for a day (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
5
5
  * setting lets go (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
6
6
  * and removes shared dependency installs no checkout has used for a week (shared-deps.ts).
7
7
  *
@@ -17,8 +17,9 @@
17
17
  *
18
18
  * What each sweep did is saved (the latest few) and in the ledger: `toolroll storage` and Settings → Storage show it.
19
19
  * A clean-up by hand (`storage clean --yes`) is saved the same way. A part that failed, or had more than one pass
20
- * takes, brings the next sweep forward to an hour later. `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off
21
- * (the unit tests do, so a test never sweeps the machine it runs on).
20
+ * takes, brings the next sweep forward to an hour later; it still waits for nothing to be running.
21
+ * `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off (the unit tests do, so a test never sweeps the machine
22
+ * it runs on).
22
23
  */
23
24
  import { realpathSync } from "node:fs";
24
25
  import { hostname } from "node:os";
@@ -29,11 +30,10 @@ import { processCensus } from "./run-closeout.js";
29
30
  import { linkedKey, pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
30
31
  import { pruneStagedRuntimes, runtimesInUse, runtimeScriptPaths, stagePlan } from "./staged-runtimes.js";
31
32
  import { bytesWords, diskBytes } from "./storage.js";
32
- import { openStoreNoMigrate } from "./store.js";
33
33
  import { removeStaleTestTemp } from "./test-temp.js";
34
34
  import { worktreeProcessOccupancy } from "./worktree.js";
35
- import { runSweepChild, storageSweepSupervisor } from "./storage-sweep-process.js";
36
- const CURSOR = "storage-sweep";
35
+ import { runSweepChild } from "./storage-sweep-process.js";
36
+ export const SWEEP_CURSOR = "storage-sweep";
37
37
  export const SWEEP_EVERY_MS = 86_400_000;
38
38
  /** A sweep that failed somewhere, or left work for later, runs again this soon. */
39
39
  export const RETRY_MS = 60 * 60_000;
@@ -46,67 +46,7 @@ const ITEMS_MAX = 20;
46
46
  export function storageSweepOff(env = process.env) {
47
47
  return (env["TOOLROLL_STORAGE_SWEEP"] ?? "").trim().toLowerCase() === "off";
48
48
  }
49
- export function storageSweepDue(store, now) {
50
- return now.getTime() - store.serviceCursor(CURSOR) >= SWEEP_EVERY_MS;
51
- }
52
- /** Claim and launch only. A hung child never holds reconciliation or the caller's store open. */
53
- export function dailyStorageSweep(store, deps, now, supervisor = storageSweepSupervisor(deps.databaseFile)) {
54
- if (storageSweepOff() || supervisor.running || !storageSweepDue(store, now()))
55
- return null;
56
- // This handle belongs to the flight, not to the command that has already returned by the time it finishes.
57
- const connection = openStoreNoMigrate(deps.databaseFile);
58
- if (!connection.ok)
59
- throw new Error("Sweep result store unavailable");
60
- const results = connection.store;
61
- const at = now();
62
- let completed = null;
63
- let owned = false;
64
- try {
65
- const mine = store.transact(() => {
66
- if (!storageSweepDue(store, at))
67
- return false;
68
- store.setServiceCursor(CURSOR, at.getTime(), at);
69
- return true;
70
- });
71
- if (!mine)
72
- return null;
73
- const request = { version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor: deps.actor ?? "worker", at: at.toISOString() };
74
- owned = supervisor.start(request, result => {
75
- const record = result.ok ? result.record : sweepFailure(request.at, request.actor, deps.pool !== null, result.reason);
76
- try {
77
- results.transact(() => {
78
- if (!results.schemaCurrent())
79
- throw new Error("Sweep result store changed");
80
- saveSweep(results, record);
81
- // Only this claim can reschedule itself. A later claim must never be overwritten by a late callback.
82
- if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined) && results.serviceCursor(CURSOR) === at.getTime()) {
83
- results.setServiceCursor(CURSOR, Math.max(0, now().getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
84
- }
85
- });
86
- completed = record;
87
- }
88
- catch {
89
- // No exception/path from a process or database is safe to copy into a log.
90
- console.error("Could not save the storage sweep result.");
91
- }
92
- finally {
93
- results.close();
94
- }
95
- }, deps.owner);
96
- if (!owned) {
97
- store.transact(() => {
98
- if (store.serviceCursor(CURSOR) === at.getTime())
99
- store.setServiceCursor(CURSOR, Math.max(0, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
100
- });
101
- }
102
- return completed;
103
- }
104
- finally {
105
- if (!owned)
106
- results.close();
107
- }
108
- }
109
- function sweepFailure(at, actor, withPool, reason) {
49
+ export function sweepFailure(at, actor, withPool, reason) {
110
50
  const problem = reason === "timeout" ? "The sweep process exceeded its time limit; some cleanup may have completed. Cleanup will retry later."
111
51
  : reason === "stopped" ? "The sweep process was stopped; some cleanup may have completed. Cleanup will retry later."
112
52
  : "The sweep process did not return a valid result; some cleanup may have completed.";
@@ -339,7 +279,7 @@ export function lastSweep(store, source) {
339
279
  export function nextSweepAt(store) {
340
280
  if (storageSweepOff())
341
281
  return null;
342
- const last = store.serviceCursor(CURSOR);
282
+ const last = store.serviceCursor(SWEEP_CURSOR);
343
283
  return last === 0 ? null : new Date(last + SWEEP_EVERY_MS).toISOString();
344
284
  }
345
285
  const NOUNS = {
@@ -1,6 +1,7 @@
1
1
  import { type Html } from "./html.js";
2
2
  import { type CheckoutPlan } from "./checkout-cleanup.js";
3
3
  import { type SweepRecord } from "./storage-sweep.js";
4
+ import { type SweepSchedule } from "./storage-sweep-schedule.js";
4
5
  import type { LandedSummary } from "./landed-cleanup.js";
5
6
  export declare const STORAGE_CSS: string;
6
7
  export type StorageView = {
@@ -9,6 +10,7 @@ export type StorageView = {
9
10
  sweep?: {
10
11
  last: SweepRecord | null;
11
12
  off: boolean;
13
+ schedule?: SweepSchedule;
12
14
  };
13
15
  landed?: LandedSummary;
14
16
  };
@@ -1,7 +1,7 @@
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
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), what landed work's teardown did and still waits on (in a disclosure, with the
4
+ * and when (its paths in a disclosure) and why the next one waits, what landed work's teardown did and still waits on (in a disclosure, with the
5
5
  * one-time command for older work), and when a finished task's clean checkout goes by itself. An instance operator's page.
6
6
  */
7
7
  import { homedir } from "node:os";
@@ -10,6 +10,7 @@ import { html, postForm } from "./html.js";
10
10
  import { previewDigest, whyWords } from "./checkout-cleanup.js";
11
11
  import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
12
12
  import { sweepDetails, sweepWords } from "./storage-sweep.js";
13
+ import { sweepScheduleWords } from "./storage-sweep-schedule.js";
13
14
  const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
14
15
  export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
15
16
  `.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
@@ -45,15 +46,17 @@ function whenWords(at) {
45
46
  const date = new Date(at);
46
47
  return Number.isNaN(date.getTime()) ? at : date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
47
48
  }
48
- /** One line: when the last sweep ran and what it did; its paths and problems behind Details. */
49
+ /** When the last sweep ran and what it did, then why the next one waits or when it is due; paths and problems behind Details. */
49
50
  function sweepHtml(sweep) {
50
51
  const last = sweep.last;
52
+ const schedule = sweep.schedule === undefined ? (sweep.off ? html `<p class="meta" data-sweep-state="off">Automatic sweep: off.</p>` : "")
53
+ : html `<p class="meta" data-sweep-state="${sweep.schedule.state}">Automatic sweep: ${sweepScheduleWords(sweep.schedule, whenWords)}</p>`;
51
54
  if (last === null)
52
- return html `<p class="meta" data-last-sweep="">${sweep.off ? "Automatic sweep is off." : html `Toolroll sweeps leftovers once a day. It hasn't run yet.`}</p>`;
55
+ return html `<div class="sweep" data-last-sweep=""><p>Not swept yet.</p>${schedule}</div>`;
53
56
  const who = last.source === "manual" ? `Cleaned up by ${last.actor}` : "Swept";
54
57
  const details = sweepDetails(last.parts);
55
58
  const problem = last.parts.some(one => one.failed > 0 || one.problem !== undefined);
56
- return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
59
+ return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${schedule}${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
57
60
  }
58
61
  /** Landed work, on demand: what its teardown did, what it waits on and why, and the one-time command for older work. */
59
62
  function landedHtml(landed) {
package/dist/store.d.ts CHANGED
@@ -78,6 +78,11 @@ export declare const FLOW_SEND_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_send
78
78
  * vocabulary and when — never text, tool names or arguments. Display state, not evidence;
79
79
  * a finished run's row goes with the live log's own retention (sweepRunActivity). */
80
80
  export declare const RUN_ACTIVITY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_activity (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK (kind IN ('session','message','edit','command','read','search','lookup','plan','delegate','tool')),\n at TEXT NOT NULL\n);\n";
81
+ /** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
82
+ * had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
83
+ * moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
84
+ * the older main it left. */
85
+ export declare const BRANCH_BASE_MOVE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS branch_base_move (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n branch TEXT NOT NULL,\n from_rev TEXT NOT NULL,\n to_rev TEXT NOT NULL,\n after_run INTEGER NOT NULL,\n moved_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);\n";
81
86
  /** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
82
87
  * stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
83
88
  * request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
@@ -161,7 +166,7 @@ export type SealedLedgerEntry = LedgerEntry & {
161
166
  hash: string;
162
167
  } | null;
163
168
  };
164
- export declare const SCHEMA_VERSION = 121;
169
+ export declare const SCHEMA_VERSION = 122;
165
170
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
166
171
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
167
172
  /**
@@ -220,7 +225,9 @@ export declare const V115_DROPPED_TABLES: readonly string[];
220
225
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
221
226
  * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
222
227
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
223
- * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
228
+ * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
229
+ * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
230
+ * same way.
224
231
  */
225
232
  export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
226
233
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
@@ -250,6 +257,12 @@ export declare const V121_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
250
257
  definition: string;
251
258
  saved: string;
252
259
  }>>>>;
260
+ /** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
261
+ * recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
262
+ export declare const V122_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
263
+ definition: string;
264
+ saved: string;
265
+ }>>>>;
253
266
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
254
267
  export declare function updateSafeSchema(version: number | null): boolean;
255
268
  export type OAuthClientRow = {
@@ -4350,6 +4363,20 @@ export declare class Store {
4350
4363
  * legacy first-attempt behavior, unchanged byte-for-byte.
4351
4364
  */
4352
4365
  firstBuilderBase(taskRef: number, branch: string): string | null;
4366
+ /** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
4367
+ * it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
4368
+ * current main until the next attempt records its base. */
4369
+ recordedBranchBase(taskRef: number, branch: string): string | null;
4370
+ /** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
4371
+ * attempt's own base (still the old one) as the branch's origin, which is exactly right. */
4372
+ recordBranchBaseMove(taskRef: number, branch: string, from: string, to: string, now: Date): void;
4373
+ /** The newest move of a task branch to current main, or null. */
4374
+ latestBranchBaseMove(taskRef: number, branch: string): {
4375
+ from: string;
4376
+ to: string;
4377
+ afterRun: number;
4378
+ movedAt: string;
4379
+ } | null;
4353
4380
  finishRun(id: number, result: {
4354
4381
  outcome: "built" | "failed" | "refused" | "parked" | "no-change" | "interrupted";
4355
4382
  reason?: string;
@@ -6004,7 +6031,15 @@ export declare class Store {
6004
6031
  leadStepsOf(leadTurn: number): ChatTurn[];
6005
6032
  /** Remember a project was opened. Upsert keeps added_at; recency always moves.
6006
6033
  * A project added for the first time starts on Quick checks. */
6007
- upsertProject(path: string, name: string, now: Date): void;
6034
+ upsertProject(path: string, name: string, now: Date, admission?: {
6035
+ remoteUrl: string | null;
6036
+ }): void;
6037
+ /** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
6038
+ projectAdmissionKnown(path: string): boolean;
6039
+ /** Full structured URL audit, without the generic ledger's display-text truncation. */
6040
+ recordProjectRemote(actor: string, repo: string, previous: string | null, remoteUrl: string, now: Date): void;
6041
+ /** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
6042
+ projectRemote(path: string): string | null;
6008
6043
  /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
6009
6044
  forgetProject(path: string): void;
6010
6045
  /** Most recently opened first — the opener page's order. */
@@ -6013,6 +6048,7 @@ export declare class Store {
6013
6048
  name: string;
6014
6049
  addedAt: string;
6015
6050
  lastOpenedAt: string;
6051
+ remoteUrl: string | null;
6016
6052
  }[];
6017
6053
  /**
6018
6054
  * Tasks whose scope waits on a person's yes: proposed, not approved (or