Skip to content

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

Description

@olegbrok

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 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)

Code trace (scheduler.py @ c1d544b, unchanged on 17ab1cc)

  1. _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.
  2. _replay_pending_locked (:751) iterates FIFO by fired_at. Per row: zombie check (deleted/reassigned/disabled-non-oneshot → discard) then delivery attempt; on not confirmedbreak (:785–790).
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. Reap zombies in a pre-pass over the whole outbox before any delivery attempt, so a stuck head cannot immortalize deleted-schedule rows.
  3. 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).

🤖 Opened by Barsik

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions