feat(watch): opt-in catch-up drain of unread inbox on fresh attach (#229) - #476
feat(watch): opt-in catch-up drain of unread inbox on fresh attach (#229)#476Masashi-Ono0611 wants to merge 5 commits into
Conversation
|
Apologies for the long silence on this one — the delay was on our side, not a reflection of the PR. Two mechanical things first, because they explain why this cannot merge as it stands and neither is about your code. The required check names changed after this PR last ran. The bats suite is now split into parallel shards, and the required context is a summary job named The macOS job on that run was cancelled, not failed. GitHub renders a cancelled check the same as a failing one, which is misleading here: it was our own macOS bats instability at the time, not anything your change did. Please don't spend time debugging it. On the change itself, two points worth your judgement. The rationale for not marking rows read no longer holds. The drain's comment says that The broad-watcher path needs the same exclusion the live path has, and a test. Last, a scope note so the expectation is clear. |
…ujibee#229) Implements the proposal in fujibee#229. When AGMSG_WATCH_CATCHUP=1 (or the delivery.monitor.catchup config key is true), a FRESH watcher attach -- no persisted watermark -- emits the current unread backlog once before entering the live stream. Default stays start-from-now. Unread at attach is defined against the watermark model: rows addressed to the subscription with read_at IS NULL and id <= the just-seeded watermark. A reconnect (persisted watermark present) never drains; the existing fujibee#107 gap-fill covers it, so nothing is replayed or delivered twice. Drained rows are capped (default 50, newest kept, one notice line for the remainder) and control messages (ctrl:*) are excluded so a stale despawn cannot tear down a fresh watcher. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y owns Addresses the review on fujibee#476. A broad (non-actas) watcher now drains only the roles it is the definitive receiver for. mark_read already applies that rule per row on the live path — a role holding an exclusive ready sentinel has its own watcher, and a broad watcher must not act on its behalf — but the drain selected on the full subscription set, so a leader's default SessionStart watcher would emit the backlog belonging to every actas'd member. The clause is built at drain time so it sees the sentinels as they stand, and before this watcher writes its own, so an exclusive watcher never excludes itself. Redo the read-marking rationale against current main. The old comment said watch.sh streaming has never advanced read_at; main's live loop now calls mark_read, so that invariant is gone and not marking is a choice rather than a consequence. The choice stands, with the consequence stated: drained rows stay unread, so a later inbox.sh re-surfaces exactly what was just shown, keeping the one command an operator uses to audit unread mail honest. Three tests. The exclusion itself; a companion pinning that it applies to broad watchers only, without which an implementation that skipped every sentinel- owning role — breaking catch-up for the spawned agents that need it most — would also pass; and the all-roles-owned no-op. Mutation-checked: removing the exclusion fails the first, dropping its broad-only guard fails the second. The third does NOT pin the empty-clause guard (that mutation leaves the suite green) and its comment says so rather than implying coverage it lacks. Also fixes two things the rebase surfaced: - The suite's local _wait_for_file helpers were replaced upstream by wait_for_file in test_helper.bash (fujibee#503). The rebase took that removal but kept this branch's new call sites on the old names, leaving them calling undefined functions. - The reconnect test killed the first watcher as soon as its log line appeared, but the live loop prints before persisting the watermark, so the reconnect could legitimately re-deliver M1 and the assertion measured kill timing rather than the contract it named. It now syncs on the watermark advancing. Flaky 1-in-3 before; 5 consecutive clean runs after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…found Follow-up to the review pass on this branch. Both are the same kind: a claim in the code or its tests that did not hold as written. FRESH_ATTACH keyed on the parsed watermark being empty rather than on the file being absent. A watermark truncated by a kill mid-write empties it too, so a damaged reconnect took the fresh-attach path and replayed a backlog the session had already seen — "a reconnect never drains" held only for an intact watermark. Now keyed on the file, with a test that fails against the old behaviour. The reconnect test's comment said drained rows stay unread, but nothing asserted it: the watermark suppresses replay whether or not a row was marked, so the absence of M0 proved nothing about the drain's no-mark choice. It now asserts read_at directly — M0 drained and unread, M1 live-delivered and marked. Also bound AGMSG_WATCH_CATCHUP_CAP's digit count before the arithmetic. The trailing "-le 0" check does not cover it: a long enough digit string wraps to an arbitrary value that lands positive as easily as negative, so the existing comment claimed more than the check delivered. And document the drain's TOCTOU window rather than locking it: an exclusive watcher writing its sentinel between the clause build and the drain still gets its backlog emitted. mark_read carries the same race per row and is documented as best-effort, the window is one clause build wide, and the failure mode is a role seeing its backlog twice, not losing it — the drain marks nothing, so the other watcher's read state is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t did not happen Third review pass on this branch. Same theme as the second: each of these is a statement that was wider than what the code delivered. The digit-count bound still let AGMSG_WATCH_CATCHUP_CAP reach nine figures, and the drained rows are materialized into one shell variable — so a large enough cap traded "floods the Monitor stream" for "exhausts memory building the string". An absolute ceiling of 10k sits far above any real backlog and keeps the guarantee the comment claims. The "drained the newest N of M" notice was printed off the COUNT query, before the fetch that produces the rows. The fetch's failure is swallowed, so a failed or racing fetch announced a drain and then emitted nothing. The notice now waits until rows are in hand. Not separately tested: forcing the fetch to fail while the count succeeds needs fault injection this suite has no seam for, so this is ordering that reads correctly rather than a pinned branch. The ctrl:* exclusion comment said replaying a stale ctrl:despawn "could tear down a freshly spawned successor". The drain only prints, so it cannot act on one — the real risk is a weeks-old teardown directive surfaced into the stream for a reader with no way to date it. Reworded to that, which is also what the test actually pins. Two test fixes: - The config-key test never cleared AGMSG_WATCH_CATCHUP. bats inherits the caller's environment, so an exported 1 would have let it pass with the config lookup broken and a 0 would have failed it with the lookup working — either way it reported on the environment rather than the key it names. - The all-roles-owned test built its sentinels with `for r in $(...)`, which word-splits and glob-expands. Switched to a tab-aware read loop so an identity name containing a space or a bracket cannot break it. Full suite: 840 passing, 0 failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Final review pass flagged both as overclaiming, and both were. The cap ceiling is a bound chosen to keep the materialized row string manageable, not a statement that no backlog reaches 10k — one can, and the remainder stays reachable via inbox.sh like anything else over the cap. The ctrl-exclusion rationale said a replayed row leaves a reader with no way to date it; the drained line carries created_at, so that is not the reason. The real one is narrower: ctrl rows are directives addressed to the live loop, which is the only place their semantics are implemented. Comments only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b2089cb to
2c4101d
Compare
|
Thanks — and no need to apologise for the delay; the two mechanical notes saved me from debugging the wrong thing entirely. I took the rebase myself rather than passing it back: current The read-marking rationaleYou were right that the invariant it leaned on is gone — The reconnect test now asserts that read state directly rather than leaving it to prose — the watermark suppresses replay whether or not a row was marked, so the absence of a drained row proved nothing about the no-mark choice on its own. The broad-watcher exclusionFixed, and it was real. A broad watcher now builds its drain clause from only the roles with no exclusive ready sentinel. The clause is built at drain time so it sees the sentinels as they stand, and before this watcher writes its own, so an exclusive watcher never excludes itself. Three tests, mutation-checked rather than trusted green:
One honest gap: that third test does not pin the empty-clause guard. Dropping the guard interpolates Things my own review pass turned upAll the same description-versus-reality kind B3 was about on #507:
Full suite: 840 passing, 0 failing. Known gap I did not closeNothing exercises a team or agent name containing a quote through the generated clause. It reuses the same Understood on the interface-addition step for |
Implements the proposal in #229: an OPT-IN, flag/config-gated catch-up drain — on a fresh attach, emit the current unread inbox once before entering the live stream, with the watermark model preventing any replay on reconnect. Default behavior stays "start from now".
Motivation — a live occurrence of the race
Beyond the spawn-then-send case in #229, the same miss happens in the "send, then arm the monitor" order. Observed on a production team on 2026-07-23:
The reply was never pushed to the leader (it sat at the just-seeded watermark) and was only discovered by manually running
inbox.sh. Withdelivery.monitor.catchupenabled, the attach would have drained it immediately.What counts as "unread at attach" (defined against the watermark model)
As #229 asks, the definition is fixed first and drives the implementation:
Consequences:
id <= LAST; the live loop coversid > LAST. A message that lands between watermark seeding and the drain query belongs to the live loop. Nothing is delivered twice.ctrl:*) are excluded. They are live teardown directives; replaying a stalectrl:despawnat attach could tear down a freshly spawned successor for the same role.read_at. Streaming has never marked rows read (that isinbox.sh's job on display); keeping that invariant makes this change purely additive on the delivery path.Gating and cap
AGMSG_WATCH_CATCHUP=1(env wins) orconfig.sh set delivery.monitor.catchup true. Default OFF — the pinned "fresh session starts from now and does not replay history" test passes unchanged.AGMSG_WATCH_CATCHUP_CAPas a tuning/test seam). When the backlog exceeds the cap, the newest rows are kept — recent context is what an attaching agent needs — and a single notice line reports how many older rows to fetch viainbox.sh.Relationship to #439 (live delivery marking
read_at)Independent but complementary. This PR does not depend on #439 and stands alone: it defines "unread at attach" purely against
read_at IS NULL+ the watermark, and deliberately does not mark drained rows read. If #439 lands, live-streamed messages start advancingread_at, which makes "unread" a more precise proxy for "never delivered" — the combination moves attach-time delivery close to exactly-once. Until then, the only overlap cost is that a drained-but-unread row may also appear in a later manualinbox.shcheck, which is the current behavior for live-streamed rows too.Notes for review
config.sh's YAML helpers read one nesting level, so dotted keys round-trip as a flatmonitor.catchup:entry underdelivery:(same pre-existing behavior asdelivery.monitor.poll_interval). The default template therefore documents the key with a comment only — no nested value line thatyaml_getwould never read. A parser extension felt out of scope for this PR.10#-normalized so a leading-zero override cannot kill the watcher via octal arithmetic.Tests
Six new bats tests in
tests/test_watch.bats(all pass; existing watch-watermark suite 23/23 green locally):ctrl:despawndoes not tear down a fresh actas watcher)delivery.monitor.catchupconfig key🤖 Generated with Claude Code