Skip to content

feat(pool): free leases whose declared holder process has exited - #188

Open
m-moravcik wants to merge 8 commits into
kunchenguid:mainfrom
m-moravcik:fm/treehouse-lease-liveness
Open

m-moravcik wants to merge 8 commits into
kunchenguid:mainfrom
m-moravcik:fm/treehouse-lease-liveness

Conversation

@m-moravcik

@m-moravcik m-moravcik commented Oct 18, 2026 •

Copy link
Copy Markdown

Why

A durable lease has no expiry, so an agent that crashes or forgets treehouse return blocks its slot forever. This lets a lease name the long-lived process that holds it. Once that process has provably exited, the next pool command frees the lease, but only if the slot passes the same safety check #159 applies to recovered entries.

What changes

  • treehouse get --lease --lease-holder-pid <pid> records the holder's PID and a start stamp in pool state (lease_holder_pid, lease_holder_start, both omitempty, no state version bump). It is the only input that records a holder.
  • WithStateLock already runs recoverQuarantinedEntries, so get, status, return, lease, enter, prune and destroy all run it. That pass now also judges leases with a declared holder. If the holder is proven gone, the slot goes through the unchanged recoverSafeEntry predicate: no process in the slot (a shell standing in it counts), no tracked changes (submodules and skip-worktree/assume-unchanged included), HEAD contained in a remote-tracking ref or the slot's base, and on Unix untracked files moved into the retained backup first.
    • Pass: only the lease is cleared. The seed inventory is kept, so the next reuse removes files seeded through .worktreeinclude / --include-file, as after a return.
    • Fail: the lease is kept and recovery_reason says why (its holder process 1234 exited, but ...); status shows it.
  • Public output is otherwise unchanged: the holder PID is not added to get --json or status --json.

Holder identity contract

  • Declared, never inferred. treehouse get --lease exits as soon as it prints the path, and harnesses that start a fresh shell per command (Claude Code's Bash tool, for one) make $$ just as short-lived. Recording the caller or its parent automatically would free a lease between allocation and first use.
  • A lease without a recorded holder (no --lease-holder-pid, treehouse lease <name>, or state written before this change) is never freed by holder liveness; only treehouse return releases it.
  • process.VerifyHolder accepts only an ancestor of the running command, refuses treehouse itself, and reads the start stamp before and after the ancestry walk. Ancestry also refuses a PID already recycled to an unrelated process. It runs before the fetch, so a refusal never touches the pool, and the holder is alive when the lease is written.
  • Shell callers: --lease-holder-pid $$ names the shell that runs treehouse, so the lease lives until that shell exits. Agent callers: name the agent process ($PPID in a per-command tool shell).
  • Liveness is tri-state (process.IdentityLiveness). Only a free PID, or one now held by a process with a different start stamp, is gone. A live holder, a holder without a recorded start, an unreadable process table and an unsupported platform all keep the lease.
  • The start stamp never changes during a process's lifetime: kernel-recorded p_starttime on macOS/BSD (gopsutil CreateTime), and the raw start ticks from /proc/<pid>/stat on Linux. I did not reuse process.StartedAt because gopsutil's Linux CreateTime adds a boot time recomputed from the wall clock on every read; over a lease's lifetime a stepped clock would make a live holder look recycled. Existence uses kill(pid, 0), so EPERM or hidepid never reads as gone.

Limitations

  • Windows: --lease-holder-pid is refused with an explicit error. A parent PID there is never reparented and can name an unrelated process after reuse, so the holder cannot be verified.
  • jj slots: the reused predicate verifies Git worktrees only, so a jj lease with a gone holder stays leased with a reason.
  • PID and start stamp mean something only inside one machine and PID namespace, the same assumption owner reservations make. A pool shared with a container that has its own PID namespace is not supported.
  • Containment is by ancestry, as in feat(pool): automatically free safe recovered slots #159. A slot whose branch was squash-merged and whose remote branch was then deleted and pruned locally is not contained, so its lease is kept with a reason and needs treehouse return.
  • A zombie holder counts as alive until reaped, and a PID reused with an identical start stamp reads as alive. Both keep the lease.
  • Freeing is state only. Reuse still goes through acquire's in-use, dirty, merged and HEAD.lock checks. Like any available slot today, a freed slot whose HEAD is merged may be deleted by prune --yes.

Related

#187 adds prune --return. The two do not depend on each other and merge cleanly in either order. Together, prune --return --yes also returns a landed slot whose holder exited, provided a remote-tracking ref contains its HEAD (for example the pushed PR branch). Prune's snapshot takes the state lock, which frees the lease first, and the kept seed inventory lets the return remove seeded files. A squash-merged HEAD that no remote-tracking ref contains any more fails the containment check above, so that lease stays held with a reason.

Tests

  • internal/process: stable stamp, missing process, alive / exited / recycled / missing identity, VerifyHolder (parent accepted; self, non-ancestor, exited and non-positive refused), Linux stat parsing with spaces and parentheses in the command name, Windows unsupported.
  • internal/pool: exited holder freed; live holder, no holder, PID without start, unreadable liveness and legacy state stay leased; recycled PID freed; tracked edits and unlanded HEAD kept with a reason; untracked files moved to backup, then freed; busy slot freed only after the resident process exits; seeded file removed by the next reuse; a slot returned and re-leased to a live holder keeps the new lease; --if-lease-id of a reclaimed lease refused; six concurrent get --lease against one reclaimable slot in a max_trees = 1 pool hand it out exactly once.
  • cmd e2e with the built binary: a shell leases with $$ and exits, the next status shows the slot available and get --lease reuses it; live holder and no holder stay leased; an exited holder over a dirty slot is kept with the reason in JSON and human output; refusals leave the pool empty.
  • Local, at cf7cbdc on macOS (the pipeline re-ran lint and tests on each later fix commit): make lint and make test pass (uncached, with a global core.hooksPath unset; the default 10-minute package timeout was raised to 30 minutes on a heavily loaded machine); GOOS=windows go build ./... and go vet ./... pass. With feat(prune): add --return to hand landed worktrees back to the pool #187 merged in as well, the full suite passes, plus an ad-hoc end-to-end check of the composition described above (not part of this PR).

Risk Assessment

✅ Low: Auto-freeing fails closed: only a provably exited, explicitly declared holder lets a lease go, and only through the existing recovered-entry safety predicate under the state lock. The recorded human decisions are applied (no env var, no public PID output, seed inventory kept), and every intent case (live, exited, recycled, no PID, unknown) has tests through the real package APIs and the CLI.

Testing

I built the treehouse binary and drove 13 scenarios live, using a disposable bare origin, a clone and an isolated HOME with TREEHOUSE_* vars stripped. Covered: a live holder, an exited holder, no holder, a simulated PID reuse, three unsafe-slot cases with their kept-leased reasons, untracked-file backup, the seed inventory being kept and removed on reuse, six invalid-declaration refusals, get as the freeing command, and four concurrent get --lease runs. All passed. I also ran the targeted holder/identity unit and e2e tests and GOOS=windows go build ./..., which passed. CLI transcripts are saved in the evidence dir. The temp env is torn down and the worktree is clean. This is CLI-only, so there are no screenshots.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
A lease taken with --lease-holder-pid $$ stays leased while that shell is alive (status shows leased; state records PID + start stamp) ✅ pass live s1-s2-holder-alive-then-exit.txt
Once the holder shell exits, the next treehouse status shows the slot available and the lease is cleared ✅ pass live s1-s2-holder-alive-then-exit.txt
A plain get --lease with no holder PID stays leased across commands until treehouse return; JSON output has no lease_holder_pid ✅ pass live s3-no-holder-stays-leased.txt
Recorded PID now held by an unrelated live process with a different start time is treated as gone and the lease is freed ✅ pass live s4-recycled-pid-freed.txt
Holder exits but the worktree has a tracked edit: still leased, status shows a kept leased: its holder process N exited, but tracked changes... reason (text and JSON recovery_reason) ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits but HEAD has an unpushed local commit: still leased with a HEAD-not-contained reason ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits but a process still runs in the worktree: still leased; after that process ends, the next command frees it ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits leaving an untracked file: the lease is freed and the file moves into the retained backup folder ✅ pass live s8-s9-untracked-backup-and-seed-inventory.txt
A lease that seeded .env via --include-file keeps seeded_paths after the holder-exit free, and a later plain get reuse removes .env ✅ pass live s8-s9-untracked-backup-and-seed-inventory.txt
Non-ancestor PID, free PID, 0, negative, non-numeric, and holder PID without --lease are refused (exit 1) without creating or changing slots ✅ pass live s10-refusals.txt
get as the next command frees an exited-holder lease and reuses that slot instead of growing the pool ✅ pass live s12-get-frees-and-reuses.txt
Four concurrent get --lease runs after three holders exit: four distinct paths, three freed slots reused, state consistent ✅ pass live s13-concurrent-gets.txt
Evidence: Live holder keeps lease; exited holder freed by next command
\### S1/S2: holder alive keeps lease; holder exits -> next command frees it
$ sh -c 'p=$(treehouse get --lease --no-fetch --lease-holder-pid $$); echo "$p"; echo holder=$$; sleep 4; true' &
/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
holder=10101
$ ps -p 10101 -o pid,lstart,command
  PID STARTED                      COMMAND
10101 Sun Oct 18 13:55:53 2026     sh -c p=$(treehouse get --lease --no-fetch --lease-holder-pid $$) || exit 1; echo "$p"; echo holder=$$; sleep 4; true
$ treehouse status   # holder alive
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)
$ state file entry
{'name': '1', 'leased': True, 'lease_holder_pid': 10101, 'lease_holder_start': 1792324553936}
# holder 10101 exited: gone
$ treehouse status   # holder gone
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)
$ treehouse status --json
[{"name":"1","path":"/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo","status":"available","branch":"","detached":true,"flavor":"git","lease_id":"","lease_holder":"","leased_at":null,"processes":[]}]
Evidence: Lease without holder stays leased; JSON has no holder PID
\### S3: lease without recorded holder stays leased; JSON output carries no holder PID
$ treehouse get --lease --no-fetch --json
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
{"path":"/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo","lease_id":"e5c09846f49830a0fdd0703d5a5ad0b5","lease_holder":"","leased_at":"2026-10-18T13:56:07.714844+02:00","base_branch":"main"}
$ treehouse status (x2)
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)
{'name': '1', 'leased': True, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': None, 'recovery_reason': None}
$ treehouse return /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
🌳 Worktree returned to pool.
exit=0
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)
Evidence: Recycled PID with different start time treated as gone
\### S4: recycled PID (PID alive, different start time) counts as gone -> freed
holder sh pid=36143 (alive)
{'name': '1', 'leased': True, 'lease_holder_pid': 36143, 'lease_holder_start': 1792324578934, 'seeded_paths': None, 'recovery_reason': None}
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)
unrelated live process pid=38233: Sun Oct 18 13:56:21 2026     /bin/zsh -c source ~/.claude-monivio3/shell-snapshots/snapshot-zsh-1792324511886-2ixl9k.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { �uiltin unalias -- 'unsetenv'; �uiltin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'T=$(cat /tmp/th-live-root); . $T/env.sh; cd $REPO; E=~/.no-mistakes/evidence/01M57DNTZSJR2C5EHB32BXDDPT; {
echo '"'"'### S4: recycled PID (PID alive, different start time) counts as gone -> freed'"'"'
sh -c '"'"'p=$(treehouse get --lease --no-fetch --lease-holder-pid $$) || exit 1; echo "$p"; sleep 30; true'"'"' > $T/s4.out 2>&1 &
HOLDER=$!; sleep 2; echo "holder sh pid=$HOLDER (alive)"; showstate
treehouse status 2>&1
sleep 600 & OTHER=$!; echo "unrelated live process pid=$OTHER: $(ps -p $OTHER -o lstart= -o command=)"
echo "# rewrite the recorded lease_holder_pid to $OTHER, keeping the original holder start stamp (simulated PID reuse)"
python3 - "$ST" "$OTHER" <<'"'"'PY'"'"'
import json,sys
p,pid=sys.argv[1],int(sys.argv[2]); s=json.load(open(p))
for w in s['"'"'worktrees'"'"']:
    if w.get('"'"'leased'"'"'): w['"'"'lease_holder_pid'"'"']=pid
json.dump(s,open(p,'"'"'w'"'"'),indent=2)
PY
showstate
echo "\$ ps -p $OTHER  # PID is alive"; ps -p $OTHER -o pid=,command=
echo '"'"'$ treehouse status'"'"'; treehouse status 2>&1
showstate
kill $HOLDER $OTHER 2>/dev/null; wait 2>/dev/null
} 2>&1 | tee $E/s4-recycled-pid-freed.txt' && pwd -P >| /tmp/claude-c6ef-cwd
# rewrite the recorded lease_holder_pid to 38233, keeping the original holder start stamp (simulated PID reuse)
{'name': '1', 'leased': True, 'lease_holder_pid': 38233, 'lease_holder_start': 1792324578934, 'seeded_paths': None, 'recovery_reason': None}
$ ps -p 38233  # PID is alive
38233 sleep 600
$ treehouse status
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)
{'name': '1', 'leased': None, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': None, 'recovery_reason': None}
Evidence: Unsafe slots kept leased with reason
\### S5: holder exits but worktree has a tracked edit -> kept leased with reason
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
leased /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo; holder shell exited after editing a.txt
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (kept leased: its holder process 52965 exited, but tracked changes (including submodule contents) are present; commit or preserve them; inspect it, then free it with: treehouse return '/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo')
{'name': '1', 'leased': True, 'lease_holder_pid': 52965, 'lease_holder_start': 1792324592521, 'seeded_paths': None, 'recovery_reason': 'its holder process 52965 exited, but tracked changes (including submodule contents) are present; commit or preserve them'}
$ treehouse status --json
[{"name":"1","path":"/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo","status":"leased","branch":"","detached":true,"recovery_reason":"its holder process 52965 exited, but tracked changes (including submodule contents) are present; commit or preserve them","flavor":"git","lease_id":"4c92588477aeec4748f824514786a302","lease_holder":"","leased_at":"2026-10-18T13:56:33.108027+02:00","processes":[]}]
$ treehouse return --force /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
🌳 Worktree returned to pool.
exit=0

\### S6: holder exits but worktree has an unpushed local commit -> kept leased
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
leased /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo; holder committed b.txt locally and exited
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (kept leased: its holder process 54461 exited, but HEAD is not contained in a remote-tracking ref or the slot's base branch; push or preserve it; inspect it, then free it with: treehouse return '/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo')
$ treehouse return --force /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
🌳 Worktree returned to pool.
exit=0

\### S7: holder exits but another process still runs inside the worktree -> kept leased
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
leased /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo; holder left a background sleep inside the worktree and exited
resident: 67348 sleep 601
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (kept leased: its holder process 66768 exited, but a process is using this worktree (a shell standing in it counts); stop it or leave the worktree; inspect it, then free it with: treehouse return '/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo')
                   sleep (67348)
# resident process killed; next command:
$ treehouse status
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)
{'name': '1', 'leased': None, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': None, 'recovery_reason': None}
Evidence: Untracked backup and seed inventory kept, reuse removes seeded file
\### S8: holder exits leaving an untracked file -> lease freed, file moved into retained backup
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
leased /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo; holder wrote untracked notes.txt and exited
$ treehouse status
treehouse: recovered untracked files from /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo into retained backup /tmp/th-live.5IHK/home/.treehouse/treehouse-recovered-backup-repo-2f467e-1
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
worktree has notes.txt? no
backup contents:
$T/home/.treehouse/treehouse-recovered-backup-repo-2f467e-1/notes.txt

\### S9: seeded file inventory survives a holder-exit free; later reuse removes the seeded file
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
leased /tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo with --include-file manifest seeding .env; holder exited
seeded .env present: SECRET=1
$ treehouse status
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
{'name': '1', 'leased': None, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': ['.env'], 'recovery_reason': None}
$ treehouse get --lease --no-fetch   # plain reuse, no manifest
/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
reused same slot: yes
seeded .env after reuse: removed
{'name': '1', 'leased': True, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': None, 'recovery_reason': None}
Evidence: Invalid holder declarations refused
\### S10: invalid holder declarations are refused before touching the pool
$ treehouse get --lease --no-fetch --lease-holder-pid 96342
lease holder process 96342 is not an ancestor of this treehouse command; name a process that started it, such as your shell ($$) or the agent that runs it
exit=1
$ treehouse get --lease --no-fetch --lease-holder-pid 96342
cannot identify lease holder process 96342: no such process
exit=1
$ treehouse get --lease --no-fetch --lease-holder-pid 0
invalid lease holder PID 0
exit=1
$ treehouse get --lease --no-fetch --lease-holder-pid -5
invalid lease holder PID -5
exit=1
$ treehouse get --no-fetch --lease-holder-pid 96326
--lease-holder-pid requires --lease
exit=1
$ treehouse get --lease --no-fetch --lease-holder-pid abc
invalid argument "abc" for "--lease-holder-pid" flag: strconv.ParseInt: parsing "abc": invalid syntax
exit=1
$ treehouse status   # pool untouched: still exactly the one slot (returned at the end of S9), no new lease or slot
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)

\### S11: the calling shell ($$) is an accepted ancestor holder
$ treehouse get --lease --no-fetch --lease-holder-pid 96326 --json
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
{"path":"/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo","lease_id":"ca3a32da3854b64ba64f7863571ba4e0","lease_holder":"","leased_at":"2026-10-18T13:57:11.12599+02:00","base_branch":"main"}
exit=0
{'name': '1', 'leased': True, 'lease_holder_pid': 96326, 'lease_holder_start': 1792324630120, 'seeded_paths': None, 'recovery_reason': None}
Evidence: get frees and reuses exited-holder slot
\### S12: the S11 holder was a per-command harness shell that has since exited; the next command is `get`, which frees and reuses that slot instead of growing the pool
{'name': '1', 'leased': True, 'lease_holder_pid': 96326, 'lease_holder_start': 1792324630120, 'seeded_paths': None, 'recovery_reason': None}
$ treehouse get --lease --no-fetch --json
🌳 Setting up worktree...
🌳 Leased worktree at ~/.treehouse/repo-2f467e/1/repo. Run 'treehouse return ~/.treehouse/repo-2f467e/1/repo' to release it.
{"path":"/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo","lease_id":"391d5fc80d6018542813081f326535e8","lease_holder":"","leased_at":"2026-10-18T13:57:21.806042+02:00","base_branch":"main"}
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
{'name': '1', 'leased': True, 'lease_holder_pid': None, 'lease_holder_start': None, 'seeded_paths': None, 'recovery_reason': None}
$ treehouse return 1
🌳 Worktree returned to pool.
Evidence: Concurrent gets reclaim safely
\### S13: concurrency - three exited-holder leases, then four parallel `get --lease` commands race under the state lock
# holders alive:
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
2     leased       ~/.treehouse/repo-2f467e/2/repo  (detached)
3     leased       ~/.treehouse/repo-2f467e/3/repo  (detached)
# all three holders exited; four concurrent gets:
get#3 exit=0 path=/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/1/repo
get#2 exit=0 path=/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/2/repo
get#1 exit=0 path=/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/3/repo
get#4 exit=0 path=/tmp/th-live.5IHK/home/.treehouse/repo-2f467e/4/repo
distinct paths handed out: 4
$ treehouse status
base  main  (repository default)
1     leased       ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
2     leased       ~/.treehouse/repo-2f467e/2/repo  (detached)
3     leased       ~/.treehouse/repo-2f467e/3/repo  (detached)
4     leased       ~/.treehouse/repo-2f467e/4/repo  (detached)
state entries: 4 leased: 4
base  main  (repository default)
1     available    ~/.treehouse/repo-2f467e/1/repo  (detached)  (recovered untracked files kept in ~/.treehouse/treehouse-recovered-backup-repo-2f467e-1)
2     available    ~/.treehouse/repo-2f467e/2/repo  (detached)
3     available    ~/.treehouse/repo-2f467e/3/repo  (detached)
4     available    ~/.treehouse/repo-2f467e/4/repo  (detached)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ internal/pool/recovered.go:51 - Every pool command runs recoverQuarantinedEntries first, inside WithStateLock. Take a script that names the agent with --lease-holder-pid and runs treehouse return --if-lease-id &lt;id&gt; &lt;path&gt; after the agent exits. The return command's own ValidateReleasePreconditions lock frees the lease first, if the slot is safe. The conditional release then fails with lease precondition failed: worktree ... is not leased (exit 1) and does not reset the worktree. The outcome is harmless: the slot is free, it passed the safety predicate, and the next reuse resets it. This also matches the rule that a command which leaves the world unchanged must not exit 0. But a set -e cleanup step could treat it as a failure. Noted for awareness only; no change required.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
A lease taken with --lease-holder-pid $$ stays leased while that shell is alive (status shows leased; state records PID + start stamp) ✅ pass live s1-s2-holder-alive-then-exit.txt
Once the holder shell exits, the next treehouse status shows the slot available and the lease is cleared ✅ pass live s1-s2-holder-alive-then-exit.txt
A plain get --lease with no holder PID stays leased across commands until treehouse return; JSON output has no lease_holder_pid ✅ pass live s3-no-holder-stays-leased.txt
Recorded PID now held by an unrelated live process with a different start time is treated as gone and the lease is freed ✅ pass live s4-recycled-pid-freed.txt
Holder exits but the worktree has a tracked edit: still leased, status shows a kept leased: its holder process N exited, but tracked changes... reason (text and JSON recovery_reason) ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits but HEAD has an unpushed local commit: still leased with a HEAD-not-contained reason ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits but a process still runs in the worktree: still leased; after that process ends, the next command frees it ✅ pass live s5-s7-unsafe-slot-kept.txt
Holder exits leaving an untracked file: the lease is freed and the file moves into the retained backup folder ✅ pass live s8-s9-untracked-backup-and-seed-inventory.txt
A lease that seeded .env via --include-file keeps seeded_paths after the holder-exit free, and a later plain get reuse removes .env ✅ pass live s8-s9-untracked-backup-and-seed-inventory.txt
Non-ancestor PID, free PID, 0, negative, non-numeric, and holder PID without --lease are refused (exit 1) without creating or changing slots ✅ pass live s10-refusals.txt
get as the next command frees an exited-holder lease and reuses that slot instead of growing the pool ✅ pass live s12-get-frees-and-reuses.txt
Four concurrent get --lease runs after three holders exit: four distinct paths, three freed slots reused, state consistent ✅ pass live s13-concurrent-gets.txt
  • go build -o $T/bin/treehouse . + isolated HOME/XDG_CONFIG_HOME, bare origin + clone
  • sh -c &#39;p=$(treehouse get --lease --no-fetch --lease-holder-pid $$); ...; sleep 4&#39; &amp; then treehouse status while alive and after exit
  • treehouse get --lease --no-fetch --json (no holder) + repeated treehouse status + treehouse return
  • State file lease_holder_pid rewritten to a live unrelated sleep PID (original start stamp kept) then treehouse status
  • Holder exits after tracked edit / local unpushed commit / leaving a resident sleep in the worktree, then treehouse status and status --json
  • Holder exits leaving untracked notes.txt -> treehouse status backup move
  • get --lease --include-file personal.manifest --lease-holder-pid $$, holder exits, status, then plain get --lease reuse checks .env removal
  • Refusals: non-ancestor PID, free PID, 0, -5, without --lease, non-numeric
  • Exited per-command-shell holder then treehouse get --lease --json frees and reuses slot 1
  • 3 exited-holder leases + 4 concurrent treehouse get --lease
  • go test ./internal/process/ ./internal/pool/ -run 'Holder|Identity|StartStamp|Liveness'
  • go test ./cmd/ -run Holder -v
  • GOOS=windows go build ./...
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

get --lease --lease-holder-pid (or TREEHOUSE_LEASE_HOLDER_PID) records the holder's PID and a stable start stamp. The holder must be an ancestor of the command, never treehouse itself. Every pool command judges it under the state lock; a holder proven gone (free PID or a different start) has its lease freed only when the slot passes the recovered-entry safety check, otherwise the lease is kept with a reason. Live, undeclared and unreadable holders keep the lease; Windows refuses the option.
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with no outstanding findings.

Reviews (3) · Last reviewed commit: "no-mistakes(ci): ci-1 (Greptile P2, "Pro..." · Reviewed by Greptile

Comment thread internal/pool/pool.go
Comment thread cmd/get.go
… Both bugs were reproduced first: each new test failed before its fix and passes after it. ci-1. Rule: a `get` that is still running its post_create hooks must keep its slot even if the declared holder exits. Only one place creates a lease with a holder: `markAcquired`, which both the reuse and the fresh-slot paths go through. The `lease <name>` takeover never declares a holder. Fix: in internal/pool/pool.go, `markAcquired` keeps the acquiring command's owner reservation (`reserveOwner`) on a lease with a holder. After `hooks.Run`, acquire calls the new `endSetupReservation`, which clears that reservation under the state lock, but only while the entry still has this lease ID and this process's PID. The existing `recoverSafeEntry` already refuses a slot with a live owner (`ownerAlive`), so recovered.go is unchanged. If `get` crashes, the reservation names a dead process, `healState` clears it, and the lease does not stay pinned. Regression test `TestHolderExitingWhilePostCreateHooksRunKeepsTheLease` in internal/pool/holder_test.go: a hook leaves the worktree and blocks, and meanwhile the holder is replaced by one that has exited. The test then checks that `List` still shows the lease, that a second `get` cannot take the slot, that the reservation is gone after the hooks finish, and that the next command frees the slot. ci-2. Rule: an invalid `--lease-holder-pid` (non-positive, self, exited, not an ancestor, or on Windows) must be refused before any write to the pool or the git excludes. Fix: in cmd/get.go, `getRunE` now calls `process.VerifyHolder` in the flag-validation block, before repo resolution and `EnsureExcluded`. This replaces the separate `<= 0` check, which `VerifyHolder` already covers. The check in acquire stays and still records the start time it proves. `TestLeaseHolderPIDIsRefusedWhenItCannotBeHonored` now runs every case with `--root .`, adds "exited" and "negative" cases, and asserts that `<repo>/.treehouse`, its `.gitignore` and `.git/info/exclude` are untouched. Docs: the README holder paragraph and the docs/design.md holder entry now describe the earlier verification and the reservation held while hooks run. No em dashes were added. Verification: `make lint` is clean, `make test` passes in every package, `GOOS=windows go build ./...` succeeds, and the two new tests passed on 5 and 3 repeated runs
Comment thread internal/process/identity_linux.go
…ide"): fixed only as the user decided, with a one-sentence documentation change. Rule violated: AGENTS.md says to use gopsutil for process data. But `internal/process/identity_linux.go` reads `/proc/<pid>/stat` start ticks directly, and `internal/process/identity_unix.go` calls `syscall.Kill(pid, 0)`, and the guide gave no exception for either. I checked every place in `internal/process/identity_*` that touches native process data: `identity_bsd.go` already uses gopsutil `CreateTime`, and `identity_windows.go` is unsupported. So the only two deviations are the Linux `/proc` read and the Unix `kill(pid, 0)`, and the exception names exactly those. Change: AGENTS.md, in the Windows-compatibility gopsutil bullet, now adds this exception: "Exception: holder identity in `internal/process/identity_*` reads Linux `/proc/<pid>/stat` start ticks and uses `kill(pid, 0)` for existence, because gopsutil's Linux `CreateTime` moves with wall-clock steps (see the holder entry in docs/design.md)." The general "use gopsutil" guidance is unchanged. No code, behavior or tests changed, and no em dash was added. Verification: `make lint` (gofmt and go vet) is clean
@m-moravcik

Copy link
Copy Markdown
Author

Could a maintainer please rerun the failed Windows job for this unchanged head (4bcb6de)? It timed out in internal/pool at the Go 10-minute package timeout. Ubuntu, macOS and check jobs passed. My account cannot rerun upstream jobs (GitHub returns HTTP 403). I have not changed the code or waived the required check.

Failed job: https://github.com/kunchenguid/treehouse/actions/runs/39144330969/job/117755250310

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.

1 participant