Skip to content

Stop a deleted transcript from pausing the watcher - #255

Open
akmad wants to merge 1 commit into
attunehq:mainfrom
akmad:fix/vanished-transcript-pressure
Open

akmad wants to merge 1 commit into
attunehq:mainfrom
akmad:fix/vanished-transcript-pressure

Conversation

@akmad

@akmad akmad commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Problem

akari watch can enter a retry loop it never leaves.

A session file deleted between discovery and upload — routine when a git worktree is removed, or when an agent harness cleans up subagent transcripts — fails with ENOENT. That is classified as resource pressure, so the worker re-marks the file into the dirty set and pauses for PressureBackoff (30s by default). Nothing about the next attempt can change the outcome, so the set never drains and the watcher spends the rest of its life cycling deleted files.

On one macOS host this ran for eleven days over 938 deleted transcripts, writing ~2.3 MB/day of identical errors into an unrotated log. The same loop was present on all four clients in that fleet, including two Linux ones.

Mechanism

pressureFailure ended with an interface match:

var networkError net.Error
return errors.As(err, &networkError)

syscall.Errno implements both Timeout() and Temporary(), so it satisfies net.Error entirely on its own. errors.As walks straight past *fs.PathError — which has Timeout() but no Temporary() — and matches the errno underneath. A missing file was therefore as firm a "network failure" as a dropped connection:

lstat ENOENT:       MATCH net.Error as syscall.Errno
http dial failure:  MATCH net.Error as *url.Error

Fix

Identify the transport by the net-package type a genuine transport failure always carries ahead of its errno, and reject a match that lands on a bare syscall.Errno.

The exhaustion errnos that the interface match used to cover by accidentEAGAIN, EMFILE, ENFILE, ENOMEM — are now named explicitly. Without that list this change would have silently stopped descriptor exhaustion from backing off, trading one bug for another.

Tests

The existing filesystem case passed errors.New("read session header"), which has no errno beneath it and so never exercised the path that fails. It is replaced with real error shapes:

  • a synthetic *fs.PathError{Err: syscall.ENOENT}, matching what resolve actually wraps
  • one case that takes its error from a real os.Lstat, so a future change to the standard library's method set cannot quietly reintroduce this
  • *url.Error wrapping *net.OpError, the shape the upload client really returns
  • a worker-level test asserting a vanished file leaves the dirty set empty rather than being retried

All of them fail against the current classifier and pass with this change.

Note

An earlier revision of this description claimed internal/server/parse.TestGoldenProjection
fails on main. That was wrong and I have corrected it — CI is green on this PR.

What I actually hit is worth flagging on its own, though: that test fails against an
unmodified main when built with Go 1.27.1, while CI passes on the 1.26.6 from
go.mod. The sole difference is one Body field carrying a PNG blob — the golden escapes
the invalid UTF-8 as \ufffd, whereas newer encoding/json emits the replacement
character literally. Contributors on a newer toolchain will trip over it. Happy to open a
separate issue if that is useful; it is unrelated to this change.

🤖 Generated with Claude Code

syscall.Errno implements Timeout() and Temporary(), so every errno
satisfies net.Error on its own. errors.As walks past *fs.PathError,
which has Timeout() but no Temporary(), and lands on the errno beneath,
so pressureFailure read an ENOENT from a vanished session file as a
network failure. The worker then re-marked the file and slept for the
pressure backoff.

Nothing about a deleted file changes on a retry, so the dirty set could
never drain. One macOS host spent eleven days cycling 938 transcripts
from removed worktrees on a 30-second loop, writing ~2.3MB a day of
identical errors into an unrotated log.

Classify the transport by the net package type that a real transport
failure always carries ahead of its errno, and name the exhaustion
errnos that the interface match used to cover by accident, so EMFILE
and friends keep backing off deliberately rather than incidentally.

The old test passed a bare errors.New for the filesystem case, which has
no errno underneath and so never exercised the path that failed. Take
the error from a real os.Lstat as well as a synthetic *fs.PathError, and
assert the worker leaves the dirty set empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant