Repository navigation
fix: deduplicate steering-inbox re-rings during handling - #71
Conversation
The re-ring ladder kept its state in .ring-state plus a separate .escalated marker, and the earlier #48 attempt added a third (.inflight). Each marker had to be reconciled with the others and with the current oldest message, and four review rounds found a new combination they got wrong. Design: one durable record per message, <task>.inbox/.ring-state, owned by the new fork seam bin/fm-task-inbox-ladder.sh: <msg> TAB <ringing|escalated> TAB <count> TAB <ring_at> TAB <seen_at> The record is valid only for the message it names; a message with no record, or a record naming another message, is the implicit delivered state, so a new oldest message is a fresh ladder by construction. Handled is implicit: the message leaves the inbox root and the record is dropped once nothing unhandled remains. Every write is temp-then-rename in the inbox directory. Transitions, one event each: delivered -> ringing (watcher delivery attempt), ringing -> ringing (re-ring, or a busy sighting after a ring updates seen_at), any -> escalated (stale wake queued; a dead pane records escalated/0 without ringing). fm_task_inbox_due_action now reads that record instead of the timer ladder: the next ring is due one grace after the later of the last ring and the last busy sighting, and a spent budget escalates after the same in-flight wait. A handling turn that dies leaves no further sightings, so it re-rings at the re-armed deadline. Suppression only moves the re-arm point; it never acks and never touches a message. An unwritable record is reported through the existing single stale-wake path (inbox_steer_state_unwritable), which ends the watcher cycle, so a write failure surfaces once per cycle and never rings or queues on every poll. tests/fm-task-inbox-ladder.test.sh covers the issue's three sequences and the review edge cases; tests/fm-task-inbox.test.sh follows the one-record format.
|
On-record decisions and validation notes for this PR. review-1 (upgrade compatibility): no compatibility layer. Test step: agent budget cut, not a failure.
The review fix rounds then changed the code and tests (age-gate comment and first-doorbell test, crash-path test, busy-sighting throttle), so those local runs predate the final head. CI runs the full suite on the final head. Review findings left open at approval. |
…t staging fixture. Relevant test and shellcheck -x pass
Design note
One durable record per message is the source of truth for the steering-inbox re-ring ladder; it replaces the
.ring-state+.escalated(+.inflight) marker combination.State record.
<task>.inbox/.ring-state, one line, owned by the fork seambin/fm-task-inbox-ladder.sh:<msg> TAB <ringing|escalated> TAB <count> TAB <ring_at> TAB <seen_at>.It is valid only for the message it names. A message with no record, or a record naming another message, is implicitly
delivered, so a new oldest message is a fresh ladder by construction.handledis implicit: the message leaves the inbox root and the record is dropped once nothing unhandled remains.Transitions (one event each, each one atomic temp-then-rename rewrite).
record_ring).note_inflightmovesseen_at).handled/.How it replaces the timer ladder.
fm_task_inbox_due_actioninbin/fm-task-inbox-lib.shnow reads that record instead of the timer markers: the next ring is due one grace after the later of the last ring and the last busy sighting, and a spent budget escalates after the same in-flight wait. A handling turn that dies leaves no further sightings, so it re-rings at the re-armed deadline. Suppression only moves the re-arm point; it never acks and never touches a message, sodocs/watcher-continuity.mdidempotency is untouched. The first prompt for a new message is the doorbellfm-sendrings at delivery; the ladder governs only re-rings.Write failures. An unwritable record surfaces once through the existing stale-wake path (
inbox_steer_state_unwritable), which ends the watcher cycle, so it never rings or queues on every poll.Intent
Build #48 (re-ring on state transition, not timer; dedupe wakes while handling is in flight) as a redesign. The first attempt was pulled from #70 by decision ("48 c is fine"): it layered marker files (.ring-state, .escalated, .inflight) around the timer-driven steering-inbox re-ring ladder, and four review rounds each found a new combination the markers got wrong - a fresh new oldest message not detected as a transition, a new message after an escalation delayed by a grace period, and an unwritable in-flight marker re-queuing the same stale wake on every poll. The decided direction: one durable state per message (delivered, ringing, handled, escalated) that rings only on state transitions. #45/#46 already landed in #70.
Issue acceptance criteria (from #48): duplicate same-kind rings during in-flight handling drop to about zero without delaying the first prompt for a genuinely new transition; lost-handling safety preserved (a handling turn that dies without ack re-rings at the re-armed deadline, crash path tested); suppression never acks and never consumes a row (docs/watcher-continuity.md idempotency untouched); transition detection uses existing durable markers, no new heuristics; fork-owned seam, no hot upstream file rewrites.
What Changed
fm-sendfor immediate first delivery, added ladder coverage, and documented the fork-owned behavior.Risk Assessment
Testing
The Test agent exceeded its invocation budget before live validation completed; no evidence was gathered for this head.
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-task-inbox-lib.sh:442- The age check returnsquietbefore reading the durable record, so a genuinely new oldest row can wait a full grace period instead of ringing on transition. This contradicts the required behavior: “re-ring on state transition, not timer” and “without delaying the first prompt for a genuinely new transition.” The same invariant is codified incorrectly at tests/fm-task-inbox-ladder.test.sh:196, while tests/fm-task-inbox-ladder.test.sh:135,175,191 backdate new rows and therefore miss the failure. Detect the record/message transition before the age gate and ring immediately; this intent contradiction requires author confirmation.tests/fm-task-inbox-ladder.test.sh:149- The required criterion says “a handling turn that dies without ack ... crash path tested,” but this regression only fabricates old timestamps withset_record; it never exercises a handling process dying and the watcher recovering from that crash boundary. The lost-handling assertion at lines 151-165 therefore does not verify crash durability or restart behavior.🔧 Fix applied.
1 warning still open:
bin/fm-watch.sh:579- Every quiet poll for aringingmessage captures the pane and rewrites.ring-statewhenever it remains busy;fm_task_inbox_ladder_probe_dueonly checksstate, ignoringseen_at(bin/fm-task-inbox-ladder.sh:112-128,bin/fm-task-inbox-lib.sh:481-492). A long handling turn therefore performs repeated backend captures and atomic writes instead of waiting until the re-armed deadline. Gate probing after a sighting, or otherwise avoid repeating the same busy-state transition until the deadline.🔧 Fix applied.
3 warnings still open:
bin/fm-watch.sh:579- Every quiet poll for aringingmessage captures the pane and rewrites.ring-statewhenever it remains busy;fm_task_inbox_ladder_probe_dueonly checksstate, ignoringseen_at(bin/fm-task-inbox-ladder.sh:112-128,bin/fm-task-inbox-lib.sh:481-492). A long handling turn therefore performs repeated backend captures and atomic writes instead of waiting until the re-armed deadline. Gate probing after a sighting, or otherwise avoid repeating the same busy-state transition until the deadline.bin/fm-task-inbox-ladder.sh:51- The new parser rejects the base version's 3-field.ring-staterecords and ignores the base version's.escalatedmarker. After upgrading a persistent home, a previously ringing or escalated message is treated asdelivered; once aged, the watcher can ring or escalate it again, duplicating a wake and losing its attempt budget. The sibling behavior is consumed atbin/fm-task-inbox-lib.sh:454. Decide whether upgrade compatibility is required; if so, migrate or consume the old markers before applying the new state semantics.bin/fm-watch.sh:545- The busy-sighting path conflates “no ringing record applies” with successful persistence:fm_task_inbox_note_inflightreturns success when.ring-stateis unreadable, malformed, or a directory becausefm_task_inbox_ladder_note_inflightreturns 0 for non-ringingstate (bin/fm-task-inbox-ladder.sh:136). For an aged message with such an unwritable marker and a busy pane,bin/fm-watch.sh:610silently skips the sighting and stale-wake report, repeating captures while never advancing bookkeeping. The quiet-probe sibling isbin/fm-watch.sh:577-581. Distinguish an absent/non-applicable record from a failed record read/write and route the latter throughinbox_steer_state_unwritable; the throttle fix round left this sibling failure path behind.✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.