Development record
2026-08-06 — iss-186 (bug-hunt loop, round 4): `commitTransition` (`internal/core/capture/workflow.go`) wrote the destination file before removing the source, so a non-ENOENT `os.Remove(src)` failure (EPERM/EROFS/EIO — e.g. an immutable source status dir, or a read-only remount) returned an error after `dst` had already landed, with no rollback. The issue id was then present in both status dirs at once, and `findIssue` rejects any id present in more than one file as `ErrDuplicateIssueID` — so the stranded copy could never again be resolved or wontfixed without a human manually deleting one file; `List`/`Status` also double-counted it, since `scanLedger` does not dedupe. Fixed by rolling 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 so a retry is all that is needed once the underlying failure clears; a failed rollback itself is surfaced in the returned error rather than swallowed. A non-ENOENT remove failure has no portable, cross-platform 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) was added, mirroring the existing `beforeOrphanRemoveHook` pattern 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.
2026-08-06 — iss-186 (bug-hunt loop, round 4): commitTransition (internal/core/capture/workflow.go) wrote the destination file before removing the source, so a non-ENOENT os.Remove(src) failure (EPERM/EROFS/EIO — e.g. an immutable source status dir, or a read-only remount) returned an error after dst had already landed, with no rollback. The issue id was then present in both status dirs at once, and findIssue rejects any id present in more than one file as ErrDuplicateIssueID — so the stranded copy could never again be resolved or wontfixed without a human manually deleting one file; List/Status also double-counted it, since scanLedger does not dedupe. Fixed by rolling 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 so a retry is all that is needed once the underlying failure clears; a failed rollback itself is surfaced in the returned error rather than swallowed. A non-ENOENT remove failure has no portable, cross-platform 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) was added, mirroring the existing beforeOrphanRemoveHook pattern 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.
Frontmatter
| id | dcn-2608060000000001 |
|---|---|
| type | decision |
| date | 2026-08-06 |
.abcd/work/decisions/dcn-2608060000000001-iss-186-bug-hunt-loop-round-4.md
open on GitHub ↗ · commit history ↗
mentions1
- iss-186capture.commitTransition writes the destination file before removing the source (workflow.go:255-273); a non-ENOENT os.Remove(src) failure (EPERM/EROFS/EIO — e.g. a read-only remount or restrictive ACL on the source status dir) returns an error after dst already landed, with no rollback of dst. The same issue id is now present in two status directories (e.g. both open/ and resolved/). findIssue (alloc.go:383) then rejects any further transition on that id as ErrDuplicateIssueID, so it can never again be resolved or wontfixed on a healthy filesystem — no repair verb exists; recovery requires manually deleting one copy. scanLedger-based reads (List/Status) do not dedupe either, so the id also double-counts. Confirmed with a real non-ENOENT unlink failure (ext4 immutable attribute on the source dir, which blocks unlink even as root, unlike chmod). Reproducing test: internal/core/capture/workflow_test.go, TestTransitionRemoveFailureDoesNotStrandIssueInTwoDirs.