Skip to content

fix: Polecat worktrees for merged work beads are never reclaimed: 17 stale trees (~2.5G) in the gascity rig alone, and the resulting host ENOSPC false-FAILs every push gate at the cmd/gc link step (gc-4guc2) - #182

Closed
zook-bot wants to merge 4 commits into
mainfrom
polecat/gc-4guc2

Conversation

@zook-bot

@zook-bot zook-bot commented Sep 11, 2026

Copy link
Copy Markdown

Summary

The problem

Per-bead git worktrees were never reclaimed. Measured across a four-rig city: 748
worktrees held, every one of them on the git-safety gate, and 708 of those
reporting stashes=true. In the gascity rig a single stash — "WIP on main", on
the rig's own checkout — held all 31 of its worktrees. The trees accumulate until
the host runs out of disk, and the resulting ENOSPC false-FAILs every push gate at
the cmd/gc link step.

Why the stash gate cannot be made to work

refs/stash is a single repository-global ref. Git records no worktree identity on
a stash entry, so git stash list answers identically from every worktree of a
repository: a per-worktree gate cannot be built on it, and one stash anywhere
protects the whole rig for as long as it exists.

Removal cannot lose the stash either — git worktree remove deletes the checkout,
not refs/stash — which is the same argument the reaper already carries for
declining to gate on push state. The gate was therefore both unimplementable and
unnecessary.

The change

  • The stash probe is gone from all three places that ran it: the closed-bead
    reaper (cmd/gc/bead_worktree_reaper.go), the worker_dir prune that
    AutoPruneWorkerDir drives by default (cmd/gc/session_worktree_prune.go), and
    doctor's nested-worktree check (internal/doctor/checks_semantic.go), which the
    other two cite as their reference gate. The uncommitted and unreachable-commit
    gates stay: removal really does destroy both. The operator-facing text that
    advertised the stash gate goes with it — doctor's --fix hint and the three
    config doc comments that feed docs/reference/config.md and the city schema.

  • New gc worktree reap. Reclaiming still needed an operator to edit
    city.toml and restart the controller, because the patrol is the reaper's only
    caller and defaults to off. reap runs the same classification on demand: it
    reports what it would remove and removes nothing until --apply. It joins
    gc worktree alongside ensure, verify and cleanup, which own
    single-worktree provisioning; reap is the bulk sweep driven by bead state.

  • --json is schema-declared. The root JSON contract answers a built-in
    command that has no schemas/<path>/result.schema.json with json_unsupported
    before its RunE runs, so the flag alone does not make the mode usable.
    schemas/worktree/reap/result.schema.json declares it, and the payload opens
    with the standard schema_version/ok/command/action envelope, with ok
    carrying the same verdict as the exit code. The contract test drives
    run([]string{"worktree", "reap", "--json"}) under GC_JSON_CONTRACT_STRICT=1
    and validates the output against the published schema.

    A pass that could not scan a rig or complete a removal exits nonzero, and the
    published result schema pins ok to a const true, so a nonzero exit cannot
    satisfy it. On such a pass ok is false and the shared error object
    (code, message, exit_code) is stamped, mirroring worktree cleanup, so the
    output meets the shared failure schema while reaped and protected still
    report the partial pass. A second contract test drives that error path and
    validates it against the failure schema.

  • reapReport grows Errors and LivenessSource, so a caller rendering the
    report rather than the stderr log still sees a rig it could not scan, a removal
    git refused, and a verdict reached on the fallback liveness scanner.

  • The report is ordered by rig then path. The reaper walks rigs in Go map
    order, which is randomized per run, so without a sort the same city lists its
    rigs differently every invocation and two runs of the command cannot be diffed
    against each other.

  • docs/troubleshooting/reclaim-worktrees.md gives the operator the disk-full
    cascade, the gate table, and the recovery. Recovery is stated from the ref that
    survives removal: the git gate passes a worktree whose HEAD is reachable from
    any branch, tag, or remote-tracking ref, so a local-only branch is reapable
    while origin/<branch> does not exist.

Effect

Against the live city the classification goes from 33 reclaimable / 748 protected
to 278 / 502, with no protection mentioning stashes and the dirty and
unreachable-commit trees still correctly held. That is also how the numbers above
were measured.

Review notes

Command census: reap mints id 208 against next_id 208; upstream holds 203-205
for worktree cleanup/ensure/verify, and the generated catalog count goes 203 -> 204.

Dispatch — what this work was asked to do

What

Every polecat worktree under .gc/worktrees/<rig>/polecats/<slot>/worktrees/<bead>
survives after its work bead closes. Measured 2026-09-02 in the gascity rig:

slot stale trees (bead closed) size
gc-toolkit.furiosa gc-6kmqc, gc-8jrtx, gc-dqn8l, gc-gfoc7, gc-nmd11, gc-sx5cq, gc-zl5ta 1125M
gc-toolkit.nux gc-04375, gc-0qbf5, gc-6a6vz, gc-h4s93, gc-na313, gc-zvffx 522M
gc-toolkit.polecat-1 gc-4jard, gc-c9upz, gc-dz64s, gc-gf1l6 872M
gc-toolkit.polecat-2 gc-4c0a7, gc-ixen4, gc-k8c31, gc-y6d3n 618M (reclaimed by hand during this session)

17 trees still on disk, ~2.5G, all for beads that are closed and in most cases
merge_result=merged and landed on main. .gc/worktrees across all four rigs
is 32G.

Why it is P1 rather than housekeeping

The host disk it sits on is the same one go link writes to. With / at 99%
(3.9G free) every unit-cmd-gc-* shard of a pre-push gate dies before running a
single test:

/usr/local/go/pkg/tool/linux_amd64/link: cannot write
  /var/tmp/go-link-1291483678/000040.o: copy_file_range: no space left on device
FAIL github.com/gastownhall/gascity/cmd/gc [build failed]

All 6 cmd/gc shards plus unit-core failed that way on gc-09meh's gate, with zero
EDQUOT anywhere in the log, so this is real host ENOSPC and not the per-agent
quota shape tracked by gc-6jye9. That gate reads as a FAIL on the diff. It is
not one: internal/tomledit still produced a real ok line in the same run while
internal/config read [build failed].

Reclaiming only provably-orphaned space — 6 stale /var/tmp/go-link-* dirs with
no live holder, stale gc-local-tests.* roots, and the four finished worktrees in
this pool slot's own home — moved free space 3.9G -> 4.7G, and the identical gate
then passed 10/10 with zero no space left hits. So the accumulation is the
proximate cause of a false-FAIL class that costs a full ~40-minute gate per
occurrence and invites blaming the diff.

Two candidate mechanisms, both worth checking

  1. A bare rework bead has no cleanup owner at all. gc-09meh arrived with no
    poured workflow (gc bd dep list -> "no dependencies"), so its method came
    from metadata. It runs workspace-setup and creates a worktree, but there is no
    cleanup-worktree step in existence to remove it. Every rework round mints
    one of these.
  2. The molecule's own cleanup-worktree is not removing them either. The stale
    trees above are named for anchor ids (gc-c9upz, gc-dz64s, gc-gf1l6), i.e.
    they came from the original mol-polecat-work run's workspace-setup, not from
    a rework. Those runs had a full molecule with a cleanup-worktree step, and
    the tree is still there. gc-gfoc7 (closed) covered one way that step gets
    stranded; this is evidence it is still not reliably reached.

What is safe to reclaim

git worktree remove on a clean tree loses nothing: every commit stays reachable
through its branch ref. The gate is the existing one from the polecat-patterns
worktree-reclaim rule — owning session closed or absent, no process cwd'd in the
tree, git status --porcelain empty — plus the bead being closed. All four trees
removed by hand here met it.

Do NOT reach for the 68G ~/.cache/go-build: AGENTS.md hard-bans
go clean -cache because it corrupts the shared fleet cache.

Related

  • gc-6jye9 — missing GOTMPDIR sends links into the /tmp tmpfs (EDQUOT). Different
    failure: this one has GOTMPDIR set and hits real ENOSPC on /.
  • gc-gfoc7 (closed) — cleanup-worktree stranded behind an unclosed scope-check bead.
  • Reported live to gc-toolkit.witness as a fleet-wide NOTICE on 2026-09-02.

Refinery handoff

  • Issue: gc-4guc2
  • Source branch: polecat/gc-4guc2
  • Target: main
  • Gates codex signed off pre-open at 3680eef7; PR opened green.

Beads on this branch

Every bead whose work is on polecat/gc-4guc2. Approving this PR approves all of them.

  • gc-4guc2 — Polecat worktrees for merged work beads are never reclaimed: 17 stale trees (~2.5G) in the gascity rig alone, and the resulting host ENOSPC false-FAILs every pu (opener)
  • gc-gq3o4 — Rework branch polecat/gc-4guc2: address pre-open signoff findings
  • gc-tpgxm — Rework branch polecat/gc-4guc2: address pre-open signoff findings
  • gc-7li1z — Rework branch polecat/gc-4guc2: address pre-open signoff findings

refinery costing added 4 commits September 4, 2026 09:19
…tree (gc-4guc2)

Per-bead git worktrees were never reclaimed. Measured across a four-rig
city: 748 worktrees held, every one of them on the git-safety gate, and
708 of those reporting stashes=true. In the gascity rig a single stash —
"WIP on main", on the rig's own checkout — held all 31 of its worktrees.

refs/stash is a single repository-global ref. Git records no worktree
identity on a stash entry, so `git stash list` answers identically from
every worktree of a repository: a per-worktree gate cannot be built on
it, and one stash anywhere protects the whole rig for as long as it
exists. Removal cannot lose the stash either — `git worktree remove`
deletes the checkout, not refs/stash — which is the same argument the
reaper already carries for declining to gate on push state. The gate was
therefore both unimplementable and unnecessary, and it is gone from all
three places that ran it: the closed-bead reaper, the worker_dir prune
that AutoPruneWorkerDir drives by default, and doctor's nested-worktree
check, which the other two cite as their reference gate. The uncommitted
and unreachable-commit gates stay: removal really does destroy both.
The operator-facing text that advertised the stash gate goes with it —
doctor's --fix hint and the three config doc comments that feed
docs/reference/config.md and the city schema.

Against the live city the classification goes from 33 reclaimable / 748
protected to 278 / 502, with no protection mentioning stashes and the
dirty and unreachable-commit trees still correctly held.

Reclaiming still needed an operator to edit city.toml and restart the
controller, because the patrol is the reaper's only caller and defaults
to off. `gc worktree reap` runs the same classification on demand: it
reports what it would remove and removes nothing until --apply. That is
how the numbers above were measured. It joins `gc worktree` alongside
ensure, verify and cleanup, which own single-worktree provisioning;
reap is the bulk sweep driven by bead state. reapReport grows Errors and
LivenessSource so a caller rendering the report rather than the stderr
log still sees a rig it could not scan, a removal git refused, and a
verdict reached on the fallback liveness scanner.

The report is ordered by rig then path: the reaper walks rigs in Go map
order, which is randomized per run, so without a sort the same city lists
its rigs differently every invocation and two runs of the command cannot
be diffed against each other.

Command census: reap mints id 208 against next_id 208; upstream holds
203-205 for worktree cleanup/ensure/verify, and the generated catalog
count goes 203 -> 204.

Claude-Session: https://claude.ai/code/session_01BdbURuwqXGvCC3waJcyhzT
`gc worktree reap --json` advertised a machine-readable mode the root JSON
contract rejected. A built-in command with no schemas/<path>/result.schema.json
is answered with json_unsupported before its RunE runs, so the flag exited 1
and emitted no report, and the payload carried no result envelope either.

- Add schemas/worktree/reap/result.schema.json. Its presence is what makes the
  command declare JSON support.
- Stamp schema_version, ok, command and action in writeWorktreeReapJSON, so
  both producers describe the pass identically and ok tracks the exit code.
- Cover the real CLI path. TestCmdWorktreeReapStrictJSONContract runs
  run([]string{"worktree", "reap", "--json"}) under GC_JSON_CONTRACT_STRICT=1
  and validates the payload against the schema; the populated-verdict test
  validates entry shape. The existing tests called runWorktreeReap directly and
  so never met the contract gate.

The troubleshooting page said a reaped worktree reproduces with
`git worktree add <path> origin/<branch>`. The git gate allows removal when
HEAD is reachable from any branch, tag, or remote-tracking ref, so a local-only
branch is reapable while origin/<branch> does not exist. State the recovery
from the surviving local ref, and name origin/<branch> only when it exists.

Claude-Session: https://claude.ai/code/session_01XNQF4rpt8UbEa1vqthuj56
…mment (gc-4guc2)

The pruneAgentHomeWorktreeIfSafeInfo call site in
reconcileSessionBeadsTracedWithNamedDemand described the prune as
"Skipped under safety gates (uncommitted, unpushed, stashed)", but this
branch already removed the stashed gate from the helper: refs/stash is a
single repository-global ref carrying no worktree identity, and
git worktree remove cannot lose it. The helper now skips only on
uncommitted or unpushed work, so the comment named a gate that no longer
exists. Drop it so the comment states the current behavior.

Addresses the sole pre-open signoff P1 (review gc-uhxwi) on branch
polecat/gc-4guc2.

Claude-Session: https://claude.ai/code/session_01VJTf66qCuhxWY5MfYVjWPv
…reap` --json (gc-4guc2)

A reap pass that could not scan a rig or complete a removal returns exit 1,
while writeWorktreeReapJSON derived ok from the same errors: the --json output
was ok:false with the reap-specific fields and no error object. That payload
matched neither the published result schema (ok is const true) nor the shared
failure schema (which requires an error object), so a machine caller reading a
partial pass got output that validated against no contract.

Stamp the shared error object (code/message/exit_code) whenever the pass has
errors, mirroring worktree cleanup. The reaped/protected verdicts stay in the
envelope so a partial pass is still legible; failure.schema.json admits them via
additionalProperties. Adds an error-path contract test alongside the
success-path one and a shared-failure-schema validation helper.

Claude-Session: https://claude.ai/code/session_01ApGo7VPkyHqmXjyiW9rFYn
@zook-bot

Copy link
Copy Markdown
Author

Pre-open signoff (comment-only — not an approval):

VERDICT: request-changes
Reviewed branch: polecat/gc-4guc2
Reviewed base: main
Reviewed commit: 3680eef

Scope checked: Read the full three-dot diff from origin/main to 3680eef across the reaper, worker-dir prune, worktree command, JSON schema, command census, config/doctor comments, generated docs, and troubleshooting docs. Checked anchor bead gc-4guc2, review bead gc-oieqs, the mol-review step text, the pack work-quality fragment, the learning-exemplar fragment, the JSON result/failure contract path, the event-provider call path, openStandaloneRigStores, reaper call sites, and the git helper safety semantics. This is pre-open, so there is no PR page to review. I did not run the full project matrix or dashboard checks; the diff does not touch internal/api, OpenAPI, dashboard code, or generated dashboard types.

Findings: P1 REQUEST-CHANGES - internal/doctor/checks_semantic_test.go:1433 still says the fake worktrees have "no stashes." At this reviewed commit, the gitWorktree test fake no longer has any stash field or HasStashesResult method, and the production safety contract in internal/doctor/checks_semantic.go:699-729 now checks liveness, repo readability, uncommitted work, and unpushed commits only. The comment is stale living-code prose on the destructive-cleanup safety contract this branch changes. It tells the next reader that stash state is still part of the clean-tree comparison when the branch deliberately removed stash state from the gate. Fix by removing "no stashes" from that comment or restating only the current modeled conditions: no uncommitted work and no unpushed commits, with liveness as the only separator.

Filed: none.

Verification: At the reviewed commit in detached worktree /tmp/gc-review-gc-oieqs.STWxJd, go test ./cmd/gc -run 'TestRunWorktreeReap|TestNewWorktreeReapCmdDefaultsToDryRun|TestCmdWorktreeReapStrictJSONContract' -count=1 passed. go test ./cmd/gc -run 'TestReapClosedBeadWorktrees|TestPruneAgentHomeWorktreeIfSafe' -count=1 passed. go test ./internal/doctor -run 'TestNestedWorktreePruneCheck' -count=1 passed. go test ./internal/productmetrics -run 'TestInjectedImmutableCommandCatalogRoundTripsWithoutExpandingProduction' -count=1 passed. go test ./internal/config -run 'TestDaemonAutoReapClosedBeadWorktrees|TestDaemonAutoPruneWorkerDir|TestDoctorConfig|TestSchema' -count=1 passed. go test ./test/docsync -count=1 passed. go test ./internal/git -run 'TestHasUncommittedWork|TestHasUnpushedCommits|TestHasUnreachableCommits|TestWorktreeRemove|TestHasStashes' -count=1 passed. go test ./cmd/gen-command-census -count=1 passed. go run ./cmd/gen-command-census -check passed. scripts/check-generated-docs-drift.sh reported generated reference docs are fresh. git diff --check origin/main...3680eef76767d2705de4c70139cc9baa06356fa8 passed.

Anchor: gc-4guc2 — check.codex @ 3680eef

@zook-bot

Copy link
Copy Markdown
Author

Closing as not-needed: disposition recorded on anchor gc-4guc2 (successor gc-85pdo). The refinery disposes the anchor from this close; no rework-or-close decision is owed. Operator ruled against fork-local adoption (keep gascity aligned with upstream); acute ENOSPC already relieved by dropping the 4-month stale 'WIP on main' stash that had jammed the repo-global stash gate. The stash-check fix, if ever wanted, belongs upstream — not a local change. Fix preserved in closed PR #182.

@zook-bot zook-bot closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant