Skip to content

Upstream worktree selfheal - #2503

Merged
atoomic merged 14 commits into
mainfrom
upstream-worktree-selfheal
Sep 9, 2026
Merged

atoomic merged 14 commits into
mainfrom
upstream-worktree-selfheal

Conversation

@atoomic

@atoomic atoomic commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • git prep self-heals a held base branch (koan/app/git_prep.py) — git allows a
    branch in at most one worktree, so an agent-created git worktree add /tmp/base140 140
    locked the project's own checkout out of its base branch. Prep now detects the holder
    and runs git checkout --detach there (detach, never remove: files and uncommitted
    work stay put), then retries the checkout once. A locked holder is never touched.
    The first checkout error is also no longer overwritten by the checkout -b fallback's
    a branch named 'X' already exists — only the original message names the holding path.
  • Pre-fetch base-branch resolution — when nothing configured a base branch and the
    hardcoded main fallback has no tracking ref, resolve the remote's real default before
    fetching instead of paying for a doomed fetch first. resolve_remote_default_branch()
    returns None on exhausted resolution, so a failed detection can never be promoted to
    an authoritative override of a configured branch.
  • Bounded, non-blocking foreign-worktree sweep (koan/app/awake.py,
    koan/app/worktree_manager.py) — the hourly sweep moves to a new single-flight
    maintenance worker lane (2-minute budget, 15 s per git command, project rotation), so
    it never delays the poll loop. Detached-HEAD retention stops being permanent: a single
    bounded rev-list --max-count=1 HEAD --not --branches --tags --remotes walk replaces
    the per-ref containment scan, and a closed-PR checkout still at its creation commit is
    reclaimable. Every candidate is dirty-checked (--force removal), scope is narrowed to
    outside-the-project plus <project>/tmp/, and every retained worktree logs its reason.
  • /doctor reports the same collision (koan/diagnostics/project_check.py), resolved
    locally only — no network probe outside --full. /doctor --fix performs the same
    detach.
  • System prompt now forbids git worktree add outside $TMPDIR, which is where the leak
    came from.

Docs and durable specs updated: docs/operations/troubleshooting.md (sweep scope, what it
keeps, git worktree lock opt-out, auto-detach under "Branch conflicts"),
docs/architecture/daemon.md (maintenance lane), specs/components/bridge.md,
specs/components/git-github.md.

Testing

  • make lint
  • make test — new coverage in koan/tests/test_git_prep.py,
    koan/tests/test_worktree_manager.py (real-git: locked holder, dirty tree, unmerged PR
    head, 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

  • Architectural change — this PR modifies a durable design contract
    (specs/components/** or specs/skills/**). The new architecture needs review
    before 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).

@github-actions
github-actions Bot requested a review from Koan-Bot September 2, 2026 19:49
@Koan-Bot

Koan-Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@Koan-Bot Koan-Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • New /doctor check can make a network call outside --full
  • Docs understate the blast radius of two new destructive behaviors

@Koan-Bot
Koan-Bot force-pushed the upstream-worktree-selfheal branch from 1449a47 to 6dc9329 Compare September 2, 2026 23:50
@atoomic

atoomic commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

@Master-Koan fix

atoomic and others added 11 commits September 4, 2026 23:19
_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)
@Master-Koan

Copy link
Copy Markdown
Contributor

Rebase with requested adjustments

Branch upstream-worktree-selfheal was rebased onto main and review feedback was applied.

Changes applied

  • Verified branch HEAD (65498768) against review HEAD (1449a47). Five later commits already implement every finding; only the PR-body gap remained.
  • Filled the PR body (Summary + Testing) — the review flagged it as the unfilled template — and checked the Architectural change box, since specs/components/bridge.md and specs/components/git-github.md change durable contracts (spec-change guard requires the declaration).

Not changed (and why)

  • /doctor network call outside --full — already fixed. project_check._base_branch() (koan/diagnostics/project_check.py:172-217) resolves locally only: configured branch → _has_remote_tracking_ref() → local_remote_default_branch() → "" (caller skips). No detect_remote_default_branch() call, no ls-remote.
  • Docs understate blast radius — already fixed. docs/operations/troubleshooting.md:72-100 documents scope (outside project + <project>/tmp/, everything else in-project out of scope), dirty-tree retention, per-retain log reasons, git worktree lock opt-out, and the auto-detach under "Branch conflicts". Reviewer's suggested wording ("anywhere inside except .worktrees/") is now stale: code narrowed in-project scope to IN_PROJECT_SCRATCH_DIRS = ("tmp",), and docs match code.
  • HIGH — list_worktrees() silent empty return — already fixed (worktree_manager.py:322-343): logs git worktree list failed/timed out with stderr before returning [].
  • MEDIUM — failure masked as benign reason — alread

Stats

15 files changed, 1933 insertions(+), 156 deletions(-)
Actions performed
  • Already-solved check: skipped (JSON parse error)
  • Rebased upstream-worktree-selfheal onto origin/main
  • Review feedback evaluated; no changes required
  • Pre-push CI check: previous run #33704493634 failed
  • Pre-push CI fix: no changes needed or Claude found nothing to fix
  • Force-pushed upstream-worktree-selfheal to origin
  • Private review gate skipped: disabled by config
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Skuggi

@Master-Koan
Master-Koan force-pushed the upstream-worktree-selfheal branch from f2326f7 to 6549876 Compare September 4, 2026 23:22
@Koan-Bot

Koan-Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@Koan-Bot Koan-Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • /doctor stays green for a locked worktree holding the base branch — the one case prep cannot heal

@Koan-Bot

Koan-Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Rebase with requested adjustments

Branch upstream-worktree-selfheal was rebased onto main and review feedback was applied.

Changes applied

  • Done.
  • /doctor now reports a locked worktree holding the base branch (koan/diagnostics/project_check.py) — non-fixable error naming git worktree unlock <path> && git -C <path> checkout --detach; --fix still leaves it untouched. Warning #1.
  • /doctor now reports when the collision check was skipped because the base branch is not resolvable from local refs (warn + git remote set-head origin -a hint), instead of emitting nothing. Silent-failure HIGH / suggestion #1.
  • Split _find_branch_holder() into _branch_holder_worktree() (returns the holder, locked or not) plus the detachable-only wrapper, so prep keeps its "never touch locked" behavior while the diagnostic can see it.
  • _branch_holder_worktree() logs the inspection failure before returning None (koan/app/git_prep.py), so "could not look" no longer reads like "nothing holds the branch". Silent-failure MEDIUM #2.
  • result.healed + logger.info now record the detach the moment it succeeds, before the retry checkout, so a detach followed by an unrelated checkout failure is still reported. Silent-failure MEDIUM #3.
  • Tests: locked holder reported-but-not-detached, unresolvable base branch reported and not fixed, detach reported when retry fails, unreadable worktree list logged; updated test_base_branch_resolution_stays_local.
  • Contract + docs: new specs/components/git-github.md bullet ("What prep cannot heal, /doctor MUST report") and matching `docs/operations/troubleshooti

Stats

15 files changed, 2198 insertions(+), 162 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent main commit touches git_prep base-branch holder detach, the maintenance worker lane, or th)
  • Rebased upstream-worktree-selfheal onto upstream/main
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed upstream-worktree-selfheal to upstream
  • Force-pushed upstream-worktree-selfheal to upstream
  • Force-pushed upstream-worktree-selfheal to upstream
  • Private review gate passed after 2 fix round(s)
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Skuggi

@atoomic
atoomic requested a review from Koan-Bot September 9, 2026 03:00
@Koan-Bot

Koan-Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

PR Review — Upstream worktree selfheal

Three 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.

  • /doctor now reports a locked holder as a non-fixable error and says when the check was skipped, matching the docs added in the same PR
  • Replacing the per-ref containment scan with one rev-list --max-count=1 ... --not --branches --tags --remotes walk is both cheaper and the correct semantics; the closed-PR reflog fallback fails toward retention and is covered by real-git tests
  • Every new deadline and timeout path retains the worktree, and _retain() names the reason, so retain-everything is distinguishable from nothing-to-do per worktree
  • prune --expire now drops git's grace for temporarily-missing worktree directories, and applies to startup recovery too
  • The out-of-project scope rule is not protected by the gitignored-files argument the spec uses to exclude in-project workspaces

✅ Resolved since last review (3)

Previously-flagged issues verified fixed
  • koan/diagnostics/project_check.py:231 /doctor stays green for a locked worktree holding the base branch — the one case prep cannot heal
  • koan/diagnostics/project_check.py:217 Collision check disables itself with no trace when the base branch can't be resolved locally
  • koan/app/git_prep.py:647 [Pre-Existing Issue] The post-fetch detection path can still promote the "main" guess over a configured branch

🟢 Suggestions

1. New `list_worktrees()` timeout parameter is not used by the two new callers
koan/app/git_prep.py:473-475

This PR adds a timeout parameter to list_worktrees() and threads a 15 s cap through the sweep, but _branch_holder_worktree() calls it bare — and that call sits on two paths where a hang is expensive:

  • prepare_project_branch(), once per mission whose base-branch checkout failed;
  • /doctor, once per configured project on the default (non---full) path.

git worktree list --porcelain stats every registered worktree path, so a single registration on a dead NFS mount blocks it indefinitely. Every other git call on these paths is bounded — run_git() defaults to 30 s, and the pre-existing checks in project_check.run() pass timeout=10 explicitly — so this is the one unbounded call in an otherwise bounded module.

Passing a modest timeout (10-15 s) preserves the existing behaviour on failure: list_worktrees() now logs the timeout and returns [], and _branch_holder_worktree() treats [] as "nothing holds it", which is the same fallback prep already takes.

        from app.worktree_manager import list_worktrees
        worktrees = list_worktrees(project_path)

Checklist

  • Error paths fail safe (retain rather than delete)
  • Diagnostics surface the failure they were added for (prior blocker)
  • No unbounded work on interactive paths — suggestion #1
  • Durable-contract changes declared as architectural
  • Docs stay in sync with changed user-visible behavior
  • New branching logic covered by tests
  • Failures are observable in aggregate, not only per-item
ℹ️ Triage summary

3 pre-existing finding(s) on unchanged code suppressed (freeze).


Silent Failure Analysis

🟠 **HIGH** — silent empty return masks failed inspection
koan/app/git_prep.py:466-479

Risk: list_worktrees() swallows CalledProcessError and TimeoutExpired internally and returns [], so this handler never fires for the realistic git failures, and "git could not tell us" collapses into "nothing holds the branch" — the exact conflation the docstring claims to prevent.

Traced failure: a project repo owned by a different UID makes git worktree list --porcelain exit 128 ("detected dubious ownership") -> list_worktrees() catches CalledProcessError, prints to stderr and returns [] -> branch_holder_worktree() iterates an empty list and returns None without raising, so the (ImportError, OSError) handler and its warning never run -> project_check.run() gets holder=None and appends no project_worktree CheckResult at all -> /doctor reports the project clean while git prep, also seeing None, skips the detach and every mission keeps dying on checkout failed: 'main' is already used by worktree at ....

try:
    from app.worktree_manager import list_worktrees
    worktrees = list_worktrees(project_path)
except (ImportError, OSError) as e:
    logger.warning("Could not inspect worktrees in %s ...", ...)
    return None

Fix: Have list_worktrees() signal inspection failure distinctly (raise, or return Optional[List]) so _branch_holder_worktree() can log/propagate "unknown" and /doctor can report the check as failed rather than clean.

🟡 **MEDIUM** — first error overwritten by fallback error
koan/app/git_prep.py:672-702

Risk: stderr is reassigned on every retry, so when all candidates fail the reported error names only the last branch tried — a branch nobody configured — discarding the original fetch failure, which is the same error-masking this PR adds an explicit spec invariant against on the checkout path.

for candidate in candidates:
    base_branch = candidate
    result.base_branch = candidate
    rc, _, stderr = _fetch_with_https_fallback(remote, base_branch, project_path, timeout=30)
    if rc == 0:
        break
if rc != 0:
    result.error = f"fetch failed: {stderr}"

Fix: Accumulate the per-candidate stderr (as the checkout path now does) and report all attempts, e.g. fetch failed: <original> (also tried 'X': <err>).

🟡 **MEDIUM** — silently mangled value instead of validated parse
koan/app/git_prep.py:194-206

Risk: rsplit("/", 1) truncates any default branch containing a slash (refs/remotes/origin/release/1.0 -> "1.0"), and the new /doctor consumer treats that wrong name as authoritative rather than as a parse failure.

if rc == 0 and stdout:
    # Output: refs/remotes/origin/master → extract "master"
    return stdout.strip().rsplit("/", 1)[-1] or None

Fix: Strip the known refs/remotes/<remote>/ prefix explicitly and return None when the output does not start with it, so an unparsable ref is reported as unresolvable instead of guessed.

🟡 **MEDIUM** — swallowed exception leaves a safety gate disarmed
koan/app/git_prep.py:589-612

Risk: A config-load failure logs a warning and leaves config_configured at False, which is precisely the state the new pre-fetch override treats as "nobody configured a base branch" — so a transient YAML/IO error silently converts a defaults: develop project into a remote-default project for that mission.

    config_configured = config_explicit or bool(defaults_am.get("base_branch"))
except Exception as e:
    logger.warning("config load error for base_branch: %s", e)

Fix: Treat a config-load exception as unknown rather than unconfigured — skip the pre-fetch override (and the detect-after-failure override) when the config could not be read.

🟡 **MEDIUM** — non-zero exit treated as a negative answer
koan/app/git_prep.py:438-448

Risk: Any git failure (corrupt refs, locked index, missing binary) is indistinguishable from "the ref does not exist", and the caller uses False to justify overriding the base branch.

rc, _, _ = run_git(
    "rev-parse", "--verify", "--quiet",
    f"refs/remotes/{remote}/{branch}",
    cwd=project_path,
)
return rc == 0

Fix: Distinguish rc==1 (ref absent) from other exit codes / stderr, and skip the override when the probe itself failed.

🟡 **MEDIUM** — early return on timeout skips cleanup, indistinguishable from success
koan/app/worktree_manager.py:258-262

Risk: The bare return skips git worktree prune and branch deletion and returns the same None as the success path, so if git deleted the directory before timing out the caller's os.path.exists() check counts it as reaped and the sweep logs "Reclaimed N" while a phantom registration remains.

except subprocess.TimeoutExpired:
    print(f"[worktree_manager] git worktree remove timed out for {wt}", file=sys.stderr)
    return

Fix: Return an explicit status (or raise) from remove_worktree() and have reap_foreign_worktrees() gate removed.append() on it rather than on directory existence alone.


Automated review by Skuggi HEAD=c5ed9d5 9 min 10s

@Koan-Bot Koan-Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@atoomic
atoomic merged commit d61bbd6 into main Sep 9, 2026
8 checks passed
@atoomic
atoomic deleted the upstream-worktree-selfheal branch September 9, 2026 16:43
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.

3 participants