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.
Files changed (128) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. package/scripts/provider-canary.mjs +235 -147
@@ -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. Only the working copy goes: its branch, and so every commit,
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, max?: number): Promise<{
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
- /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
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.get(target) === "removing")
241
- return { ok: false, reason: "in-use", message: `${target} is being removed; try again in a moment` };
242
- const mine = !busy.has(target);
243
- if (mine)
244
- busy.set(target, "leasing");
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
- if (mine)
250
- busy.delete(target);
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
- const add = await this.git(request.repo, [
350
- "worktree",
351
- "add",
352
- ...(fromBase ? ["-b", request.branch] : []),
353
- path,
354
- ...(fromBase ? [request.base] : [request.branch]),
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. Only the working copy goes: its branch, and so every commit,
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) || !wanted(row, false))
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) || !wanted(now, true))
840
+ if (!idle(now, row.releasedAt))
707
841
  continue;
708
- const gone = await this.removeWorkingCopy(row);
709
- if (!gone) {
710
- kept.push({ path: row.path, why: "git refused" });
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
- /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
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.60",
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"