Upstream worktree selfheal - #2503
Conversation
|
|
Koan-Bot
left a comment
There was a problem hiding this comment.
Warning
Important issues found.
- New /doctor check can make a network call outside --full
- Docs understate the blast radius of two new destructive behaviors
1449a47 to
6dc9329
Compare
|
@Master-Koan fix |
_maybe_reap_worktrees() did the entire sweep inline in the poll loop: prune, list, and the per-worktree safety checks, for every configured project. On a large checkout those git calls take tens of seconds, and for their whole duration the bridge neither polled Telegram nor flushed the outbox -- so once an hour inbound commands and agent replies simply stalled. Add a third worker lane, "maintenance", beside chat and bg, and dispatch the sweep to it. Like bg it is single-flight and silent when busy, so internal housekeeping never spams the channel. _maybe_reap_worktrees() now only decides whether a sweep is due and hands it off; when the lane is still busy it leaves last_reap untouched, so the next poll starts one as soon as the lane frees instead of skipping a full hour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 808aa2f)
Moving the sweep off the poll loop stopped it blocking messages but left it unbounded. None of its subprocesses had a timeout, so a single hung git call -- an NFS stall, a lock held elsewhere -- pinned the single-flight maintenance lane indefinitely and no later sweep could ever start. The activity check is unbounded by construction too: it stats every tracked and untracked path in the tree. Cap a sweep at FOREIGN_WORKTREE_REAP_BUDGET_SECONDS (120s) with FOREIGN_WORKTREE_GIT_TIMEOUT_SECONDS (15s) per git command, threading one shared monotonic deadline through reap_foreign_worktrees() and both safety checks, and polling it every 128 entries inside the path walk. Expiry and timeout are both treated as "could not verify", which retains the worktree -- the checks stay fail-safe under the budget. remove_worktree() gains fallback_remove=False for this caller, because shutil.rmtree() cannot be interrupted at the deadline; a path that survives git's removal is skipped rather than reported reaped. A capped sweep will often not finish, so rotate the work to keep it making progress: sort projects and candidate worktrees, then offset both by the sweep index, so a large early entry cannot starve everything behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 9cf4c39)
…ery ref The detached-HEAD safety check ran `git for-each-ref --contains=HEAD` across refs/heads, refs/remotes and refs/tags. That walks history once per ref, so in a repository with many refs the check alone can outlast the sweep's 15-second per-command timeout -- and since a timed-out check retains the worktree, the reap quietly reclaimed nothing on exactly the large checkouts that need it. Resolve refs/remotes/origin/HEAD and ask `git merge-base --is-ancestor HEAD <default>` instead: one bounded ancestry query regardless of ref count. The safety property is deliberately narrower -- a commit reachable only from some other tag or remote branch now counts as unpushed -- so the change errs toward keeping worktrees, never toward deleting one that was previously protected. An unresolvable origin/HEAD keeps the worktree as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit be229ff)
Swapping the all-ref containment scan for `merge-base --is-ancestor HEAD origin/HEAD` changed the question being asked, not just its cost, and left the reaper unable to reclaim the worktrees it exists for. Review checkouts are detached at a pull-request head (`git worktree add /tmp/review-<sha> <sha>`), which is durable on its remote branch but never an ancestor of the default branch -- so every one of them was retained forever. Squash and rebase merges keep that true even after the PR lands, since neither preserves the head SHA. The check also required refs/remotes/origin/HEAD, which `git init` + `remote add` + `fetch` never creates and a plain clone can leave unset; where it was missing the sweep reclaimed nothing at all. It fails safe, so nothing was lost -- but the disk exhaustion that motivated the sweep was back on the table. Ask instead whether any durable ref already reaches HEAD, via a single `git rev-list --max-count=1 HEAD --not --branches --tags --remotes`. That restores the verdicts of the original for-each-ref scan exactly, while keeping the performance win that motivated the change: one revision walk that stops at the first uncontained commit, rather than a reachability computation per ref. The existing tests could not have caught this. Their fixture checks the foreign worktree out at HEAD, which the fixture also points origin/main at, so the ancestry test was trivially true in every case. Cover the real shape instead: a worktree detached at a commit held only by refs/remotes/origin/side, and one at a commit no ref reaches at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit a1938d1)
Moving the sweep onto the maintenance lane also moved it out from under the
poll loop's exception handler. The import and the closing summary log sit
outside the function's own try, so anything they raise now reaches
threading.excepthook: it prints to stderr and writes no koan log entry at all.
A reaper broken for weeks would look exactly like one with nothing to reclaim.
Split the sweep into _sweep_foreign_worktrees() and keep _reap_worktrees() as a
thin guard around it, so the whole body -- imports included -- reports through
log("error", ...) as before. The inner per-project handler stays; this is the
outer net for the code that currently has none, and the split keeps the sweep
within the file's function-size convention rather than adding a level of
indentation to all of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 042edff)
test_worktree_reap_continues_across_projects asserts the two projects are swept in a fixed order, but the sweep rotates that order by the hour (int(time.time() // WORKTREE_REAP_INTERVAL) % len(projects)) and the test never pinned the clock. With two projects the rotation parity flips every hour, so the test passed for one hour and failed the next -- it went green at 09:21 and red at 10:21 on the same tree. Pin time.time() to 7200.0, which yields rotation 0 and matches the pinning test_worktree_reap_stops_after_shared_budget already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit a37b0cf)
…make the reaper actually reap
* docs(spec): worktree ownership is a git-prep and reap concern
Reachability alone cannot decide whether a detached foreign worktree is safe
to reclaim. A review worktree sits at a pull-request head commit, which is
durable on its remote branch only while that PR is open. Once the PR is
squash-merged the remote branch is deleted and no durable ref reaches the
commit, so the reachability test retains every completed review forever.
Retention now turns on whether the working tree is clean.
Also states that the sweep skips only the two directories other code owns
(.worktrees/ and .claude/worktrees/) rather than the whole project directory,
which today leaves <project>/tmp/ worktrees unreclaimable by anything.
Adds two git-prep invariants: a base branch held by another worktree is
recovered by detaching the holder, and the first checkout error is never
overwritten by the fallback error.
* fix(git-prep): recover when the base branch is held by another worktree
Git checks a branch out in at most one worktree. An agent running a /fix
mission created one ad hoc to compare coverage:
git worktree add -q /tmp/base140 140
That took branch 140 away from /usr/local/cpanel, and every mission after it
died in prep. 90 consecutive missions failed over 31 minutes before a human
detached the worktree by hand.
Prep now detects the holding worktree and detaches it, freeing the branch
without deleting anything or disturbing its uncommitted work, then retries the
checkout once. Detach rather than remove: prep runs unattended before every
mission, so a false positive must never destroy an agent's in-flight work.
Reclaiming the disk stays the bridge sweep's job. A worktree git reports as
locked is never touched.
Also stops the fallback error overwriting the first one. The checkout -b
fallback failed with "a branch named '140' already exists", which replaced the
only message naming the blocking path. Across the retained logs, 92 prep
failures on three projects reported that fallback error and not one reported
"is already used by worktree at" -- so the real cause never reached an
operator.
Verified against real git on a scratch repo, not only mocks: the holder is
detached and the checkout recovers, uncommitted work in the holder survives,
and a locked holder is left alone with both errors reported.
* fix(worktree): stop retaining reclaimable worktrees forever
The foreign-worktree sweep reported "0 reclaimed" on every hourly run for
days while gigabytes of leaked worktrees sat undisturbed. Two independent
holes, both measured on the koan host.
Reachability cannot decide retention on its own. A review worktree sits at a
pull-request head commit, durable on its remote branch only while that PR is
open; once the PR is squash-merged the remote branch is deleted and no durable
ref reaches the commit any more. The old rule then read that as unique unpushed
work and kept the worktree permanently, so every *completed* review became
immortal -- three of four leaked worktrees on the host were retained for exactly
this reason. An unreachable detached HEAD now falls back to the question that
actually matters: uncommitted changes mean real work would be lost, a clean tree
means a closed PR. Age needs no new constant, because the activity guard runs
first and already requires max_age_days of no file activity. The tracking-branch
path (HEAD ahead of upstream) is unchanged, and so is the existing behaviour for
a *reachable* dirty worktree -- reviews leave incidental edits behind and those
still reap.
The sweep also skipped everything inside the project directory, which is wider
than the ownership boundary it meant to respect. Only .worktrees/ (managed by
cleanup_stale_worktrees) and .claude/worktrees/ (managed by the Claude Code
harness) have an owner. Scratch worktrees under <project>/tmp/ have none, and
the OS temp sweeper does not reach inside a project either, so nothing on the
host could ever reclaim them: app-csf held three that were 23, 24 and 34 days
old. Verified by dry run -- the sweep now reclaims exactly those three and still
leaves every owned worktree alone.
Two smaller fixes alongside. The activity skip logged nothing while the locked
and unpushed skips both did, so a retained worktree could not be explained from
the logs. And prune now passes --expire now: the default gc.worktreePruneExpire
is 3.months.ago, which would leave a freshly orphaned registration in place for
a quarter.
Also corrects the docstring premise. It blamed the review skill for instructing
ad-hoc worktrees with no teardown; no skill text mentions worktrees at all.
/review runs in a worktree Koan creates and removes, behind a read-only shell
guard that denies git worktree outright. The leak comes from /fix-class
missions, whose shell is unhooked.
* feat(doctor): diagnose and repair worktree branch collisions
Git prep self-heals a held base branch now, but an operator watching missions
fail had no way to see the cause or clear it without waiting for the next
mission. /doctor already walks every configured project, so the check belongs
there.
Reports an error naming the holding worktree, and exposes fix() so /doctor --fix
detaches it. The diagnostics framework auto-wires any module with a fix(), so
no registration is needed. Detach, never remove -- the worktree, its files and
any uncommitted work stay exactly where they are; reclaiming the disk belongs
to the bridge sweep. A locked worktree is reported by neither the check nor the
fix.
_base_branch() mirrors prepare_project_branch()'s own resolution: an explicit
project-level git_auto_merge.base_branch wins, otherwise the remote default is
detected. Reporting a different branch than prep actually uses would make the
check worse than useless.
Verified against the live host: reports clean, and fix() is a no-op when
nothing is held.
* fix(prompts): give /fix and /implement the temp-hygiene rule
The rule that would have prevented the outage already existed. temp-hygiene
names "repo checkouts" as a $TMPDIR case and forbids bare /tmp/... paths -- and
the agent that ran `git worktree add -q /tmp/base140 140` never saw it, because
{@include temp-hygiene} appears only in agent.md, not in fix.md, implement.md,
or implementation-workflow.md.
Including it from implementation-workflow.md reaches /fix and /implement in one
line. Verified by rendering both prompts: the section is present in each.
The rule also gains the part it was missing. Bare /tmp paths were framed as a
disk-cleanup problem, but a worktree additionally takes exclusive ownership of
the branch checked out into it, which is what actually broke 90 missions. It now
says so, and says to use --detach and to remove the worktree when done.
Instruction is the only lever here: the read-only shell guard that denies
`git worktree` outright is wired to model_key="review_mode", and /fix runs as
"mission" with an unhooked Bash.
* fix(git-prep): resolve the base branch before fetching, not after it fails
A project that pins no git_auto_merge.base_branch falls back to the generic
"main" default, and prep only discovered the real default branch after the
fetch for "main" had already failed. cp is exactly this shape -- it sets
issue_tracker.default_branch "140" but not git_auto_merge.base_branch -- so
every single mission on it paid for a doomed network fetch before finding
"140" and fetching again.
Prep now resolves the remote default before the first fetch, guarded on the
configured branch having no remote-tracking ref locally. That guard matters:
detecting unconditionally would override a configured branch that works
perfectly well, which is a regression, not an optimisation. The check is a
local rev-parse, no network. When the ref does exist, behaviour is byte for
byte what it was, including the original detect-after-failure fallback.
Confirmed against the live cp repo: refs/remotes/origin/main is absent and
refs/remotes/origin/140 is present, so the doomed fetch is skipped.
Fixing this in projects.yaml instead would help exactly one project on one
host -- that file is gitignored -- and leave the next unconfigured project
paying the same cost.
* fix(worktree): protect local commits and expose probe failures
---------
Co-authored-by: Nicolas Rochelemagne <nicolas.rochelemagne@webpros.com>
(cherry picked from commit e8d6c0569cfb73b10b9ac4c153f24de295422e74)
…es explain themselves
Rebase with requested adjustmentsBranch Changes applied
Not changed (and why)
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Skuggi |
f2326f7 to
6549876
Compare
|
|
Koan-Bot
left a comment
There was a problem hiding this comment.
Warning
Important issues found.
- /doctor stays green for a locked worktree holding the base branch — the one case prep cannot heal
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Skuggi |
PR Review — Upstream worktree selfhealThree worktree fixes — prep detaching a holder instead of dying, a bounded maintenance-lane sweep, and detached-HEAD retention that stops being permanent — landed contract-first with real-git coverage, and the previous review's blocker is genuinely resolved. What's left is a handful of non-blocking edges around scope symmetry, prune expiry, and sweep observability.
✅ Resolved since last review (3)Previously-flagged issues verified fixed
🟢 Suggestions
1. New `list_worktrees()` timeout parameter is not used by the two new callers
|
Koan-Bot
left a comment
There was a problem hiding this comment.
Tip
No blocking issues found — ready to merge.
Summary
Three related worktree fixes, all triggered by one production outage (92 consecutive
mission failures) plus a reaper that reported "0 reclaimed" every hour for days:
koan/app/git_prep.py) — git allows abranch in at most one worktree, so an agent-created
git worktree add /tmp/base140 140locked the project's own checkout out of its base branch. Prep now detects the holder
and runs
git checkout --detachthere (detach, never remove: files and uncommittedwork stay put), then retries the checkout once. A
lockedholder is never touched.The first checkout error is also no longer overwritten by the
checkout -bfallback'sa branch named 'X' already exists— only the original message names the holding path.hardcoded
mainfallback has no tracking ref, resolve the remote's real default beforefetching instead of paying for a doomed fetch first.
resolve_remote_default_branch()returns
Noneon exhausted resolution, so a failed detection can never be promoted toan authoritative override of a configured branch.
koan/app/awake.py,koan/app/worktree_manager.py) — the hourly sweep moves to a new single-flightmaintenanceworker lane (2-minute budget, 15 s per git command, project rotation), soit never delays the poll loop. Detached-HEAD retention stops being permanent: a single
bounded
rev-list --max-count=1 HEAD --not --branches --tags --remoteswalk replacesthe per-ref containment scan, and a closed-PR checkout still at its creation commit is
reclaimable. Every candidate is dirty-checked (
--forceremoval), scope is narrowed tooutside-the-project plus
<project>/tmp/, and every retained worktree logs its reason./doctorreports the same collision (koan/diagnostics/project_check.py), resolvedlocally only — no network probe outside
--full./doctor --fixperforms the samedetach.
git worktree addoutside$TMPDIR, which is where the leakcame from.
Docs and durable specs updated:
docs/operations/troubleshooting.md(sweep scope, what itkeeps,
git worktree lockopt-out, auto-detach under "Branch conflicts"),docs/architecture/daemon.md(maintenance lane),specs/components/bridge.md,specs/components/git-github.md.Testing
make lintmake test— new coverage inkoan/tests/test_git_prep.py,koan/tests/test_worktree_manager.py(real-git: locked holder, dirty tree, unmerged PRhead, closed PR after ref deletion, missing
refs/remotes/origin/HEAD),koan/tests/test_diagnostics.py,koan/tests/test_awake.py,koan/tests/test_awake_worker_lanes.py(maintenance lane never blocks chat).Declarations
(
specs/components/**orspecs/skills/**). The new architecture needs reviewbefore approval. Rationale: prep may now detach a worktree holding the base branch, and
the sweep's scope/retention contract changes (
specs/components/git-github.md,specs/components/bridge.md).