You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Scheduler: proven-live outbox drain replay-storms — no attempt cap + FIFO head-of-line break re-pastes a never-confirming wake every tick and immortalizes zombie rows (live incident: 7+ dup deliveries/hr on Pi) #991
The #981 proven-live heartbeat drain turns a single never-confirming outbox row into an unbounded replay storm: the head-of-line row is re-pasted into the live session on every proven-live boundary, each replay costs the agent a full session turn (token burn), and the FIFO break means zombie rows queued behind it are never reaped. Live incident on the Pi fleet box (agent geordi), 2026-08-03 13:54–14:50 PDT: 7+ duplicate wake deliveries in under an hour, accelerating to ~1 per 1–3 min, from 13 stranded rows.
Live evidence (geordi@pi, all times PDT, verified against artifacts before acting — zero double-executions)
13:58 — soup-watchdog 4:30am prompt re-delivered (morning run fully logged; no new firing behind it)
14:46:29 — third feat: Agent config tabbed navigation #99 delivery carrying pre-1:48pm prompt text (row serialized at fire time; schedule prompt had since been edited)
Replay bursts cluster after send_heartbeat calls (13:56, 14:05) — consistent with the _check_heartbeats → _drain_outbox_if_pending trigger
Code trace (scheduler.py @ c1d544b, unchanged on 17ab1cc)
_check_heartbeats calls _drain_outbox_if_pending on EVERY tick (~30s) whenever the agent is proven live and rows are pending (scheduler.py:899, :943). No per-row attempt count, no backoff, no cap.
_replay_pending_locked (:751) iterates FIFO by fired_at. Per row: zombie check (deleted/reassigned/disabled-non-oneshot → discard) then delivery attempt; on not confirmed → break (:785–790).
Consequence A — unbounded re-paste: a head row whose replay pastes but never yields a confirmed receipt survives, so every proven-live tick spawns a fresh replay task that re-pastes the same prompt. The docstring's "never double-delivers" (:962) holds only for confirmation; the transport-visible paste (and the session turn it burns) repeats indefinitely.
Consequence B — head-of-line blocking defeats zombie reaping: the break sits before rows behind the head are ever zombie-checked, so rows for deleted schedules are immortal while the head is stuck.
Open sub-question — why does the head row never confirm? The sessions demonstrably accepted the replayed prompts (the agent no-op'd them), so either the wake callback returns non-True on the replay path or receipt matching fails against the pane acceptance (fix(scheduler): duplicate-execution guard — timeout decisions read transport execution state, not receipt state #983 semantics). journalctl pull pending to distinguish remains pending: no positive receipt vs was confirmed but outbox retirement did not match a row (:797) vs zombie lines.
Note: byte-identical last_run floats across two schedules stamped in one tick (observed 1785793514.3285415 on #154 and #157) are the documented cohort-stamp design (agent_registry.py:3238 comment), not a defect.
Impact
Duplicate wake prompts at ~1/min sustained = token burn plus real double-execution risk on any fleet agent without geordi's artifact-check discipline.
Outbox rows for deleted schedules never reap while a head row is stuck.
Stale serialized prompt text replays after schedule edits (correct-by-design for a durable outbox, but combined with A it re-delivers obsolete instructions repeatedly).
Mitigation used (no DB writes)
remove_wake_schedule for every schedule still holding stranded rows (#99, #126, #128, #154) → next proven-live drain zombie-drops all rows in order → storm stops; recreate the needed schedules afterward.
Fix directions
Per-row replay attempt counter + backoff, with a cap: after N failed replay attempts, park the row (dead-letter state) and send ONE owner alert instead of re-pasting forever.
Reap zombies in a pre-pass over the whole outbox before any delivery attempt, so a stuck head cannot immortalize deleted-schedule rows.
Root-cause the replay-path receipt mismatch (sub-question 5) — likely the actual bug that armed this loop.
Related: #981 (drain trigger), #983 (receipt semantics), #984 (same family: wake lifecycle vs transport truth).
Summary
The #981 proven-live heartbeat drain turns a single never-confirming outbox row into an unbounded replay storm: the head-of-line row is re-pasted into the live session on every proven-live boundary, each replay costs the agent a full session turn (token burn), and the FIFO
breakmeans zombie rows queued behind it are never reaped. Live incident on the Pi fleet box (agent geordi), 2026-08-03 13:54–14:50 PDT: 7+ duplicate wake deliveries in under an hour, accelerating to ~1 per 1–3 min, from 13 stranded rows.Live evidence (geordi@pi, all times PDT, verified against artifacts before acting — zero double-executions)
pending_schedule_wakes(conversations_agents.db): 13 stranded geordi rows,fired_at05:00→08:00 (ids 9–18 = schedule_ids 99,99,140,138,135,137,99,126,128,99; fired_at 1785758409→1785772828), eachcreated_at30–90 min afterfired_at— consistent with rows minted by_record_schedule_undelivered→persist_schedule_wakeafter morning deliveries failed to confirm behind busy sessionssend_heartbeatcalls (13:56, 14:05) — consistent with the_check_heartbeats→_drain_outbox_if_pendingtriggerCode trace (scheduler.py @ c1d544b, unchanged on 17ab1cc)
_check_heartbeatscalls_drain_outbox_if_pendingon EVERY tick (~30s) whenever the agent is proven live and rows are pending (scheduler.py:899,:943). No per-row attempt count, no backoff, no cap._replay_pending_locked(:751) iterates FIFO byfired_at. Per row: zombie check (deleted/reassigned/disabled-non-oneshot → discard) then delivery attempt; onnot confirmed→break(:785–790).:962) holds only for confirmation; the transport-visible paste (and the session turn it burns) repeats indefinitely.breaksits before rows behind the head are ever zombie-checked, so rows for deleted schedules are immortal while the head is stuck.remains pending: no positive receiptvswas confirmed but outbox retirement did not match a row(:797) vs zombie lines.Note: byte-identical
last_runfloats across two schedules stamped in one tick (observed 1785793514.3285415 on #154 and #157) are the documented cohort-stamp design (agent_registry.py:3238comment), not a defect.Impact
Mitigation used (no DB writes)
remove_wake_schedulefor every schedule still holding stranded rows (#99, #126, #128, #154) → next proven-live drain zombie-drops all rows in order → storm stops; recreate the needed schedules afterward.Fix directions
Related: #981 (drain trigger), #983 (receipt semantics), #984 (same family: wake lifecycle vs transport truth).
🤖 Opened by Barsik