bd17f2b8c9
Review found that the first cut traded the duplicate-delivery bug for a worse one, and disproved its own load-bearing safety claim by experiment. Expiry was unreachable. _claim_parked touched the mtime on every re-claim and _release_claim backdated to exactly now minus the stale threshold, so a file's age hovered around 121 seconds and never approached the 7-day expiry. The attempt count in the filename therefore bounded nothing: an undeliverable batch (revoked key, proxy 403, oversized event) lived on disk forever, and because spawn_flush starts a sender whenever a .sending file exists, it spawned a detached Python process on every hook, MCP call and CLI invocation, forever. The old code self-healed here, so this was a regression. Expiry now gates on the attempt budget, which is the thing that actually accumulates; age stays only as a backstop for files that never carried an attempt marker. The attempt parser sniffed for a leading "a", which also matches a hex id like a1234567, so a legacy telemetry-<pid>-<hex>.sending file parsed as attempt 1234567 and was deleted unsent on the first flush after upgrade — precisely the population this PR is meant to protect. Anchored on field position instead. The rewrite was not durable: no fsync before the rename, and _drain unlinked any claim that parsed to zero events. A crash between write and rename left the claim empty, and the next flush deleted it. Now fsynced, and a non-empty file that parses to nothing is quarantined as .corrupt rather than destroyed. read_text raises UnicodeDecodeError on a torn file, which `except OSError` does not catch. flush() runs from a bare `finally:` in flush_worker, so the exception also skipped the handoff cleanup and left it stuck in .running. The per-batch rewrite's return value was discarded, so a failed rewrite let the loop continue as though progress had been recorded — reintroducing the exact duplicate delivery this PR exists to fix. .partial files orphaned by a crash between write and rename matched no glob in the module and were never cleaned up. Also replaces the heartbeat test, which asserted `SEND_TIMEOUT * 4 < CLAIM_STALE_SECONDS` — two constants, executing none of the code under test. It now drives the real rewrite and watches the mtime move. New tests cover expiry being reachable, legacy filename parsing, torn-claim quarantine, failed-rewrite behaviour and debris sweeping. 64 core tests, 199 host tests. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb