Skip to content

fix(pool): reuse lowest available slot number - #206

Open
artburkart wants to merge 1 commit into
kunchenguid:mainfrom
artburkart:fm/reuse-freed-slot-numbers
Open

artburkart wants to merge 1 commit into
kunchenguid:mainfrom
artburkart:fm/reuse-freed-slot-numbers

Conversation

@artburkart

Copy link
Copy Markdown

What Changed

  • New slots now get the lowest positive number that no pool entry is using, instead of the highest existing number plus one. When destroy or prune frees a number, the next acquire takes that number again rather than growing the pool past it.
  • When an acquire looks for a free numbered slot (freeTemplatedSlot), it now steps to the next unused number with each attempt. Before, it walked a fixed consecutive range. Now it skips numbers already in state when a candidate's worktree path is occupied.
  • Added two tests: TestNextSlotNumber_ReusesTheLowestFreedNumber and TestFreeTemplatedSlot_SkipsRegisteredNumbersAfterAnOccupiedPath.

Risk Assessment

✅ Low: The change is small and runs under the state lock. Reusing a freed number at a path that is still registered is already handled: git uses worktree add --force, jj forgets the stale path-keyed workspace before adding, the built-in layout reclaims an empty leftover directory, and the templated layout skips an occupied path. The new tests check the 'lowest unused positive number' spec and the skip behaviour.

Testing

I built the real treehouse CLI and ran it against a throwaway git remote and clone, with HOME isolated. After slot 2 was destroyed, the next get reused slot 2 and the one after it created slot 4. With a templated worktree_path, get warned about and skipped an occupied path for slot 1, reused the freed slot 2, then skipped the registered slots 3 and 4 and created slot 5. The foreign directory was left untouched. A binary built from the base commit handed out slot 4 instead of 2, which confirms the change fixes the old behavior. The new focused unit tests pass. All temp directories were removed afterwards.

  • Live validation: ✅ go - 3 of 3 scenarios driven live against the product
Scenario Result Live Evidence
After destroying slot 2 of 1-3, treehouse get --lease reuses slot 2 (local CLI, disposable repo) ✅ pass live scenario1-reuse-freed-slot.txt: get printed ~/.treehouse/myrepo-414a7f/2/myrepo; the pre-fix baseline printed .../4/myrepo
Once the gap is filled, the next get creates slot 4 and no number is used twice ✅ pass live scenario1-reuse-freed-slot.txt: second get gave slot 4; status shows 1, 2, 3, 4
Adversarial: with a templated worktree_path, a foreign directory at slot 1's path is skipped and left untouched, freed slot 2 is reused, and registered slots 3-4 are skipped (next is 5) ✅ pass live scenario2-templated-occupied-path.txt: 'skipped slot 1' warning, leased proj-2, then proj-5; proj-1/keep.txt still present
Evidence: Scenario 1 CLI transcript: freed slot 2 reused, then slot 4
$ treehouse get --lease  (x3)
/tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/1/myrepo
/tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/2/myrepo
/tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/3/myrepo
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/myrepo-414a7f/1/myrepo  (detached)
2     leased       ~/.treehouse/myrepo-414a7f/2/myrepo  (detached)
3     leased       ~/.treehouse/myrepo-414a7f/3/myrepo  (detached)
$ treehouse return /tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/2/myrepo
🌳 Worktree returned to pool.
$ treehouse destroy /tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/2/myrepo --yes
🌳 Destroyed 1 worktree in ~/.treehouse/myrepo-414a7f/2/myrepo and freed 183 B.
  2     [disposable]  183 B  ~/.treehouse/myrepo-414a7f/2/myrepo
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/myrepo-414a7f/1/myrepo  (detached)
3     leased       ~/.treehouse/myrepo-414a7f/3/myrepo  (detached)
$ treehouse get --lease   (expect slot 2 reused)
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/myrepo-414a7f/2/myrepo. Run 'treehouse return ~/.treehouse/myrepo-414a7f/2/myrepo' to release it.
/tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/2/myrepo
$ treehouse get --lease   (expect slot 4)
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/myrepo-414a7f/4/myrepo. Run 'treehouse return ~/.treehouse/myrepo-414a7f/4/myrepo' to release it.
/tmp/tmp.afgzNboWoH/home/.treehouse/myrepo-414a7f/4/myrepo
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/myrepo-414a7f/1/myrepo  (detached)
3     leased       ~/.treehouse/myrepo-414a7f/3/myrepo  (detached)
2     leased       ~/.treehouse/myrepo-414a7f/2/myrepo  (detached)
4     leased       ~/.treehouse/myrepo-414a7f/4/myrepo  (detached)
Evidence: Scenario 2 CLI transcript: templated path, occupied slot 1 skipped, slot 2 reused, then 5
$ treehouse get --lease --worktree-path '{repo_parent}/{repo}-{slot}'  (x4)
/tmp/tmp.afgzNboWoH/work2/proj-1
/tmp/tmp.afgzNboWoH/work2/proj-2
/tmp/tmp.afgzNboWoH/work2/proj-3
/tmp/tmp.afgzNboWoH/work2/proj-4
$ treehouse return /tmp/tmp.afgzNboWoH/work2/proj-1 && treehouse destroy /tmp/tmp.afgzNboWoH/work2/proj-1 --yes
🌳 Worktree returned to pool.
🌳 Destroyed 1 worktree in /tmp/tmp.afgzNboWoH/work2/proj-1 and freed 121 B.
  1     [disposable]  121 B  /tmp/tmp.afgzNboWoH/work2/proj-1
$ treehouse return /tmp/tmp.afgzNboWoH/work2/proj-2 && treehouse destroy /tmp/tmp.afgzNboWoH/work2/proj-2 --yes
🌳 Worktree returned to pool.
🌳 Destroyed 1 worktree in /tmp/tmp.afgzNboWoH/work2/proj-2 and freed 121 B.
  2     [disposable]  121 B  /tmp/tmp.afgzNboWoH/work2/proj-2
$ mkdir /tmp/tmp.afgzNboWoH/work2/proj-1   # foreign directory occupies slot 1's templated path
$ treehouse status
base  main  (repository default)
3     leased       /tmp/tmp.afgzNboWoH/work2/proj-3  (detached)
4     leased       /tmp/tmp.afgzNboWoH/work2/proj-4  (detached)
$ treehouse get --lease --worktree-path '{repo_parent}/{repo}-{slot}'  (expect skip 1, reuse 2)
🌳 Setting up worktree...
🌳 Warning: skipped slot 1 because its worktree path /tmp/tmp.afgzNboWoH/work2/proj-1 already exists.
🌳 Leased worktree at /tmp/tmp.afgzNboWoH/work2/proj-2. Run 'treehouse return /tmp/tmp.afgzNboWoH/work2/proj-2' to release it.
/tmp/tmp.afgzNboWoH/work2/proj-2
$ treehouse get --lease --worktree-path '{repo_parent}/{repo}-{slot}'  (expect skip 1, skip registered 2-4, use 5)
🌳 Setting up worktree...
🌳 Warning: skipped slot 1 because its worktree path /tmp/tmp.afgzNboWoH/work2/proj-1 already exists.
🌳 Leased worktree at /tmp/tmp.afgzNboWoH/work2/proj-5. Run 'treehouse return /tmp/tmp.afgzNboWoH/work2/proj-5' to release it.
/tmp/tmp.afgzNboWoH/work2/proj-5
$ treehouse status
base  main  (repository default)
3     leased       /tmp/tmp.afgzNboWoH/work2/proj-3  (detached)
4     leased       /tmp/tmp.afgzNboWoH/work2/proj-4  (detached)
2     leased       /tmp/tmp.afgzNboWoH/work2/proj-2  (detached)
5     leased       /tmp/tmp.afgzNboWoH/work2/proj-5  (detached)
$ ls /tmp/tmp.afgzNboWoH/work2/proj-1
keep.txt
Evidence: Baseline transcript (pre-fix binary gave slot 4 instead of 2)
# BASE binary (f168807): same steps as scenario 1
/tmp/tmp.afgzNboWoH/home3/.treehouse/myrepo-414a7f/1/myrepo
/tmp/tmp.afgzNboWoH/home3/.treehouse/myrepo-414a7f/2/myrepo
/tmp/tmp.afgzNboWoH/home3/.treehouse/myrepo-414a7f/3/myrepo
🌳 Worktree returned to pool.
🌳 Destroyed 1 worktree in ~/.treehouse/myrepo-414a7f/2/myrepo and freed 184 B.
  2     [disposable]  184 B  ~/.treehouse/myrepo-414a7f/2/myrepo
$ treehouse get --lease
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/myrepo-414a7f/4/myrepo. Run 'treehouse return ~/.treehouse/myrepo-414a7f/4/myrepo' to release it.
/tmp/tmp.afgzNboWoH/home3/.treehouse/myrepo-414a7f/4/myrepo

Pipeline

Updates from git push no-mistakes

⏭️ **Intent** - skipped

Step was skipped.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ internal/pool/pool.go:1534 - Recovery backups are keyed by slot name only (recoveryBackupDir(poolDir, wt.Name) in internal/pool/recovered.go:96), and status reports one for any entry with that name (internal/pool/pool.go:1226). Before this change, a slot number was reused only when the highest slot was freed. Now any freed number is reused, so this case is more common. Example: slot 3 is recovered and its untracked files go to treehouse-recovered-backup-<pool>-3. Slot 3 is then destroyed and a later get creates a new slot 3. status now shows the old backup folder on the fresh slot. Nothing is lost: the folder is kept, and a later recovery writes into a suffixed subfolder. The old pattern already allowed this case, so no change is required.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 3 of 3 scenarios driven live against the product
Scenario Result Live Evidence
After destroying slot 2 of 1-3, treehouse get --lease reuses slot 2 (local CLI, disposable repo) ✅ pass live scenario1-reuse-freed-slot.txt: get printed ~/.treehouse/myrepo-414a7f/2/myrepo; the pre-fix baseline printed .../4/myrepo
Once the gap is filled, the next get creates slot 4 and no number is used twice ✅ pass live scenario1-reuse-freed-slot.txt: second get gave slot 4; status shows 1, 2, 3, 4
Adversarial: with a templated worktree_path, a foreign directory at slot 1's path is skipped and left untouched, freed slot 2 is reused, and registered slots 3-4 are skipped (next is 5) ✅ pass live scenario2-templated-occupied-path.txt: 'skipped slot 1' warning, leased proj-2, then proj-5; proj-1/keep.txt still present
  • go build of the target commit, driven against a disposable bare remote, clone and HOME under /tmp
  • Scenario 1: treehouse get --lease x3 → return + destroy --yes slot 2 → get --lease twice → status
  • Scenario 2: get --lease --worktree-path '{repo_parent}/{repo}-{slot}' x4 → destroy slots 1 and 2 → put a foreign directory at proj-1 → get twice → status → ls proj-1
  • Baseline: same steps as scenario 1 with a binary built from f168807 (gave slot 4)
  • go test ./internal/pool/ -run 'TestFreeTemplatedSlot_SkipsRegisteredNumbersAfterAnOccupiedPath|NextSlot|...'
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge.

Reviews (1) · Last reviewed commit: "fix(pool): reuse lowest available slot n..." · Reviewed by Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: thanks @artburkart. I reviewed the diff (slot-number selection in internal/pool/pool.go plus two tests) and see no security risk, so I approved the pending fork CI runs on 0f13e8a. Greptile is at 5/5 and the live evidence covers reuse, gap fill, and the templated-path skip case.

Nothing is needed from you while CI runs. One heads-up so it doesn't surprise you later: I'm classifying this as a change to default behavior, which means Kun decides it rather than it auto-merging. Today an unconfigured get after destroy 2 hands out slot 4; with this it hands out slot 2 again, so the path a fresh acquisition lands on changes for everyone. Nothing in the README or design notes promised either numbering, so it isn't a restore. Once CI and no-mistakes are green I'll put it in front of Kun. If that's the only open question, it will be waiting on him, not on you.

Contract-class: new-default (the unconfigured acquisition now picks the lowest free slot number instead of max+1).

VISION per-rule (VISION.md on main f168807; inspected nextSlotNumber / freeTemplatedSlot on main and the tip):

  • Isolation and reuse: aligns. Selection still runs under the state lock and skips any number already in state; a freed number is only reused when no entry holds it.
  • Safe lifecycle operations: aligns, with one thing Kun should weigh: reusing a freed path depends on the existing leftover handling (worktree add --force, jj forget, empty-dir reclaim, templated occupied-path skip). The PR exercises the templated skip; I didn't see a test for a non-empty leftover at a reused built-in-layout path.
  • Ownership and interface: aligns. Smaller, steadier slot numbers are easier to read in status.
  • Scope and evaluation: aligns. Stays inside the pool primitive.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: a quick update, @artburkart. I reran the Windows job on 0f13e8a and it timed out again at Go's 10-minute package limit (cmd 605s, internal/pool 600s). That's the same limit main and other PRs hit. Every other check is green, and Greptile is 5/5.

Nothing here is waiting on you. This is still the new-default call for Kun I described above, plus the repo-wide Windows test budget. I've passed both to him.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants