feat(sessions): serialized worktree cleanup, stale-lock reporting, and merge-proven branch deletion - #17
Conversation
roughcoder
left a comment
There was a problem hiding this comment.
Verdict: REQUEST-CHANGES
-
[major]
SessionOrchestrationGitLockErroris unreachable for real worktree lock failures.describeGitFailureparses onlycause.message(apps/server/src/mcp/toolkits/sessions/handlers.ts:906-913), but the production Git driver discards stderr and substitutesfallbackErrorDetailwhen constructingGitCommandError(apps/server/src/vcs/GitVcsDriverCore.ts:873-884; removeWorktree supplies"git worktree remove failed"at :2978-81). Thus the actual.lockpath cannot reach the parser; the test injects raw stderr directly (settleSession.test.ts:497-514). Preserve a bounded stderr diagnostic / structured lock indication through the driver and test that real translation. -
[major] The semaphore is keyed by a non-canonical spelling of the workspace root (
apps/server/src/git/GitRepositoryLock.ts:37-38,67). A symlink or path alias to the same Git common dir receives a second permit, recreating the contention this PR fixes (and/normalizes to empty). Key it by canonical realpath/common-git-dir identity and cover aliases. -
[major] The custom-branch proof is performed before acquiring the semaphore (
handlers.ts:1124-45), thenremoveWorktree/forceddeleteRefrun later under it (:1149-77). If another actor changes the branch while this call queues, the already-proved SHA is stale and the current ref may be deleted. Move/revalidate proof inside the critical section immediately before mutation. -
[minor] Lock parsing is English stderr/quoted-path matching (
handlers.ts:331-37), anddeleteReflock failures are swallowed as a success detail (:1166-77) rather than yielding the structured lock remedy.
#12 ↔ #17 overlap: HIGH — actual merge-tree conflicts in handlers.ts, settleSession.test.ts, tools.ts, contracts, and docs. #12 introduces shared settleChildCascade + archive semantics; #17 has inline settle logic. Manual reconciliation is required; retain the shared cascade, apply hygiene to it/cleanup, and specify archive stop-timeout reporting.
Verified read-only: real-fiber concurrency test is meaningful; removal+branch deletion share a permit; operations themselves are bounded. Merge proof/unauthenticated gh refusal is conservative/structured. No tests/typecheck/lint run.
|
All four fixed in 6de08575e. Finding 1 was the one that mattered most — you were right that the whole lock path was theatre in production. 1. Unreachable lock error. Confirmed: The end-to-end test provokes the real thing: real repo, real branch, a real held Worth recording for the merge gate: I checked empirically that 2. Symlink/alias keying. Key is now 3. Merge-proof TOCTOU. The two heads are re-read inside the critical section immediately before the delete. A branch that moved while queued is kept and reported, not deleted, and not a hard failure: the worktree removal was authorized by the dirty check rather than by the proof, so this lands on the same outcome as never passing 4. Robustness + swallowed detail. Matching is on the Kept the real-fiber concurrency test; it now also covers the alias case at the lock level. Validation: Not touching the #12 overlap — waiting for it to land before rebasing and moving this into |
roughcoder
left a comment
There was a problem hiding this comment.
Delta verdict: REQUEST-CHANGES.
- [major] NOT RESOLVED safely:
gitStderrExcerptis bounded (500 chars, tail-first) but unredacted (apps/server/src/vcs/GitVcsDriverCore.ts:378-397) and is exported onGitCommandError(packages/contracts/src/git.ts:329-345), including generic RPC error paths. Git transport failures can echo credential-bearing HTTPS remotes (userinfo/tokens/query strings), so this can disclose secrets to clients/telemetry. Redact URL credentials/query/fragment before attaching; reuse the source-control transport-safe precedent and add a test.
Original findings:
2. [major] RESOLVED — real driver failures now carry stderr excerpt through the normal helper path (GitVcsDriverCore.ts:874-913); the ref-lock test uses a real held lock and maps to the structured handler error (settleSession.test.ts:561-615). Its scoped temp directory cleans the lock on failure.
3. [major] RESOLVED — repository keys canonicalize real paths and linked-worktree common git dirs, with alias serialization tests and graceful fallbacks (GitRepositoryLock.ts:85-111,148-154; GitRepositoryLock.test.ts:65-160).
4. [major] RESOLVED for deletion safety — head revalidation runs under the permit just before delete (handlers.ts:1259-1273) and permit release is scoped. [minor] A raced reproof returns successful partial cleanup/detail after removing the worktree, rather than the structured BranchNotMerged refusal requested (:1266-1271); test codifies it.
#12: unchanged as claimed; no cascade/archive reconciliation was smuggled in. It remains merge-ready only AFTER #12 lands and a manual shared-handler/contracts/tests/docs reconciliation. No tests/typecheck/lint run.
|
Correction to my delta review: retain a second [major]. The in-lock revalidation closes queued in-process races, but |
|
Round 3 fixed in 0aacc8c68. Finding 1 was a regression I introduced, and it had a witness I should have read. 1. Unredacted excerpt (security). Fixed, and worse than the report suggested: Two tests: unit cases over the redactor, and a real failing 2. Proof-to-delete CAS. You're right that the in-lock re-check only binds writers in this process. One caveat I want on the record rather than buried: 3. Structured partial success. Validation: Still holding on #12 — will rebase and move this into |
roughcoder
left a comment
There was a problem hiding this comment.
Round-4 delta verdict: REQUEST-CHANGES.
- [major] The CAS deletion bypasses Git’s checked-out-in-another-worktree protection.
cleanupChildWorktreeremoves only this child worktree (handlers.ts:1263-1275), then a merge-proven branch usesupdate-ref -d(handlers.ts:1315-1321,GitVcsDriverCore.ts:3175-3189). Another linked worktree can still have that branch checked out (including an external interleaving); the repository semaphore cannot prevent it.git branch -Dpreviously refused this state, butupdate-refdeletes the ref and leaves the other worktree on a dangling symbolic HEAD. Add a checked-out-worktree guard (with a real linked-worktree regression test) or retain Git’s branch safety check alongside CAS. The docs claim that removing the child worktree makes this safe, which is not sufficient.
Resolved: stderr is redacted before 500-char truncation and attachment; the real credentialed-fetch test checks all string error fields. Actual argv is not exposed by this error/logging path (only argument count), though the comment claiming argv cannot contain secrets is too broad. The real-repo moved-ref CAS refusal/intact test is good.
Not run: tests/typecheck/lint, per constraints. #12 reconciliation remains a separate prerequisite.
|
Fixed in 65804cd9f — the caveat is now a guard. Checked-out-branch protection. Inside the critical section, before the CAS, An unreadable worktree list fails closed ( Two tests, and I made the driver one prove the hazard rather than assume it:
Doc nit. You're right, and the comment was wrong in a way my own test demonstrated — my credentialed-URL redaction test passes the URL as an argument. Corrected: credentialed URLs do reach argv (clone/fetch/push take the remote as an argument), which is precisely why redaction is written against URL shapes rather than a command allowlist. argv itself is never attached to an error — only Validation: Standing by for the #12 merge signal to rebase and move this into |
roughcoder
left a comment
There was a problem hiding this comment.
Final delta verdict: REQUEST-CHANGES.
-
[major] The new guard runs only when
provenBranch !== null(handlers.ts:1337), so it misses auto-deletedt3code/*branches (which have no merge proof). Those still reachupdate-ref -d(:1377-1383); a user can hold one viagit worktree add --force, leaving the same dangling-HEAD regression. Apply the guard to every branch deletion and add the real temp-branch case. -
[major] The stated residual is not bounded by CAS: after a clean list, another worktree can check out the exact branch without moving its OID; CAS succeeds and deletes its HEAD ref. The external-worktree TOCTOU therefore remains. Need a design that retains Git’s worktree protection or a controlled interleaving test/explicitly accepted contract.
Resolved: proven-branch guard is fail-closed and real-test-backed; redaction scope comment is corrected. No checks run.
|
Reversed in 31729eb6e. You're right and my round-4 reasoning was wrong in a way I should have caught: I claimed the CAS "bounds the damage", but a checkout doesn't move the branch OID, so the compare-and-swap isn't bounding that race — it can't see it at all. The guard was doing all the work and the CAS was providing false comfort. 1. Porcelain deletion. The driver test now pins the reason rather than the mechanism: a checkout leaves the OID unchanged (asserted), porcelain refuses, and plumbing with that very SHA deletes anyway. Anyone who later wonders why we don't compare-and-swap has the answer in the suite instead of in a commit message. 2. In-lock re-proof kept, residual documented. The trade is stated at the code that accepts it and in the internals doc, in your framing: a checkout we get wrong corrupts someone else's directory and no reflog repairs it; a ref move we miss costs a pointer the reflog still holds. Worktree safety absolute, ref-pointer safety best-effort-plus-reflog. 3. Guard on every deletion. Moved out of the One thing worth flagging, because it made my first attempt at these tests worthless: with Validation: Waiting on the #12 merge signal for the |
roughcoder
left a comment
There was a problem hiding this comment.
Final delta verdict: APPROVE.
Verified the deletion reversal is complete: production deleteRef is porcelain git branch -D, with no expectedSha driver path. The in-lock worktree guard is unconditional, so it covers both merge-proven custom branches and automatic t3code/* deletion; unreadable lists fail closed. A late porcelain checked-out-worktree refusal is parsed back into the structured branch_checked_out_elsewhere result with the conflicting path, rather than returning raw stderr.
The accepted residual is documented accurately: an external ref move between re-proof and porcelain delete may lose a recoverable reflog pointer, while porcelain atomically protects a checked-out worktree. Real custom and temporary linked-worktree tests are load-bearing.
Not run: tests/typecheck/lint, per review constraints. Merge-ready after #12 lands and the planned shared-cascade reconciliation.
Git allows one writer per repository: concurrent `git worktree remove` runs on one repo contend for .git/index.lock, and the losers block until the caller's own timeout kills them rather than queueing. This is one Effect Semaphore per repository root so callers can serialize the mutations that take that lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
settle_session gains `cleanupBranch` (delete a branch Phoenix did not create, against a merge proof), a `branchProof` on the worktree outcome, and a `warning` on the result for a settle whose child process outlived its stop. Two structured errors carry what prose cannot: which leg of the merge proof failed with the SHAs that disagree, and which git lock file is in the way plus the remedy. `ChangeRequest.headRefOid` is the merged commit the proof compares against; optional, since not every host reports one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fore deleting Settling eight children at once with cleanupWorktree made every cleanup fail: concurrent `git worktree remove` calls fought over .git/index.lock until each timed out, and the one that ran alone was the only one that worked. A timed-out removal then left a zero-byte index.lock that blocked every later git command on the repository. - Worktree removal and branch deletion now run inside GitRepositoryLock, keyed by the project root, so parallel settles queue instead of racing. Sessions still settle independently of cleanup. - A git failure naming a lock file answers with SessionOrchestrationGitLockError: the path, its age, whether it matches the conservative stale heuristic (empty and older than 60s), and the remedy. Phoenix never deletes the lock itself — nothing in this process can prove no live git owns it. - cleanupBranch deletes a user's branch only when local head == remote head == the head commit of a merged PR. `git branch --merged` is useless here: this repo squash-merges, so a merged branch is never an ancestor of main. The proof runs before anything is destroyed, so a refusal costs nothing. - A settle whose stop wait times out no longer succeeds silently; it names the provider and the status the session was last seen in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What changed and why: one cleanup per repository at a time, a leftover lock reported rather than removed (with the heuristic and why Phoenix will not delete it), the squash-merge trap that makes `git branch --merged` the wrong proof, and the settle warning for a process that outlived its stop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and the merge proof race-free Review findings on #17, all four of which were the difference between code that looks right and code that works. - The structured lock error could never fire in production: the driver threw away git's stderr and substituted a fixed detail string, so only a test's injected message ever matched. GitCommandError now carries a bounded stderrExcerpt (tail-first — the fatal line is last), and the driver attaches it to every non-zero exit. Proven end to end against a real held ref lock: real repo → real git failure → structured error naming the real lock file. - The semaphore was keyed on a trimmed path, so a symlinked alias or a linked worktree of the same repository got a *different* permit and serialized nothing. The key is now realpath + the repository's common git directory, with tests for both aliases (they fail against the old keying). - The merge proof was computed before the semaphore and acted on inside it, so a branch updated while a cleanup queued would be deleted on a stale proof. The two heads are re-read inside the critical section immediately before the delete; a branch that moved is kept and reported instead. - Lock matching keys on the .lock path rather than on git's English, and a branch delete that fails on a lock now raises the same structured error instead of swallowing it into a detail string — after clearing the thread's worktreePath, since the directory really is gone by then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…en branches by compare-and-swap Round-3 review on #17. - The stderr excerpt I added last round crossed RPC unredacted, and git echoes remote URLs that can carry their own credentials (https://x-access-token:ghs_...@host/o/r). Userinfo, query strings, fragments, and known token shapes are stripped before the excerpt is attached — and before it is truncated, so nothing survives on a boundary. Scheme, host, and path stay: which remote failed is the diagnostic value. This restores an invariant an existing driver test already asserted for args and raw stderr, and which the excerpt had quietly reversed. - The in-lock re-proof is only atomic against writers in this process; a terminal or a second Phoenix can still move the ref between the check and the delete. deleteRef now takes an expectedSha and routes through `git update-ref -d <ref> <old>`, so git itself refuses a moved ref under its own lock. Temporary t3code branches keep the plain force delete: no proof, nothing to hold git to. - A cleanup that removes the worktree but keeps the branch now reports a structured worktree.branchRefusal (branch, reason, SHAs, the expected commit) sharing its reason vocabulary with the BranchNotMerged error, instead of folding a partial success into a prose detail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hecked out Round-4 review on #17. The compare-and-swap closed the ref race but opened a hole: `git update-ref -d` is plumbing and, unlike `git branch -d`, deletes a branch another linked worktree is sitting on — leaving that worktree's HEAD pointing at nothing. Removing our own worktree first is not enough, because the other one can be a worktree a user made by hand that this process has never heard of and the repository lock does not cover. So inside the critical section, before the CAS, the worktree list is consulted and a branch still held anywhere is kept with a structured refusal naming the conflicting path. An unreadable worktree list fails closed for the same reason. The CAS stays as the final atomicity layer. The driver test proves the hazard rather than assuming it: `git branch -d` refuses a checked-out branch, plumbing deletes it. The settle test is the real regression the reviewer asked for — a real repository with a real second worktree, refused; the worktree removed; deleted. Also corrects a comment that claimed argv never carries credentials: clone, fetch, and push take the remote URL as an argument, which is exactly why redaction is written against URL shapes rather than a command allowlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design reversal, and the reasoning I gave last round was wrong. I argued the compare-and-swap bounded the worktree TOCTOU. It does not bound it at all: a checkout does not move the branch's OID, so `update-ref -d <ref> <sha>` succeeds while another worktree holds the branch and leaves that worktree's HEAD dangling. The two remaining races are not equally survivable, and that decides the mechanism. A checkout we get wrong corrupts someone else's directory and no reflog repairs it. A ref move we miss costs a ref pointer the reflog still holds. `git branch -D` refuses a checked-out branch atomically at delete time and cannot see a ref move; the compare-and-swap is exactly the reverse. So: - deleteRef is porcelain again, and the expectedSha parameter is removed rather than left lying around as an invitation to reintroduce the hazard. - The in-lock re-proof stays as the merged-safety check. The residual — an external ref move between re-proof and delete — is documented at the code that accepts it. - The worktree-conflict guard now wraps EVERY branch deletion, temporary t3code/* branches included; a user can check one of those out too. git enforces it regardless, so the guard's job is the structured refusal naming the directory, and git's own refusal is parsed back into the same shape for a worktree that appears in the gap. The driver test now pins the reason rather than the mechanism: the OID is unchanged by a checkout, porcelain refuses, plumbing with that very SHA deletes. Both real-worktree settle tests assert git was never asked, so the guard is load-bearing and not shadowed by git's own protection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n added Rebase fallout that git could not see: #14's new post_report test builds the sessions handlers, and those now require GitRepositoryLock and the source-control registry. Textually clean, semantically broken — the stubs get both, empty, since post_report reaching either would be a bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31729eb to
4b808af
Compare
|
Rebased onto New head: Conflicts, both keep-everything:
One semantic conflict git could not see, and worth flagging since it's the class of thing a merge preview misses: #14's new Verified supersession + usage + my additions all present in the merged contract: Exit codes:
I'm not going to claim a green driver file I didn't observe in one run. The evidence that it's contention and not the change: rotating identity, ~900s wall against a 120s budget, and clean passes in isolation. The gate runs serialized, which is the condition where these hold. |
Settling eight children at once with
cleanupWorktree: truefailed all eight. Concurrentgit worktree removeruns on one repository do not queue — they fight over.git/index.lockuntil each one hits our 15s command timeout, and the only cleanup that worked was the one that ran alone. A removal killed mid-write then left a zero-byteindex.lockbehind, which blocked every later git command on that repository. Settling also kept quiet about two things it should not have.Serialize per repository.
GitRepositoryLockis one EffectSemaphoreper repository root. Worktree removal and branch deletion both run inside it — the ref delete takes a lock in the same repository, so leaving it outside would just move the race. The lock is built once where the sessions toolkit is registered; a per-call instance serializes nothing. Sessions still settle independently of cleanup, so a cleanup failure never blocks a settle.Report a held lock instead of forcing through it. When a git failure names a lock file, settle_session answers with
SessionOrchestrationGitLockError: the path, its age, whether it matches the stale heuristic (empty and older than 60s — git writes the new index into the lock, so an empty one means the writer died before writing), and the remedy. Phoenix never deletes the lock: nothing in this process can prove no live git — a developer's shell, a second Phoenix, an editor — owns it, and deleting a live lock corrupts the index. Naming the file is the part only we can do.cleanupBranch: true, with proof. The trap: this repo squash-merges, so a merged branch is never an ancestor ofmainandgit branch --mergedreports nothing — trusting it would refuse every merged branch, and on a rebase-merging repo it would accept branches that were never merged. The proof is commit identity instead: local head == remote head == the head commit of a merged PR (gh pr list --state merged --head <branch> --json headRefOid), which also means zero commits ahead. Anything else isSessionOrchestrationBranchNotMergedErrorwith the reason and the SHAs that disagree. The proof runs before the worktree is touched, so a refusal costs nothing and leaves a whole job rather than half of one.No more silent settle-timeout leak (reviewer follow-up from #10). A plain settle whose 10s stop wait times out has nothing to withhold, so it still succeeds — but it now carries a
warningnaming the provider and the status the session was last seen in.Tests
settleSession.test.tsdrives the real handler against stubs, with the real lock. Eight parallel settles on one repository: all succeed, max concurrent removals is 1, and each removal is immediately followed by its own branch delete. Verified it fails without the lock (8 in flight). A second case proves the lock is per repository, not global. The stop-timeout cases useTestClock. The merge proof is covered per refusal reason, plus the pure helpers for lock-path parsing and staleness.npm run typecheck— exit 0vp lint --report-unused-disable-directives— exit 0 (warnings pre-existing)vp test run src/mcp/toolkits/sessions/ src/sourceControl/— exit 0, 203 passedvp test runcontracts — exit 0, 244 passedvp test run src/git/GitManager.test.ts -t "cross-repo PRs…"— exit 0 (the--jsonfield assertion this touches)Full suite left to the merge gate, per the orchestrator.
Written by Claude Opus 5 in Phoenix (Claude Code harness).
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.