toolroll 0.8.1 → 0.8.2

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/dist/worktree.js CHANGED
@@ -25,10 +25,10 @@
25
25
  import { createHash, randomBytes } from "node:crypto";
26
26
  import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync } from "node:fs";
27
27
  import { hostname } from "node:os";
28
- import { basename, dirname, join } from "node:path";
28
+ import { dirname, join } from "node:path";
29
29
  import { run } from "./exec.js";
30
30
  import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
- import { HANDOFF_PREFIX, MAILBOX_SUFFIX, readMailbox } from "./evidence.js";
31
+ import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
32
32
  import { parseHandoff } from "./decision.js";
33
33
  /**
34
34
  * The note a lease leaves in the checkout, naming the process holding it.
@@ -649,37 +649,40 @@ export class WorktreePool {
649
649
  });
650
650
  }
651
651
  /**
652
- * Keep storage in check: remove the working copies nobody needs any more.
653
- * A checkout goes when it was released `keepMs` ago or longer, nothing
654
- * holds it, it is clean (build output that .gitignore hides is not work),
655
- * everything its HEAD reaches is on a branch, tag or remote, and it isn't the
656
- * checkout of one of `keep()`'s branches (those unfinished tasks work on or
657
- * would come back to, read afresh before each removal), by its branch or by
658
- * the name a lease of that branch would give it under any root. Only the
659
- * working copy goes: its branch, and so every commit, stays, and a later
660
- * lease of the same branch makes a new one. At most `max` go per pass.
652
+ * Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
653
+ * work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
654
+ * or remote. Reads only.
661
655
  */
662
- async prune(repo, now, keepMs, keep, max = 20) {
656
+ async inspect(path) {
657
+ const dirty = await this.isDirty(path, true);
658
+ if (dirty !== false)
659
+ return dirty === null ? "unreadable" : "has changes";
660
+ // Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
661
+ const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
662
+ if (own.code !== 0)
663
+ return "unreadable";
664
+ return own.stdout.trim() === "" ? "clean" : "has commits";
665
+ }
666
+ /**
667
+ * Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
668
+ * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
669
+ * lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
670
+ * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
671
+ */
672
+ async prune(rows, wanted, max = Infinity) {
663
673
  const removed = [];
664
674
  const kept = [];
665
675
  const idle = (row, releasedAt) => row !== null && row.runner === null && row.releasedAt === releasedAt && !this.inUse(row.path).held;
666
- const kept_ = (branches, row) => branches.includes(row.branch) || branches.some(branch => basename(worktreePath("/", repo, branch)) === basename(row.path));
667
- for (const row of this.store.listWorktrees().filter(one => one.repo === repo)) {
676
+ for (const row of rows) {
668
677
  if (removed.length >= max)
669
678
  break;
670
- if (row.releasedAt === null || row.runner !== null || busy.has(row.path) || kept_(keep(), row))
679
+ if (row.releasedAt === null || row.runner !== null || busy.has(row.path) || !wanted(row, false))
671
680
  continue;
672
- if (now.getTime() - Date.parse(row.releasedAt) < keepMs || !existsSync(row.path) || !idle(row, row.releasedAt))
681
+ if (!existsSync(row.path) || !idle(row, row.releasedAt))
673
682
  continue;
674
- const dirty = await this.isDirty(row.path);
675
- if (dirty !== false) {
676
- kept.push({ path: row.path, why: dirty === null ? "unreadable" : "has changes" });
677
- continue;
678
- }
679
- // Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
680
- const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
681
- if (own.code !== 0 || own.stdout.trim() !== "") {
682
- kept.push({ path: row.path, why: own.code !== 0 ? "unreadable" : "has commits" });
683
+ const found = await this.inspect(row.path);
684
+ if (found !== "clean") {
685
+ kept.push({ path: row.path, why: found });
683
686
  continue;
684
687
  }
685
688
  if (busy.has(row.path))
@@ -687,27 +690,14 @@ export class WorktreePool {
687
690
  busy.set(row.path, "removing");
688
691
  try {
689
692
  // A lease, or a task coming back, since the listing wins: look again right before removing.
690
- if (!idle(this.store.getWorktree(row.path), row.releasedAt) || kept_(keep(), row))
691
- continue;
692
- // Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
693
- const note = join(row.path, MARKER);
694
- try {
695
- writeFileSync(note, `${process.pid} storage-retention removing ${currentBootId() ?? "unknown"} ${hostname()}\n`, "utf8");
696
- }
697
- catch {
693
+ const now = this.store.getWorktree(row.path);
694
+ if (!idle(now, row.releasedAt) || !wanted(now, true))
698
695
  continue;
699
- }
700
- const gone = await this.git(repo, ["worktree", "remove", "--force", row.path]);
701
- if (gone.code !== 0) {
702
- rmSync(note, { force: true });
696
+ const gone = await this.removeWorkingCopy(row);
697
+ if (!gone) {
703
698
  kept.push({ path: row.path, why: "git refused" });
704
699
  continue;
705
700
  }
706
- this.store.transact(() => {
707
- const still = this.store.getWorktree(row.path);
708
- if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
709
- this.store.forgetWorktree(row.path);
710
- });
711
701
  removed.push(row);
712
702
  }
713
703
  finally {
@@ -716,6 +706,54 @@ export class WorktreePool {
716
706
  }
717
707
  return { removed, kept };
718
708
  }
709
+ /**
710
+ * Throw away a let-go checkout kept for its changes, on purpose: the working copy and what is uncommitted in it
711
+ * go; its branch, and every commit on it, stays. Refused while anything holds it.
712
+ */
713
+ async discardChanges(path) {
714
+ const row = this.store.getWorktree(path);
715
+ if (row === null)
716
+ return { ok: false, message: `${path} is not a checkout Toolroll made` };
717
+ if (row.releasedAt === null || row.runner !== null || busy.has(path))
718
+ return { ok: false, message: `${path} is in use — it can't be discarded now` };
719
+ const occupied = this.inUse(path);
720
+ if (occupied.held)
721
+ return { ok: false, message: `${path} is being used by process ${occupied.by} — it can't be discarded now` };
722
+ busy.set(path, "removing");
723
+ try {
724
+ if (!existsSync(path)) {
725
+ this.store.transact(() => { const still = this.store.getWorktree(path); if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
726
+ this.store.forgetWorktree(path); });
727
+ return { ok: true, row };
728
+ }
729
+ return (await this.removeWorkingCopy(row)) ? { ok: true, row } : { ok: false, message: `git would not remove ${path}` };
730
+ }
731
+ finally {
732
+ busy.delete(path);
733
+ }
734
+ }
735
+ /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
736
+ async removeWorkingCopy(row) {
737
+ // Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
738
+ const note = join(row.path, MARKER);
739
+ try {
740
+ writeFileSync(note, `${process.pid} storage-retention removing ${currentBootId() ?? "unknown"} ${hostname()}\n`, "utf8");
741
+ }
742
+ catch {
743
+ return false;
744
+ }
745
+ const gone = await this.git(row.repo, ["worktree", "remove", "--force", row.path]);
746
+ if (gone.code !== 0) {
747
+ rmSync(note, { force: true });
748
+ return false;
749
+ }
750
+ this.store.transact(() => {
751
+ const still = this.store.getWorktree(row.path);
752
+ if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
753
+ this.store.forgetWorktree(row.path);
754
+ });
755
+ return true;
756
+ }
719
757
  /**
720
758
  * Whether anybody's work is in there. null means we could not tell, which is
721
759
  * neither clean nor dirty and must not be rounded to either.
@@ -723,7 +761,7 @@ export class WorktreePool {
723
761
  * `--untracked-files=all` and no `--ignored`: build output that .gitignore
724
762
  * hides is not work, but a file somebody dropped in and never staged is.
725
763
  */
726
- async isDirty(path) {
764
+ async isDirty(path, ignoreOwnFiles = false) {
727
765
  const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
728
766
  if (status.code !== 0)
729
767
  return null;
@@ -731,10 +769,12 @@ export class WorktreePool {
731
769
  // makes every checkout permanently dirty, which would jam the pool shut on
732
770
  // the first lease — untracked files counting as dirty is the right rule,
733
771
  // and this is the one file it must not apply to.
772
+ // Deciding whether a checkout can go, the files Toolroll wrote at its top (a progress note, a handoff, a
773
+ // proof list) are not a person's work either; any other untracked or changed file still is.
734
774
  return status.stdout
735
775
  .split("\n")
736
776
  .filter(line => line.trim() !== "")
737
- .some(line => !line.trimEnd().endsWith(MARKER));
777
+ .some(line => !line.trimEnd().endsWith(MARKER) && !(ignoreOwnFiles && line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3).trim())));
738
778
  }
739
779
  git(cwd, args, prefix = []) {
740
780
  return this.runner(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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"