diff --git a/.no-mistakes/evidence/fm/treehouse-stable-lease-identity-b1/lease-cli-transcript.md b/.no-mistakes/evidence/fm/treehouse-stable-lease-identity-b1/lease-cli-transcript.md new file mode 100644 index 0000000..baec4c5 --- /dev/null +++ b/.no-mistakes/evidence/fm/treehouse-stable-lease-identity-b1/lease-cli-transcript.md @@ -0,0 +1,65 @@ +# Stable lease identity CLI validation + +This transcript came from the compiled `treehouse` binary in a fresh local Git repository with an isolated `HOME`. It exercises the end-user JSON and conditional-return surfaces. + +## 1. Allocate a lease as `automation-A` + +```json +{"path":"/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo","lease_id":"4d6e581c9889f539cf3defb32209c991","lease_holder":"automation-A","leased_at":"2026-07-20T12:20:27.465416-07:00"} +``` + +## 2. Status exposes the same identity + +```json +[{"name":"1","path":"/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo","status":"leased","lease_id":"4d6e581c9889f539cf3defb32209c991","lease_holder":"automation-A","leased_at":"2026-07-20T12:20:27.465416-07:00","processes":[]}] +``` + +## 3. A wrong identity is rejected without releasing the lease + +Command exited 1: + +```text +failed to return worktree: lease precondition failed: lease identity does not match worktree /tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo +``` + +Status still reports the original lease: + +```json +[{"name":"1","path":"/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo","status":"leased","lease_id":"4d6e581c9889f539cf3defb32209c991","lease_holder":"automation-A","leased_at":"2026-07-20T12:20:27.465416-07:00","processes":[]}] +``` + +## 4. The correct identity releases successfully + +```text +🌳 Worktree returned to pool. +``` + +## 5. Same path and holder get a new identity + +Old path: + +```text +/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo +``` + +New allocation: + +```json +{"path":"/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo","lease_id":"42e716b76d721ab9212dba38c062f450","lease_holder":"automation-A","leased_at":"2026-07-20T12:20:27.800304-07:00"} +``` + +Identity comparison: `ids_differ=true`. + +## 6. The old identity cannot release the newer same-holder lease + +Command exited 1: + +```text +failed to return worktree: lease precondition failed: lease identity does not match worktree /tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo +``` + +Status confirms that the newer lease remains current after the stale ABA attempt: + +```json +[{"name":"1","path":"/tmp/treehouse-lease-validation.IP9UTX/home/.treehouse/repo-f547e7/1/repo","status":"leased","lease_id":"42e716b76d721ab9212dba38c062f450","lease_holder":"automation-A","leased_at":"2026-07-20T12:20:27.800304-07:00","processes":[]}] +``` diff --git a/AGENTS.md b/AGENTS.md index b90f483..899190a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,9 @@ make test - No daemon - all operations are inline CLI commands - Detached HEAD worktrees reset to whichever of local or origin default branch is further ahead (prefers origin on divergence) - In-use detection uses process scanning plus short-lived persisted owner reservations for lifecycle operations -- Durable leases are a separate, process-independent reservation: `WorktreeEntry.Leased`/`LeaseHolder`/`LeasedAt` persist in the state file (all `omitempty`, so pre-lease state files keep today's behavior). A lease is NOT derived from live processes, so it survives with zero processes inside the worktree and `healState` never clears it (it only clears dead owner reservations). Leased worktrees are skipped by `Acquire` and `prune`, classified `DestroyLeased` by destroy (removable only when the exact path is named with `--include-leased`, NEVER via `--all`), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`) +- Durable leases are a separate, process-independent reservation: `WorktreeEntry.Leased`/`LeaseID`/`LeaseHolder`/`LeasedAt` persist in the state file with `omitempty`. Every acquisition generates a new immutable 128-bit random `LeaseID`; older state without it loads with an empty ID and remains releasable through the legacy unconditional path. A lease is NOT derived from live processes, so it survives with zero processes inside the worktree and `healState` never clears it. Leased worktrees are skipped by `Acquire` and `prune`, classified `DestroyLeased` by destroy (removable only when the exact path is named with `--include-leased`, NEVER via `--all`), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`) - `destroy` is safe-by-default and mirrors `prune`: dry-run unless `--yes`, narrow explicit targets (`destroy ` for one worktree; `destroy --all` for that pool only - there is NO cross-pool/global destroy, and `--all` with no pool target is an error). The old blunt `--force` flag is REMOVED (this was the v2.0.0 breaking change); each risk class is its own opt-in: `--include-unlanded` (dirty, unmerged, or unverified), `--include-in-use` (running process or owner reservation; processes terminated cleanly first), `--include-leased` (leased, single named path only). A bare `--all --yes` removes only the disposable set (merged, clean, idle, unleased) and skips the rest with the flag that would include each. Bulk skips exit 0; a single-target skip exits non-zero. Entry points: `pool.DestroyWorktree` (single path, `allowLeased=true`) and `pool.DestroyPool` (bulk, `allowLeased=false`). Both share `classifyForDestroy` in `internal/pool/destroy.go`, which reuses prune's classification primitives (`ownerAlive`, `process.FindProcessesInWorktree`, `backingRepositoryMissing`, `git.IsDirty`, `git.IsHeadMergedIntoRef` against the `resolvePruneDefaultRef` ref) so destroy and prune agree on leased/in-use/unlanded/unverified/disposable. Removal keeps the same two-phase reservation as prune (reserve under flock, run `pre_destroy` hooks, remove only worktrees whose `sameDestroyReservation` still holds), so a worktree re-acquired during its hook is never deleted -- `get --lease` (see `getLeaseRunE`) is the non-interactive acquire: it implies the durable lease, opens no subshell, routes post-create hook stdout and all banners to stderr, and prints ONLY the worktree path to stdout so `path=$(treehouse get --lease)` is clean. `--lease-holder`/`$TREEHOUSE_LEASE_HOLDER` set the recorded holder. `pool.AcquireLease` is the entry point; both it and `Acquire` delegate to the shared `acquire(..., acquireOptions)` core, and `markAcquired` stamps either a lease or an owner reservation. Concurrency safety comes from the existing `WithStateLock` (flock) around all pool mutation +- `get --lease` (see `getLeaseRunE`) is the non-interactive acquire: it opens no subshell, routes hook output and banners to stderr, and keeps path-only stdout unchanged. `get --lease --json` returns `pool.AcquireLeaseInfo`, and `status --json` exposes the same `lease_id`, holder, and timestamp. Conditional return uses `pool.ReleaseConditional` with `--if-lease-id` and optional `--if-lease-holder`; comparison, caller-side preparation, reset, and final clear share one `WithStateLock`, while return without conditions keeps the legacy path-only behavior - Dirty checks include untracked files even when repository config hides them from normal `git status` output - Prune deletes only idle managed worktrees that are clean and whose HEAD is merged into the default branch; dry run is the default - Prune reports unsafe idle worktrees in grouped, stable categories and keeps raw git diagnostics for verbose output instead of default output diff --git a/README.md b/README.md index 732e791..204e577 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ The default treehouse root is `~/.treehouse/`. - **No daemon** - all operations are inline CLI commands. Pool state is a small on-disk file, written under a lock by each command. - **In-use detection** — treehouse scans running processes and short-lived owner reservations to determine which worktrees are in-use. Reservations are persisted only while `get`, `destroy`, and `prune` lifecycle work is running. -- **Durable leases** — `treehouse get --lease` reserves a worktree as a persistent home without keeping a process inside it. The lease is recorded in treehouse's own state, so the worktree is never handed out by a later `get` and never removed by `prune` until you release it with `treehouse return`. Unlike process-based in-use detection, a lease survives with zero processes running inside the worktree. +- **Durable leases** - `treehouse get --lease` reserves a worktree as a persistent home without keeping a process inside it. Each acquisition gets an immutable random lease identity, and the lease is recorded in treehouse's own state. The worktree is never handed out by a later `get` and never removed by `prune` until you release it with `treehouse return`. Unlike process-based in-use detection, a lease survives with zero processes running inside the worktree. - **State recovery** - treehouse writes pool state atomically via a temp file and replacement. If an existing state file is empty or truncated, treehouse warns, rebuilds entries from worktrees still on disk, and marks those entries leased until you verify them with `treehouse status`. - **Dirty detection** - treehouse treats tracked changes and untracked files as dirty, even when repository config hides untracked files from normal `git status` output. @@ -165,8 +165,12 @@ The default treehouse root is `~/.treehouse/`. | --------- | --------- | --------------------------------- | | `get` | `--lease` | Durably lease the worktree without opening a subshell; print only its path to stdout | | `get` | `--lease-holder` | Optional label recorded as the lease holder (defaults to `$TREEHOUSE_LEASE_HOLDER`) | +| `get` | `--json` | Print `path`, `lease_id`, `lease_holder`, and `leased_at` as JSON (requires `--lease`) | | `enter` | `--print-path` | Print only the worktree's absolute path to stdout instead of opening a subshell (for `cd "$(treehouse enter --print-path 1)"`) | +| `status` | `--json` | Print worktree status and lease metadata as JSON | | `return` | `--force` | Clean, reset, and return without prompting | +| `return` | `--if-lease-id` | Return only if the current lease has the expected per-acquisition identity | +| `return` | `--if-lease-holder` | Return only if the current lease has the expected holder | | `prune` | `--yes` | Delete listed prune candidates instead of doing a dry run | | `prune` | `--all` | Sweep every managed pool under the user-level treehouse root | | `prune` | `--global` | Alias for `--all` | @@ -190,16 +194,38 @@ path=$(treehouse get --lease) # $path is the leased worktree's absolute path; all banners went to stderr. ``` -It acquires a worktree exactly like `get`, but instead of opening a subshell it marks the worktree **leased** in treehouse's persistent state and prints only the worktree's absolute path to stdout (every human-facing message goes to stderr, so command substitution stays clean). +It acquires a worktree exactly like `get`, but instead of opening a subshell it marks the worktree **leased** in treehouse's persistent state. By default it prints only the worktree's absolute path to stdout; `--json` prints the lease allocation instead. Every human-facing message goes to stderr, so either output mode stays clean. A leased worktree is never handed out by a later `get` and never removed by `prune`, regardless of whether any process runs inside it, until the lease is explicitly released. A bulk `treehouse destroy --all` never removes it either; only naming its exact path with `treehouse destroy --include-leased --yes` will. Pass `--lease-holder