toolroll 0.9.60 → 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.
- package/CHANGELOG.md +32 -0
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment.d.ts +4 -0
- package/dist/assignment.js +8 -6
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +113 -13
- package/dist/chat-channel.js +9 -0
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/cli.js +28 -11
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +14 -3
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +170 -0
- package/dist/landed-cleanup.js +814 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +4 -4
- package/dist/lead-doors.js +45 -1
- package/dist/lead-tools.js +40 -7
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +249 -26
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +32 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +65 -8
- package/dist/provider.js +80 -21
- package/dist/remote-main.d.ts +57 -0
- package/dist/remote-main.js +179 -0
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +19 -1
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +12 -0
- package/dist/server/render-tasks.d.ts +15 -2
- package/dist/server/render-tasks.js +30 -7
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +5 -3
- package/dist/server/task-views.js +11 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-status.d.ts +8 -2
- package/dist/steer-status.js +19 -5
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +48 -0
- package/dist/storage-sweep-process.js +204 -0
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +31 -6
- package/dist/storage-sweep.js +165 -39
- package/dist/storage-ui.d.ts +4 -0
- package/dist/storage-ui.js +22 -7
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +200 -3
- package/dist/store.js +443 -17
- package/dist/surface.js +8 -4
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +4 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +57 -3
- package/dist/worktree.js +201 -23
- package/package.json +1 -1
- package/scripts/provider-canary.mjs +235 -147
package/dist/worktree.d.ts
CHANGED
|
@@ -52,6 +52,32 @@ export type LeaseRequest = {
|
|
|
52
52
|
* stands, as its kept checkout would have been reused. Without it, `base` always starts a new branch (and an
|
|
53
53
|
* existing one refuses). */
|
|
54
54
|
reuseBranch?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Start from current main (fresh base): a checkout still at its recorded base — no commits of its own — and with no
|
|
57
|
+
* changes moves, with its branch, to `main` before the attempt. Anything else is never moved; the result says how
|
|
58
|
+
* far behind main it is. Needs `taskRef`. `recordedBase` null means no attempt on the branch recorded one yet, and
|
|
59
|
+
* then only a HEAD that main already contains counts as having no commits of its own.
|
|
60
|
+
*/
|
|
61
|
+
freshBase?: {
|
|
62
|
+
main: string;
|
|
63
|
+
recordedBase: string | null;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/** Where a checkout stands against current main once its lease is granted (fresh base). */
|
|
67
|
+
export type BaseStanding = {
|
|
68
|
+
/** HEAD as the attempt starts. */
|
|
69
|
+
head: string;
|
|
70
|
+
/** Current main's commit. */
|
|
71
|
+
main: string;
|
|
72
|
+
/** Commits on main that HEAD does not have. */
|
|
73
|
+
behind: number;
|
|
74
|
+
/** Set when this lease moved the checkout to main: where it was, and how far behind that was. */
|
|
75
|
+
moved: {
|
|
76
|
+
from: string;
|
|
77
|
+
behind: number;
|
|
78
|
+
} | null;
|
|
79
|
+
/** Why a checkout behind main stayed where it is. */
|
|
80
|
+
kept: "has commits" | "has changes" | "not on its branch" | null;
|
|
55
81
|
};
|
|
56
82
|
export type LeaseResult = {
|
|
57
83
|
ok: true;
|
|
@@ -60,6 +86,8 @@ export type LeaseResult = {
|
|
|
60
86
|
reclaimed?: string;
|
|
61
87
|
resumedFromRun?: number;
|
|
62
88
|
recoveryKind?: "completed" | "partial";
|
|
89
|
+
/** With `freshBase`: where the checkout stands against main (null when main could not be read). */
|
|
90
|
+
base?: BaseStanding | null;
|
|
63
91
|
} | {
|
|
64
92
|
ok: false;
|
|
65
93
|
reason: LeaseFailure;
|
|
@@ -76,6 +104,13 @@ export type KeptWhy = "has changes" | "has commits" | "unreadable" | "git refuse
|
|
|
76
104
|
* something is in there.
|
|
77
105
|
*/
|
|
78
106
|
export declare const MARKER = ".standing-orders-lease";
|
|
107
|
+
/**
|
|
108
|
+
* The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
|
|
109
|
+
* is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
|
|
110
|
+
*/
|
|
111
|
+
export declare const branchCreatedMessage: (branch: string) => string;
|
|
112
|
+
/** Scouts' disposable branches: never landed work. */
|
|
113
|
+
export declare const SCOUT_PREFIX = "toolroll-scout/";
|
|
79
114
|
/** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
|
|
80
115
|
export declare const SLIM_NAMES: readonly string[];
|
|
81
116
|
/** Creating a worktree copies a tree; it is local work but not instant. */
|
|
@@ -114,6 +149,8 @@ export declare class WorktreePool {
|
|
|
114
149
|
private readonly store;
|
|
115
150
|
private readonly options;
|
|
116
151
|
private readonly runner;
|
|
152
|
+
/** Carried to the independent storage sweep process. */
|
|
153
|
+
get root(): string;
|
|
117
154
|
constructor(store: Store, options: PoolOptions);
|
|
118
155
|
/**
|
|
119
156
|
* Take a working copy for this branch.
|
|
@@ -155,6 +192,13 @@ export declare class WorktreePool {
|
|
|
155
192
|
recordProviderOccupancy(path: string, runner: string, providerPid: number, leaseEpoch?: string | null): void;
|
|
156
193
|
lease(request: LeaseRequest): Promise<LeaseResult>;
|
|
157
194
|
private leaseNow;
|
|
195
|
+
/**
|
|
196
|
+
* Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
|
|
197
|
+
* branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
|
|
198
|
+
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
199
|
+
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
200
|
+
*/
|
|
201
|
+
private freshen;
|
|
158
202
|
/**
|
|
159
203
|
* A crash can strand either a finished handoff or a partial source draft.
|
|
160
204
|
* The expired lease can no longer authorize a commit, so this never ingests
|
|
@@ -243,10 +287,11 @@ export declare class WorktreePool {
|
|
|
243
287
|
/**
|
|
244
288
|
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
245
289
|
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
246
|
-
* lease since keeps it) and `inspect` finds it clean
|
|
290
|
+
* lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
|
|
291
|
+
* made after that). Only the working copy goes: its branch, and so every commit,
|
|
247
292
|
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
248
293
|
*/
|
|
249
|
-
prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean
|
|
294
|
+
prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean | Promise<boolean>, max?: number): Promise<{
|
|
250
295
|
removed: WorktreeRow[];
|
|
251
296
|
kept: {
|
|
252
297
|
path: string;
|
|
@@ -278,8 +323,17 @@ export declare class WorktreePool {
|
|
|
278
323
|
ok: false;
|
|
279
324
|
message: string;
|
|
280
325
|
}>;
|
|
281
|
-
/**
|
|
326
|
+
/** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
|
|
327
|
+
* Only `discardChanges`, where losing them is what a person asked for. */
|
|
282
328
|
private removeWorkingCopy;
|
|
329
|
+
/**
|
|
330
|
+
* Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
|
|
331
|
+
* reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
|
|
332
|
+
* removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
|
|
333
|
+
* git's check would count them.
|
|
334
|
+
*/
|
|
335
|
+
private removeCleanCopy;
|
|
336
|
+
private forgetRemoved;
|
|
283
337
|
/**
|
|
284
338
|
* Whether anybody's work is in there. null means we could not tell, which is
|
|
285
339
|
* neither clean nor dirty and must not be rounded to either.
|
package/dist/worktree.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* over `.git` internals corrupts both.
|
|
24
24
|
*/
|
|
25
25
|
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
-
import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync } from "node:fs";
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
27
27
|
import { hostname } from "node:os";
|
|
28
28
|
import { dirname, join } from "node:path";
|
|
29
29
|
import { run } from "./exec.js";
|
|
@@ -40,8 +40,70 @@ import { markNeverIndex } from "./never-index.js";
|
|
|
40
40
|
* something is in there.
|
|
41
41
|
*/
|
|
42
42
|
export const MARKER = ".standing-orders-lease";
|
|
43
|
+
/**
|
|
44
|
+
* The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
|
|
45
|
+
* is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
|
|
46
|
+
*/
|
|
47
|
+
export const branchCreatedMessage = (branch) => `Toolroll created branch ${branch}`;
|
|
48
|
+
/** Scouts' disposable branches: never landed work. */
|
|
49
|
+
export const SCOUT_PREFIX = "toolroll-scout/";
|
|
43
50
|
/** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
|
|
44
51
|
const busy = new Map();
|
|
52
|
+
// Outside the checkout so removal cannot erase the reservation before git and the row update finish.
|
|
53
|
+
const reservationPath = (path) => `${path}.occupancy`;
|
|
54
|
+
function reservation(path) {
|
|
55
|
+
try {
|
|
56
|
+
const note = JSON.parse(readFileSync(reservationPath(path), "utf8"));
|
|
57
|
+
if (Number.isSafeInteger(note.pid) && note.pid > 0 && typeof note.host === "string" && typeof note.token === "string")
|
|
58
|
+
return note;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === "ENOENT")
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// An unreadable reservation is occupied, never a reason to delete someone's checkout.
|
|
65
|
+
return { pid: 0, host: "unknown", bootId: null, token: "unknown" };
|
|
66
|
+
}
|
|
67
|
+
function reservationAlive(note) {
|
|
68
|
+
if (note.pid === 0 || note.host !== hostname())
|
|
69
|
+
return true;
|
|
70
|
+
if (note.bootId !== null && provenDeadByBootChange(note))
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
process.kill(note.pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return error.code !== "ESRCH";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** A short store transaction arbitrates marker creation/recovery, never git, a scan or deletion.
|
|
81
|
+
* Atomic rename publishes a complete owner; a crash cannot leave a half-written reservation. */
|
|
82
|
+
function reserveCheckout(store, path) {
|
|
83
|
+
const note = { pid: process.pid, host: hostname(), bootId: currentBootId(), token: randomBytes(12).toString("hex") };
|
|
84
|
+
const file = reservationPath(path), temp = `${file}.${note.token}`;
|
|
85
|
+
const held = store.transact(() => {
|
|
86
|
+
const prior = reservation(path);
|
|
87
|
+
if (prior !== null && reservationAlive(prior))
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
91
|
+
writeFileSync(temp, JSON.stringify(note), { flag: "wx", mode: 0o600 });
|
|
92
|
+
renameSync(temp, file);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
rmSync(temp, { force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
if (!held)
|
|
103
|
+
return null;
|
|
104
|
+
return () => store.transact(() => { if (reservation(path)?.token === note.token)
|
|
105
|
+
rmSync(file, { force: true }); });
|
|
106
|
+
}
|
|
45
107
|
/** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
|
|
46
108
|
export const SLIM_NAMES = ["node_modules", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".turbo", ".parcel-cache", "coverage", "target"];
|
|
47
109
|
/** Creating a worktree copies a tree; it is local work but not instant. */
|
|
@@ -94,6 +156,9 @@ export function recordWorktreeProcess(store, path, runner, pid, leaseEpoch) {
|
|
|
94
156
|
}
|
|
95
157
|
/** Read-only occupancy witness shared with stop settlement. */
|
|
96
158
|
export function worktreeProcessOccupancy(path) {
|
|
159
|
+
const reserved = reservation(path);
|
|
160
|
+
if (reserved !== null && (reserved.pid !== process.pid || reserved.host !== hostname()) && reservationAlive(reserved))
|
|
161
|
+
return { held: true, by: reserved.pid };
|
|
97
162
|
const note = join(path, MARKER);
|
|
98
163
|
if (!existsSync(note))
|
|
99
164
|
return { held: false };
|
|
@@ -176,6 +241,8 @@ export class WorktreePool {
|
|
|
176
241
|
store;
|
|
177
242
|
options;
|
|
178
243
|
runner;
|
|
244
|
+
/** Carried to the independent storage sweep process. */
|
|
245
|
+
get root() { return this.options.root; }
|
|
179
246
|
constructor(store, options) {
|
|
180
247
|
this.store = store;
|
|
181
248
|
this.options = options;
|
|
@@ -237,17 +304,18 @@ export class WorktreePool {
|
|
|
237
304
|
}
|
|
238
305
|
async lease(request) {
|
|
239
306
|
const target = worktreePath(this.options.root, request.repo, request.branch);
|
|
240
|
-
if (busy.
|
|
241
|
-
return { ok: false, reason: "in-use", message: `${target} is
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
|
|
307
|
+
if (busy.has(target))
|
|
308
|
+
return { ok: false, reason: "in-use", message: `${target} is busy; try again in a moment` };
|
|
309
|
+
const release = reserveCheckout(this.store, target);
|
|
310
|
+
if (release === null)
|
|
311
|
+
return { ok: false, reason: "in-use", message: `${target} is busy; try again in a moment` };
|
|
312
|
+
busy.set(target, "leasing");
|
|
245
313
|
try {
|
|
246
314
|
return await this.leaseNow(request);
|
|
247
315
|
}
|
|
248
316
|
finally {
|
|
249
|
-
|
|
250
|
-
|
|
317
|
+
release();
|
|
318
|
+
busy.delete(target);
|
|
251
319
|
}
|
|
252
320
|
}
|
|
253
321
|
async leaseNow(request) {
|
|
@@ -346,17 +414,34 @@ export class WorktreePool {
|
|
|
346
414
|
catch { /* git reports it */ }
|
|
347
415
|
markNeverIndex(this.options.root);
|
|
348
416
|
markNeverIndex(dirname(path));
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
417
|
+
// A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
|
|
418
|
+
let made = null;
|
|
419
|
+
if (fromBase) {
|
|
420
|
+
const base = await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `${request.base}^{commit}`], READ_ONLY);
|
|
421
|
+
const oid = base.stdout.trim();
|
|
422
|
+
if (base.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid))
|
|
423
|
+
return { ok: false, reason: "git", message: `git couldn't find ${request.base}` };
|
|
424
|
+
const branch = await this.git(request.repo, ["update-ref", "--create-reflog", "-m", branchCreatedMessage(request.branch), `refs/heads/${request.branch}`, oid, "0".repeat(oid.length)]);
|
|
425
|
+
if (branch.code !== 0)
|
|
426
|
+
return { ok: false, reason: "git", message: firstLine(branch.stderr) };
|
|
427
|
+
made = oid;
|
|
428
|
+
}
|
|
429
|
+
const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
|
|
356
430
|
if (add.code !== 0) {
|
|
431
|
+
if (made !== null)
|
|
432
|
+
await this.git(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
|
|
357
433
|
return { ok: false, reason: "git", message: firstLine(add.stderr) };
|
|
358
434
|
}
|
|
359
435
|
}
|
|
436
|
+
// Fresh base, inside the same reservation as everything above: nothing else leases, prunes or slims this
|
|
437
|
+
// checkout between the inspection and the move.
|
|
438
|
+
let base;
|
|
439
|
+
if (request.freshBase !== undefined && request.taskRef !== undefined) {
|
|
440
|
+
const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
|
|
441
|
+
if (!fresh.ok)
|
|
442
|
+
return { ok: false, reason: "git", message: fresh.message };
|
|
443
|
+
base = fresh.standing;
|
|
444
|
+
}
|
|
360
445
|
const row = {
|
|
361
446
|
path,
|
|
362
447
|
repo: request.repo,
|
|
@@ -392,8 +477,50 @@ export class WorktreePool {
|
|
|
392
477
|
...(reclaimed === undefined ? {} : { reclaimed }),
|
|
393
478
|
...(resumedFromRun === undefined ? {} : { resumedFromRun }),
|
|
394
479
|
...(recoveryKind === undefined ? {} : { recoveryKind }),
|
|
480
|
+
...(base === undefined ? {} : { base }),
|
|
395
481
|
};
|
|
396
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
|
|
485
|
+
* branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
|
|
486
|
+
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
487
|
+
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
488
|
+
*/
|
|
489
|
+
async freshen(path, branch, taskRef, fresh, now) {
|
|
490
|
+
const read = async (args) => {
|
|
491
|
+
const out = await this.runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
492
|
+
return out.code === 0 ? out.stdout.trim() : null;
|
|
493
|
+
};
|
|
494
|
+
const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
|
|
495
|
+
const head = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
496
|
+
if (main === null || head === null)
|
|
497
|
+
return { ok: true, standing: null };
|
|
498
|
+
const count = await read(["rev-list", "--count", `${head}..${main}`]);
|
|
499
|
+
const behind = count === null ? NaN : Number(count);
|
|
500
|
+
if (!Number.isSafeInteger(behind))
|
|
501
|
+
return { ok: true, standing: null };
|
|
502
|
+
const stay = (kept) => ({ ok: true, standing: { head, main, behind, moved: null, kept } });
|
|
503
|
+
if (behind === 0)
|
|
504
|
+
return stay(null);
|
|
505
|
+
if (await read(["symbolic-ref", "-q", "HEAD"]) !== `refs/heads/${branch}`)
|
|
506
|
+
return stay("not on its branch");
|
|
507
|
+
if (fresh.recordedBase !== null && fresh.recordedBase !== head)
|
|
508
|
+
return stay("has commits");
|
|
509
|
+
const contained = await this.runner(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
510
|
+
if (contained.code !== 0)
|
|
511
|
+
return stay("has commits");
|
|
512
|
+
if (await this.isDirty(path) !== false)
|
|
513
|
+
return stay("has changes");
|
|
514
|
+
// Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
|
|
515
|
+
this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
|
|
516
|
+
const moved = await this.runner(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
517
|
+
const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
518
|
+
const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
|
|
519
|
+
if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
|
|
520
|
+
return { ok: false, message: `${path} could not be moved to current main (${firstLine(moved.stderr) || "it changed during the move"}) — leaving it for a person` };
|
|
521
|
+
}
|
|
522
|
+
return { ok: true, standing: { head: main, main, behind: 0, moved: { from: head, behind }, kept: null } };
|
|
523
|
+
}
|
|
397
524
|
/**
|
|
398
525
|
* A crash can strand either a finished handoff or a partial source draft.
|
|
399
526
|
* The expired lease can no longer authorize a commit, so this never ingests
|
|
@@ -678,7 +805,8 @@ export class WorktreePool {
|
|
|
678
805
|
/**
|
|
679
806
|
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
680
807
|
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
681
|
-
* lease since keeps it) and `inspect` finds it clean
|
|
808
|
+
* lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
|
|
809
|
+
* made after that). Only the working copy goes: its branch, and so every commit,
|
|
682
810
|
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
683
811
|
*/
|
|
684
812
|
async prune(rows, wanted, max = Infinity) {
|
|
@@ -688,7 +816,10 @@ export class WorktreePool {
|
|
|
688
816
|
for (const row of rows) {
|
|
689
817
|
if (removed.length >= max)
|
|
690
818
|
break;
|
|
691
|
-
if (row.releasedAt === null || row.runner !== null || busy.has(row.path)
|
|
819
|
+
if (row.releasedAt === null || row.runner !== null || busy.has(row.path))
|
|
820
|
+
continue;
|
|
821
|
+
const selected = wanted(row, false);
|
|
822
|
+
if (!(typeof selected === "boolean" ? selected : await selected))
|
|
692
823
|
continue;
|
|
693
824
|
if (!existsSync(row.path) || !idle(row, row.releasedAt))
|
|
694
825
|
continue;
|
|
@@ -699,20 +830,28 @@ export class WorktreePool {
|
|
|
699
830
|
}
|
|
700
831
|
if (busy.has(row.path))
|
|
701
832
|
continue;
|
|
833
|
+
const release = reserveCheckout(this.store, row.path);
|
|
834
|
+
if (release === null)
|
|
835
|
+
continue;
|
|
702
836
|
busy.set(row.path, "removing");
|
|
703
837
|
try {
|
|
704
838
|
// A lease, or a task coming back, since the listing wins: look again right before removing.
|
|
705
839
|
const now = this.store.getWorktree(row.path);
|
|
706
|
-
if (!idle(now, row.releasedAt)
|
|
840
|
+
if (!idle(now, row.releasedAt))
|
|
707
841
|
continue;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
842
|
+
// Landed cleanup rechecks Git here; existing synchronous guards keep their synchronous path.
|
|
843
|
+
const allowed = wanted(now, true);
|
|
844
|
+
if (!(typeof allowed === "boolean" ? allowed : await allowed))
|
|
845
|
+
continue;
|
|
846
|
+
const gone = await this.removeCleanCopy(row);
|
|
847
|
+
if (gone !== "removed") {
|
|
848
|
+
kept.push({ path: row.path, why: gone });
|
|
711
849
|
continue;
|
|
712
850
|
}
|
|
713
851
|
removed.push(row);
|
|
714
852
|
}
|
|
715
853
|
finally {
|
|
854
|
+
release();
|
|
716
855
|
busy.delete(row.path);
|
|
717
856
|
}
|
|
718
857
|
}
|
|
@@ -735,6 +874,9 @@ export class WorktreePool {
|
|
|
735
874
|
.filter(one => { const name = one.split("/").at(-1); return SLIM_NAMES.includes(name) && (deps || name !== "node_modules"); });
|
|
736
875
|
if (drop.length === 0)
|
|
737
876
|
return { ok: true, dropped: [], bytes: 0 };
|
|
877
|
+
const release = reserveCheckout(this.store, row.path);
|
|
878
|
+
if (release === null)
|
|
879
|
+
return { ok: false, message: `${row.path} is in use` };
|
|
738
880
|
busy.set(row.path, "removing");
|
|
739
881
|
const note = join(row.path, MARKER);
|
|
740
882
|
let held = false;
|
|
@@ -759,6 +901,7 @@ export class WorktreePool {
|
|
|
759
901
|
finally {
|
|
760
902
|
if (held)
|
|
761
903
|
rmSync(note, { force: true });
|
|
904
|
+
release();
|
|
762
905
|
busy.delete(row.path);
|
|
763
906
|
}
|
|
764
907
|
}
|
|
@@ -775,8 +918,14 @@ export class WorktreePool {
|
|
|
775
918
|
const occupied = this.inUse(path);
|
|
776
919
|
if (occupied.held)
|
|
777
920
|
return { ok: false, message: `${path} is being used by process ${occupied.by} — it can't be discarded now` };
|
|
921
|
+
const release = reserveCheckout(this.store, path);
|
|
922
|
+
if (release === null)
|
|
923
|
+
return { ok: false, message: `${path} is in use` };
|
|
778
924
|
busy.set(path, "removing");
|
|
779
925
|
try {
|
|
926
|
+
const fresh = this.store.getWorktree(path);
|
|
927
|
+
if (fresh === null || fresh.runner !== null || fresh.releasedAt !== row.releasedAt || this.inUse(path).held)
|
|
928
|
+
return { ok: false, message: `${path} is in use` };
|
|
780
929
|
if (!existsSync(path)) {
|
|
781
930
|
this.store.transact(() => { const still = this.store.getWorktree(path); if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
782
931
|
this.store.forgetWorktree(path); });
|
|
@@ -785,10 +934,12 @@ export class WorktreePool {
|
|
|
785
934
|
return (await this.removeWorkingCopy(row)) ? { ok: true, row } : { ok: false, message: `git would not remove ${path}` };
|
|
786
935
|
}
|
|
787
936
|
finally {
|
|
937
|
+
release();
|
|
788
938
|
busy.delete(path);
|
|
789
939
|
}
|
|
790
940
|
}
|
|
791
|
-
/**
|
|
941
|
+
/** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
|
|
942
|
+
* Only `discardChanges`, where losing them is what a person asked for. */
|
|
792
943
|
async removeWorkingCopy(row) {
|
|
793
944
|
// Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
|
|
794
945
|
const note = join(row.path, MARKER);
|
|
@@ -803,12 +954,39 @@ export class WorktreePool {
|
|
|
803
954
|
rmSync(note, { force: true });
|
|
804
955
|
return false;
|
|
805
956
|
}
|
|
957
|
+
this.forgetRemoved(row);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
|
|
962
|
+
* reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
|
|
963
|
+
* removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
|
|
964
|
+
* git's check would count them.
|
|
965
|
+
*/
|
|
966
|
+
async removeCleanCopy(row) {
|
|
967
|
+
const found = await this.inspect(row.path);
|
|
968
|
+
if (found !== "clean")
|
|
969
|
+
return found;
|
|
970
|
+
const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
971
|
+
if (status.code !== 0)
|
|
972
|
+
return "unreadable";
|
|
973
|
+
for (const line of status.stdout.split("\n")) {
|
|
974
|
+
const name = line.startsWith("?? ") ? line.slice(3).trim() : "";
|
|
975
|
+
if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
|
|
976
|
+
rmSync(join(row.path, name), { recursive: true, force: true });
|
|
977
|
+
}
|
|
978
|
+
const gone = await this.git(row.repo, ["worktree", "remove", row.path]);
|
|
979
|
+
if (gone.code !== 0)
|
|
980
|
+
return (await this.isDirty(row.path)) === false ? "git refused" : "has changes";
|
|
981
|
+
this.forgetRemoved(row);
|
|
982
|
+
return "removed";
|
|
983
|
+
}
|
|
984
|
+
forgetRemoved(row) {
|
|
806
985
|
this.store.transact(() => {
|
|
807
986
|
const still = this.store.getWorktree(row.path);
|
|
808
987
|
if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
809
988
|
this.store.forgetWorktree(row.path);
|
|
810
989
|
});
|
|
811
|
-
return true;
|
|
812
990
|
}
|
|
813
991
|
/**
|
|
814
992
|
* Whether anybody's work is in there. null means we could not tell, which is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.62",
|
|
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"
|