Skip to content

fix: capture transitions roll back the destination on a failed source remove (iss-186) - #203

Merged
REPPL merged 4 commits into
mainfrom
bugfix/iss-186-capture-transition-remove-rollback
Aug 6, 2026
Merged

fix: capture transitions roll back the destination on a failed source remove (iss-186)#203
REPPL merged 4 commits into
mainfrom
bugfix/iss-186-capture-transition-remove-rollback

Conversation

@REPPL

@REPPL REPPL commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Fixes iss-186 (minor), surfaced during a bug-hunt loop sweep (round 1) and independently verified with a failing test.

The bug

commitTransition (internal/core/capture/workflow.go) writes the destination file before removing the source. A non-ENOENT os.Remove(src) failure — EPERM/EROFS/EIO, e.g. an immutable attribute or a read-only remount on the source status dir — returned that error after dst had already landed, with no rollback of dst.

The issue id ended up present in both status directories at once (e.g. both open/ and resolved/). findIssue (internal/core/capture/alloc.go) rejects any id present in more than one file as ErrDuplicateIssueID, so the strand could never again be resolved or wontfixed — recovery required a human to manually delete one copy. List/Status also double-counted the id, since scanLedger does not dedupe.

The fix

commitTransition now rolls the destination back (best-effort os.Remove(dst)) whenever the source remove fails for a reason other than "already gone", restoring the pre-call state (source present, destination absent) so a retry is all that's needed once the underlying failure clears. A failed rollback is folded into the returned error rather than swallowed.

A non-ENOENT remove failure has no portable way to trigger deterministically in a test — immutable attributes and read-only remounts are Linux/ext4- and permission-model-specific, and this repo's CI runs macOS and Linux — so a test-only seam (removeSourceHook, nil in production) lets a test force it, mirroring the existing beforeOrphanRemoveHook pattern already in alloc.go.

Evidence

  • internal/core/capture/workflow.goremoveSourceHook, rollback logic in commitTransition
  • internal/core/capture/workflow_test.goTestTransitionRemoveFailureDoesNotStrandIssueInTwoDirs, watched failing on pre-fix code for the claimed reason (destination not rolled back) and passing after
  • .abcd/work/issues/resolved/iss-186-capture-transition-remove-failure-strands-issue.md — resolved via abcd capture resolve
  • .abcd/work/DECISIONS.md, CHANGELOG.md — updated

An independent adversarial correctness review of the full diff (fresh subagent, no context from the fix) found the shipped rollback logic correct — no data loss possible (rollback only ever removes the file this same call just wrote, since a pre-existing file at that path would have already tripped ErrDuplicateIssueID before commitTransition ran), no broken errors.Is/errors.As chains, no race from the package-level test hook (no t.Parallel() in this package, hook resets via defer), and no other move path in the codebase (spec/store.go, intent/lifecycle.go) shares this two-copy window since both use a single os.Rename. It flagged one real gap — the test's error assertion (err == nil) didn't confirm the injected failure was what surfaced, so an unrelated earlier error could make the test pass without exercising the rollback at all — fixed by asserting errors.Is(err, injected). Verdict: MERGE.

make preflight green; gofmt -l . clean; go run ./cmd/record-lint 0 blockers.

claude added 2 commits August 6, 2026 09:04
… remove (iss-186)

commitTransition (internal/core/capture/workflow.go) wrote the destination
file before removing the source. A non-ENOENT os.Remove(src) failure —
EPERM/EROFS/EIO, e.g. an immutable attribute or a read-only remount on the
source status dir — returned that error after dst had already landed, with
no rollback. The issue id ended up present in both status directories at
once (e.g. open/ and resolved/), and findIssue (alloc.go) rejects any id
present in more than one file as ErrDuplicateIssueID — so the strand could
never again be resolved or wontfixed without a human manually deleting one
of the two copies. List/Status also double-counted it, since scanLedger does
not dedupe.

Fixed by rolling dst back (best-effort os.Remove(dst)) whenever the source
remove fails for a reason other than "already gone", restoring the pre-call
state so a retry is all that's needed once the underlying failure clears. A
failed rollback is folded into the returned error rather than swallowed.

A non-ENOENT remove failure has no portable way to trigger deterministically
in a test — immutable attributes and read-only remounts are Linux/ext4- and
permission-model-specific, and this repo's CI runs macOS and Linux — so a
test-only seam (removeSourceHook, nil in production) lets a test force it,
mirroring the existing beforeOrphanRemoveHook pattern already in alloc.go.

Repro: internal/core/capture/workflow_test.go,
TestTransitionRemoveFailureDoesNotStrandIssueInTwoDirs, watched failing on
pre-fix code for the claimed reason (destination not rolled back) and
passing after.

Assisted-by: Claude:claude-opus-5
Moves iss-186 open/ -> resolved/ via abcd capture resolve, appends the
DECISIONS.md round entry, and adds the CHANGELOG Fixed entry for the
capture-transition rollback fix.

Assisted-by: Claude:claude-opus-5
@REPPL REPPL mentioned this pull request Aug 6, 2026
claude added 2 commits August 6, 2026 11:16
….2 merge

Merging main brought in the v0.4.2 release cut, which renamed [Unreleased]
to a dated [0.4.2] section concurrently with this branch's own append to
[Unreleased]. CHANGELOG.md's merge=union driver resolved the merge with no
conflict markers, but a textual union has no notion of section boundaries:
it placed the iss-186 entry inside the already-tagged [0.4.2] section
instead of the (now empty) [Unreleased] one, mis-attributing an unreleased
fix to a release that shipped without it. Moved the entry back under
[Unreleased].

Assisted-by: Claude:claude-opus-5
@REPPL
REPPL merged commit 4c56802 into main Aug 6, 2026
12 checks passed
@REPPL
REPPL deleted the bugfix/iss-186-capture-transition-remove-rollback branch August 6, 2026 12:33
REPPL added a commit that referenced this pull request Aug 6, 2026
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.

2 participants