Preserve unique work across archive cleanup - #574
Conversation
|
Claude finished @zionts's task in 3m 10s —— View job ✅ ApproveRe-review after the latest push. Both surviving findings from the prior automated review round (07:47:15Z) are fixed and tested by Verified fixes1. Archive hook non-zero exit is now detected (was HIGH) The hook now runs before terminal capture/kill (previously kill came first) — a deliberate reorder: a hook failure is a recoverable precondition failure and should leave terminals/sessions intact for diagnosis rather than already dead. Covered by the new test 2. Dead terminals / no failure signal after terminal teardown (was MEDIUM)
The RPC path ( Bonus, not required: Spec doc ( Self-directed check (not from prior rounds)Traced whether the new catch block's cleanup could double-run against a partially-successful try block (e.g., No new High/Medium issues found in the delta. 1 self-generated item investigated and dismissed as not a new defect (see above) — not counted as filtered "invalid feedback" since it was never drafted as a finding. Review diagnosticsNo tool calls failed or were denied. Static review only (ubuntu-latest runner, no macOS Swift build available) — relied on reading source directly and cross-checking call-site signatures against their definitions (HookResolver.execute, NotificationStore.create/unread) to confirm the new code compiles as intended. |
The classifier folded every ignored file into the blocking set, and the reap snapshot force-added them. Measured on this repository's own worktree -- 11,453 ignored files, 668 MB -- that had three consequences: - Non-force archive became impossible for any worktree that had ever been built. The app has no force affordance; `--force` exists only in the CLI, so the primary interface offered an action that always failed. - The refusal message joined every offending path into one string and handed it to a GUI alert through an RPC error. - GC committed the entire build tree into `refs/tbd/snapshots/...`. Snapshot refs stay reachable, so `git gc` never prunes them and the repository grew by hundreds of megabytes per reaped worktree while preserving no work. This also silently reversed a pre-existing assertion that ignored bytes stay out of a snapshot. `.gitignore` is the user's own standing declaration that those bytes are reproducible, and it is the only signal Git offers. Excluding them costs nothing this design was built to buy: bootstrap scaffolding is untracked by convention rather than ignored, so it still arrives as `??` and stays subject to the full provenance check. An always-refusing archive is not a stricter system but a bypassed one -- it trains users onto `--force`, which skips the unpublished-commit check this work exists to enforce. Also cap each category in `blockingSummary` at twenty paths, and correct the `stageAllAndWriteTree` doc comment, which claimed it mutates the real index while the body already used a scratch `GIT_INDEX_FILE`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Archive refusals were a dead end from the app. `TBDApp` never passes `force: true` anywhere -- the only override lives in the CLI -- so a GUI-only user with a genuinely dirty worktree hit a wall with nothing to act on. The recovery now travels with the error: `archiveUnsafe` names `tbd worktree archive <name> --force` alongside the blocking summary, and both the CLI and the app alert render that description. An in-app "Archive anyway" control is deliberately left out. How prominent a total-bypass gesture should be is a UI decision that belongs in a spec, not in a fix that is already changing the deletion gate. Also close a coverage gap and trim dead code: - `explicitForceRetainsItsArchiveOverride` asserted only against `beginArchiveWorktree`, which mutates nothing whether force is set or not, so it passed independently of force actually bypassing the gate. Replaced with a test that drives force through to physical removal on a worktree the classifier is first shown to refuse, plus its non-force counterpart so removal is attributable to force rather than the fixture. - The `"!!"` branches in the classifier were unreachable once ignored paths left the boundary: `worktreeStatusEntries` builds its `git status` call without `--ignored`, so no `!!` record can reach it. They read as if ignored files were still considered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g it This PR had moved terminal teardown from phase 1 to after removal, so a live Claude/Codex agent stayed running through the archive hook, the final classifier revalidation, and `git worktree remove --force`. That reopened the window the revalidation exists to close: the agent can create a file after the check returns and before removal executes, and forced removal then discards it with nothing having observed it. On origin/main no live writer remained by the time removal ran, so this window was new. Capture and kill each terminal window first instead, immediately after phase 1 has already gated eligibility. `captureThenKillWindow` only touches tmux and the history rows -- never the directory -- so it is free to run this early, and the terminal rows themselves still survive until removal is verified. Ordering alone was never the property; the property is that nothing can write to the worktree between the last check and the removal. Publishing archived-final state is what waits on physical absence. Silencing writers is a precondition for a safe removal and belongs before it -- the spec now draws that distinction explicitly. Covered by a new test that pins the whole sequence through the tmux dry-run recorder and the archive seams: classify, kill, classify, remove. NOTE: not verified locally -- the box hit a swap/resource incident and all Swift builds were stopped mid-run. Needs `swift build` + `swift test` under the shared governor before this is pushed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review: policy is right, execution has two gaps, and the RPC shouldn't blockThe core fix — never publish archived-final state before verified physical removal, and refuse to delete work that exists nowhere else — is correct and well-tested. We researched how comparable tools handle this (details at the bottom): the refuse-unless-clean-and-pushed policy is at the strict end of the field but matches the strongest precedent almost check-for-check, so the policy needs no softening. The findings below are about execution. 1. Make archive non-blocking with a persisted
|
| Tool | Dirty check | Unpushed check | What happens |
|---|---|---|---|
plain git worktree remove |
refuses | none | --force overrides; branch always survives |
| Claude Code (headless job cleanup) | refuses ("has uncommitted changes, kept") | refuses ("commits that are not pushed anywhere") | keeps in place; force override |
| Claude Code (interactive exit) | state-aware prompt | commits-ahead check | explicit discard_changes opt-in |
| Codex CLI | — | — | doesn't manage worktrees |
| OpenCode | UI warning only | none | server force-deletes worktree and branch -Ds |
| Crystal | none | none | generic confirm → worktree remove --force |
| claude-squad (kill) | none | none | force remove + branch -D — unpushed commits destroyed |
| claude-squad (pause) | auto-commits | n/a | preserve-by-commit, keeps branch |
| ccmanager | warns, click-through | none | two-step confirm, then force |
| vibe-kanban | none at delete time | none | auto-commits after every agent run; worktree is disposable |
| GitButler / jj | n/a | n/a | snapshot everything, allow anything, undoable |
Takeaways:
- This PR's policy mirrors Claude Code's own headless deletion path check-for-check (dirty → keep, unpushed-to-any-remote → keep, force override) — the right comparable, since both are non-interactive daemon-side destruction paths. It is stricter than every open-source worktree manager surveyed; several of those silently destroy uncommitted work (and two delete the branch, making unpushed commits genuinely unreachable).
- The road not taken is preserve-rather-than-refuse (claude-squad pause and vibe-kanban auto-commit before removal; GitButler/jj snapshot everything). TBD already has snapshot machinery on the GC reap path but deliberately refuses in place on the archive path. That's defensible — refusal leaves the user's state untouched rather than creating machine-made commits, and it's what Claude Code chose too — but the spec should state the choice explicitly, since a snapshot-then-archive middle path would dissolve most of the refusal gate's UX friction and is the obvious follow-up if the gate proves too aggressive in practice.
Generated by Claude Code
…ap-provenance # Conflicts: # Sources/TBDDaemon/Lifecycle/WorktreeLifecycle.swift
… with it Three ways the gate refused work it had no business refusing. A repository with no remote could never non-force archive anything. The publication check ran `git branch -r --contains HEAD`, which is empty when no remote is configured, so a spotless fully committed worktree in a local-only repo was refused forever. Eligibility now asks whether HEAD survives removal: a remote-tracking branch where one exists, and otherwise "some local branch contains HEAD", which is the honest question there -- `git worktree remove` keeps the branch, so those commits stay reachable. A detached HEAD on no branch still blocks, content eligibility is untouched so deleting a remote waves nothing through, and any git failure still returns false. A failing archive hook was reported as a safety refusal. It still stops the archive -- the hook may be what preserves work elsewhere -- but it now throws `archiveHookFailed`, naming the hook. Telling someone their broken script means "unique unpublished work, use --force" routes an ordinary scripting bug through the one flag that also skips every content and publication check, which is worse than the pre-gate behavior. The refusal message said to run `tbd worktree archive <name> --force`, which is not a command anyone can run. It now names the worktree; both throw sites had it in scope. Also: - The common refusal reason read "no matching trusted out-of-worktree bootstrap attestation" for a worktree with ordinary uncommitted work and no manifest anywhere near it, describing machinery the user has never encountered. That case now reads "uncommitted or untracked content". - "Remove repo from list" claimed "files on disk are not touched" while force-archiving every active worktree, which removes each working directory and skips every check this PR adds. The confirmation now says what it does and that the branches are kept. Its blanket force is pre-existing and left alone. NOTE: not verified locally -- raw Swift builds remain banned after the swap incident, so CI is the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewer request. TBD's GC reap path already snapshots dirty bytes before removing a worktree, and several comparable tools auto-commit or snapshot rather than refuse -- so "archive refuses instead" is a real fork in the design, and the spec never said it was one. Refusal leaves the user's state as they left it rather than manufacturing machine-authored commits they will later have to find and prune, and an archive is a deliberate gesture on one worktree with someone present to read the refusal -- unlike a GC sweep over worktrees nobody has touched in days. The friction is the cost, snapshot-then-archive is the obvious follow-up if the gate proves too aggressive, and that would be a change of policy rather than mechanism: the classifier already names the paths that would need preserving and ReapSnapshot already knows how to preserve them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @zionts's task in 10m 39s —— View job 🧌 Changes requestedThe overall policy (fail-closed archive gate, GC snapshot isolation, 1. HIGH — Archive hook non-zero exit is never detected as a failure ( The PR's stated fix — hook failure now blocks archive via 2. MEDIUM — Failure after terminal-kill leaves the worktree active with dead terminals and no signal on auto-archive ( Terminals are killed first, before the hook/revalidate/remove sequence that can still throw. Any failure there (including #1 above) leaves the worktree 1 item from the correctness pass was filtered out as not rising to HIGH/MEDIUM (see disposition below). Minor items
Finding dispositions
No conventions findings were filed (0 filed, 0 dropped) — the specialist checked the default-off-flag exemption claim, TUI-scraping surface, public-repo/multi-tenant leakage, DB migration triple-update, and theory-placement on the 20-path cap / 60s timeout / Review diagnosticsNo tool calls failed or were denied for either specialist or the orchestrator. Both specialists completed their reviews and wrote valid findings files; the orchestrator additionally re-verified the surviving HIGH/MEDIUM findings directly against source before merging. |
|
Claude finished @zionts's task in 10m 2s —— View job ✅ Looks goodBoth specialists ( Correctness ran the mandatory premise audit for this guard/safety-shaped change (fail-closed archive gate, GC snapshot isolation) and independently re-verified the two HIGH/MEDIUM findings from the prior automated review round against the current HEAD:
Both fixes were spot-checked directly against source by the orchestrator (grep/read), not taken on the specialist's word alone. Ten additional premises from the PR description/spec (GC never has a live Conventions checked all five assigned areas and found no violations: the bug-fix exemption from the default-off-flag rule holds (every destructive surface touched was already autonomous/destructive pre-PR; the diff only tightens safety); no new TUI screen-scraping was added ( 0 findings filtered out as invalid during merge (none were reported to filter). Finding dispositionsNo specialist findings were reported — nothing to dispose of. Review diagnosticsNo tool calls failed or were denied for either specialist or the orchestrator. Both specialists completed their reviews and wrote valid findings files; the orchestrator additionally re-verified the two carried-over HIGH/MEDIUM findings from the prior round directly against source before concluding they no longer apply. |
…ap-provenance
Two files conflicted, both where upstream's actuation log met this branch's
synchronous archive.
`AutoArchiveOnMergeCoordinator` and `handleWorktreeArchive`: kept upstream's
actuation rows and kept this branch's inline `beginArchiveWorktree` +
`completeArchiveWorktree`, including the `force:` passthrough that upstream's
side had dropped. The `.dispatched` outcome now records after removal
completes rather than after `begin` -- claiming the archive happened while
the directory is still on disk is the same error this branch exists to fix.
Upstream's detached `Task { completeArchiveWorktree }` is not carried over;
removing that fire-and-forget phase is the point of the branch, since a
detached phase two outlives the safety check that authorized it.
`AutoArchiveTriggerTests` needed the new `actuationLog:` argument at the two
coordinator constructions this branch added. Git merged that file cleanly --
no marker, no warning -- so only compiling it surfaced the break.
Verified with `scripts/swift-safe`: TBDDaemonLib builds, and 115 tests across
14 suites pass, including all four of this branch's archive-ordering,
no-remote-fallback, hook-failure and force-bypass tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge with upstream/main put three of upstream's actuation-log tests against this branch's archive gate for the first time, and they went red: Archive blocked: unique unpublished work: /tmp/acme-wt-...; HEAD is not reachable from a remote-tracking branch That is the gate working. Those fixtures are database-only -- worktree paths under /tmp that never exist on disk, with no git repo behind them -- and they passed on upstream only because phase two was detached and swallowed its errors. Made inline and gated, archive correctly refuses a worktree that is not there. Both branches were green alone and red together. Fixed on the test side, not the production side. These tests assert which actuation rows a teardown writes; they have no stake in archive safety. Loosening the gate to satisfy them would defeat the PR, and promoting the fixtures to real git repositories would turn fast unit tests into filesystem integration tests. Instead they now use the lifecycle's existing `archiveSafetyEvaluator` and `worktreeRemover` seams, which exist for exactly this, so each suite keeps testing its own subject -- `ArchiveSafetyClassifierTests` already owns the gate's behaviour. Verified by running the whole `TBDDaemonTests` target rather than a filter: 2680 tests, the three named tests now passing. Two unrelated failures remain locally (`GitManagerCommitDateTests`, `GitStatusTests`, both `shell Code=128`) which pass on CI and touch nothing in this change -- an environment limit of this machine, not a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Summary
Where the boundary sits
Fail-closed applies to everything Git tracks or reports as untracked. It does not extend to ignored paths, and that line is deliberate.
An earlier revision folded ignored files in. Measured on this repository's own worktree — 11,453 ignored files, 668 MB — that produced three effects, none of them more safety:
force: trueanywhere;--forceexists only in the CLI. The primary interface would have offered an action that always failed.refs/tbd/snapshots/…. Snapshot refs stay reachable, sogit gcnever prunes them — the repository would grow by hundreds of megabytes per reaped worktree while preserving no work. This also silently reversed a pre-existing assertion onmainthat ignored bytes stay out of a snapshot..gitignoreis the user's own standing declaration that those bytes are reproducible, and it is the only signal Git offers — nothing distinguishes a build tree from an ignored file someone would want back.Excluding them costs nothing this design was built to buy. Bootstrap scaffolding —
.agents,.codex,.Codex, hooks,AGENTS.md— is untracked by convention rather than ignored, so it still arrives as??and remains subject to the full provenance check. An ignored file has been removed along with its worktree since long before this PR; that behavior is unchanged, not newly introduced.The alternative was not a stricter system but a bypassed one: an archive that always refuses trains every user onto
--force, which skips the unpublished-commit check this work exists to enforce.blockingSummarynow caps each category at twenty paths regardless.Correction to an earlier description
A previous version of this description said it "wire[s] known-published state into auto-archive", which read as though auto-archive trusts a known-published shortcut. It does not. Auto-archive always re-verifies via
isHeadReachableFromAnyRemote, andmergedTransitionDoesNotWaivePublicationCheckspins that.knownPublished: trueis passed only by GC, where it is a no-op becauserequiresPreservationnever consultsheadIsPublished.Validation
swift build --target TBDDaemonLibswiftlint --strict— 0 violations across 643 filesswift-format lint --strictfor the changed classifier and test filesswift testbuild succeeds locally under the 6.3.2 toolchain; hosted CI remains the authoritative rungitin a scratch repo rather than TBD code: ignored paths absent fromstatus --porcelain -uall; bootstrap families present as per-file??; staged (M) and staged-plus-unstaged (MM) tracked dirt still reported;git add -Ainto a scratchGIT_INDEX_FILEomitting ignored paths and leaving the real index untouched; publication flipping correctly across commit/push; merged HEAD still remote-reachable; and an in-worktreegit branch -mobservable viagit worktree listonce paths are symlink-resolved.No archive, worktree removal, restart, deployment, or live-desk mutation was performed.
Known gap: GUI recovery for genuinely dirty worktrees
Before this PR, non-force archive from the app essentially always succeeded, because
git worktree remove --forceran unconditionally — the silent-data-loss bug this PR fixes. After it, a worktree with real (non-ignored) uncommitted or unpublished content is refused, and the app's own Archive action never forces. A GUI-only user who wants to discard such a worktree has no targeted in-app override.Correction to an earlier revision of this description. It claimed the app never passes
force: trueanywhere. That is wrong, and the claim came from a grep for the literalforce: truethat missed a computed argument.RepoSectionView.swift:157passesforce: activeWorktreeCount > 0to "Remove repo from list", andhandleRepoRemovethen cascadesarchiveWorktree(force: true)over every active worktree in the repo — skipping the classifier preflight, the revalidation, and everything else this PR adds. Its confirmation text ("Your git repository and files on disk are not touched") is materially wrong for that path, since the worktree directories are force-removed.That path is pre-existing and its blanket force is untouched here, and it is not a usable override for archiving one worktree — it is unlabelled, repo-wide, and all-or-nothing. But it does mean the escape hatch is not CLI-only as previously stated. The mis-worded dialog is fixed in this PR; only the cascade's force semantics are left alone.
Failing closed is still the intended direction — it fails safe, and the refusal is legible. What was missing was a route out, so
archiveUnsafenow names it: the message carries the blocking summary andtbd worktree archive <name> --force. Both the CLI and the app alert render that description.A proper in-app affordance — an "Archive anyway" confirmation on the refusal alert — is deliberately not in this PR. It is a UI decision about how prominent a total-bypass control should be, and it belongs to a spec rather than to a fix that is already changing the deletion gate. Tracked as follow-up.
Writers are silenced before the final check
An earlier revision of this PR moved terminal teardown from phase 1 to after removal. That left a live Claude/Codex agent running through the archive hook, the final revalidation, and
git worktree remove --force— so it could create a file after the check returned and before removal executed, and forced removal would discard it with nothing having observed it. Onmainno live writer remained by the time removal ran, so the window was newly introduced here.Terminals are now captured and killed first, immediately after phase 1 has already gated eligibility.
captureThenKillWindowonly touches tmux and the history rows — never the directory — so it is free to run that early, and the terminal rows themselves still survive until removal is verified.Ordering alone was never the property. The property is that nothing can write to the worktree between the last check and the removal. Publishing archived-final state is what waits on physical absence; silencing writers is a precondition for a safe removal and belongs before it. The spec now draws that distinction, and a test pins the whole sequence through the tmux dry-run recorder:
classify, kill, classify, remove.Availability, and where the gate deliberately relaxes
A gate that refuses ordinary cases is not stricter, it is routed around. Three such cases were closed:
git branch -r --contains HEAD, empty when no remote is configured, so a spotless fully committed worktree in a local-only repo was refused forever. Eligibility now asks whether HEAD survives removal — a remote-tracking branch where one exists, otherwise "some local branch contains HEAD".git worktree removekeeps the branch, so those commits stay reachable. Detached HEAD on no branch still blocks; content eligibility is untouched, so deleting a remote waves nothing through; any git failure still fails toward preservation. Worth stating plainly: this check protects discoverability, not reachability — archive keeps the branch either way.archiveHookFailednaming the hook, rather than a safety refusal. Reporting a broken user script as "unique unpublished work, use--force" sends an ordinary scripting bug through the one flag that skips every content and publication check.tbd worktree archive <name> --force, which nobody can run. It now names the worktree.Plus two wording fixes: the common no-manifest refusal reason now reads "uncommitted or untracked content" instead of naming a bootstrap attestation the user has never encountered, and "Remove repo from list" no longer claims files on disk are untouched while force-archiving every active worktree.
Explicitly deferred
Non-blocking archive via a persisted
.archivingstatus. The archive RPC is synchronous through hook, revalidation and removal, so the row can sit inert for up to ~60s. The proposed fix — a newWorktreeStatuscase, a migration, recovery for rows stuck in.archiving, and daemon/app decode skew — is a shared-model change, whichCLAUDE.mdrequires to land with a spec and a recovery story in one commit. It is deferred to its own spec and PR rather than stacked onto a safety change that is already four review rounds deep. The cost of deferring is UX latency, not data loss.The repo-removal cascade's blanket force.
RepoSectionViewpassesforce: activeWorktreeCount > 0, andhandleRepoRemoveforce-archives every active worktree, skipping this PR's gate entirely. Pre-existing and untouched here; only its misleading confirmation text is corrected. Routing it through the classifier changes the behavior of a path this PR never touched and deserves its own decision.Exemption claim. No feature flag. This is a bug fix under
CLAUDE.md— archive was callinggit worktree remove --forceunconditionally and destroying unpublished work — so the default-off flag rule does not apply. The availability shift is real, which is why the three cases above were closed rather than left to--force.