CB-590: one nudge schedule per lead, not two racing ones #84

Closed
agent wants to merge 0 commits from worker/cb590-916766-2 into main
Member

Fixes gitea issue #75.

The defect: ReplyPushLoop ran two independent reminder schedules that both ended in agents.send(<lead terminal>, nudge) to the same pane: the CB-307 reply-nudge schedule (keyed by worker target, tracked in activeTargets) and the CB-588 ticket-nudge schedule (keyed by lead terminal, tracked in activeLeads). Neither checked the other's active set, so both could independently decide to inject into the same lead pane in the same window — a race that interrupts the lead's live turn twice.

The fix: collapsed both into one schedule per lead. onReplyQueued and onTicketTerminal both resolve the delegating lead and coalesce onto a single per-lead schedule (activeLeads, now the only active-schedule map). Each tick reads everything pending for that lead — reply targets whose inbox still holds an unacked message, and tickets not yet collected — and sends at most one combined nudge per tick, sharing one reminder cap across both sources. The STOP-vs-race-restart logic from CB-588 (stopOrRestartTicketLoop) is generalized to stopOrRestart, now diffing both pending-reply and pending-ticket snapshots so work that lands in the decide→release window is never stranded.

This is the shape the ticket asked for ("one schedule per lead that drains both kinds of pending work", not a lock between two schedules).

Acceptance criteria:

  1. Two nudge injections into the same lead pane cannot overlap — structurally true now: at most one scheduled tick chain is ever live per lead (CAS-guarded via activeLeads), so at most one agents.send to that lead is ever in flight.
  2. A nudge is never dropped, only deferred — both entry points re-register into the shared pending maps and coalesce onto the live (or a freshly restarted) schedule.
  3. New tests replyAndTicketForTheSameLeadCoalesceIntoOneSendNeverOverlapping and aReplyQueuedWhileTheLeadIsBusyIsNotLostWhenATicketArrivesToo drive both entry points at the same lead and prove criteria 1 and 2.
  4. isActive() keeps its CB-551 contract (now backed by the single activeLeads map).
  5. No spin — WAIT_BUSY still only reschedules on the configured backoff; the race-restart path terminates in one step (proof carried over from CB-588's javadoc, now covering both pending sources).
  6. mvn -f bridged/pom.xml clean install: Tests run: 810, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS (unpiped).

Test plan:

  • Unpiped mvn clean install green (810 tests)
  • Existing ReplyPushLoopTest suite rewritten to register pending work through the public entry points before exercising the now-unified decide(), preserving every prior behavioral guarantee (cap enforcement, coalescing, idempotency, metrics)
  • Two new tests for the CB-590-specific race/loss scenarios
Fixes gitea issue #75. **The defect:** ReplyPushLoop ran two independent reminder schedules that both ended in `agents.send(<lead terminal>, nudge)` to the same pane: the CB-307 reply-nudge schedule (keyed by worker target, tracked in `activeTargets`) and the CB-588 ticket-nudge schedule (keyed by lead terminal, tracked in `activeLeads`). Neither checked the other's active set, so both could independently decide to inject into the same lead pane in the same window — a race that interrupts the lead's live turn twice. **The fix:** collapsed both into one schedule per lead. `onReplyQueued` and `onTicketTerminal` both resolve the delegating lead and coalesce onto a single per-lead schedule (`activeLeads`, now the only active-schedule map). Each tick reads everything pending for that lead — reply targets whose inbox still holds an unacked message, and tickets not yet collected — and sends at most one combined nudge per tick, sharing one reminder cap across both sources. The STOP-vs-race-restart logic from CB-588 (`stopOrRestartTicketLoop`) is generalized to `stopOrRestart`, now diffing both pending-reply and pending-ticket snapshots so work that lands in the decide→release window is never stranded. This is the shape the ticket asked for ("one schedule per lead that drains both kinds of pending work", not a lock between two schedules). **Acceptance criteria:** 1. Two nudge injections into the same lead pane cannot overlap — structurally true now: at most one scheduled tick chain is ever live per lead (CAS-guarded via `activeLeads`), so at most one `agents.send` to that lead is ever in flight. 2. A nudge is never dropped, only deferred — both entry points re-register into the shared pending maps and coalesce onto the live (or a freshly restarted) schedule. 3. New tests `replyAndTicketForTheSameLeadCoalesceIntoOneSendNeverOverlapping` and `aReplyQueuedWhileTheLeadIsBusyIsNotLostWhenATicketArrivesToo` drive both entry points at the same lead and prove criteria 1 and 2. 4. `isActive()` keeps its CB-551 contract (now backed by the single `activeLeads` map). 5. No spin — WAIT_BUSY still only reschedules on the configured backoff; the race-restart path terminates in one step (proof carried over from CB-588's javadoc, now covering both pending sources). 6. `mvn -f bridged/pom.xml clean install`: Tests run: 810, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS (unpiped). **Test plan:** - [x] Unpiped `mvn clean install` green (810 tests) - [x] Existing ReplyPushLoopTest suite rewritten to register pending work through the public entry points before exercising the now-unified `decide()`, preserving every prior behavioral guarantee (cap enforcement, coalescing, idempotency, metrics) - [x] Two new tests for the CB-590-specific race/loss scenarios
agent added 1 commit 2026-08-16 16:56:26 +02:00
CB-590: collapse the CB-307 and CB-588 nudge schedules into one per lead
CI / build (pull_request) Successful in 1m0s
CI / contract (pull_request) Successful in 1m33s
78ca24dc3f
Both reply-queued and ticket-terminal nudges could independently decide
to inject into the same lead pane in the same window, since they ran as
two separate schedules keyed differently (worker target vs. lead) that
never checked each other. Replace both with a single per-lead schedule
(activeLeads) that drains pending reply targets and pending tickets
together, sends at most one combined nudge per tick, and shares one
reminder cap across both sources — so two injections into the same pane
can no longer overlap, and work queued while the lead is busy is never
lost, only deferred.
Owner

Lead review — not merged yet, one regression to fix first

I built this branch myself, unpiped: Tests run: 810, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS. An independent reviewer, briefed only from the diff, returned a HIGH "do not merge". I read ReplyPushLoop.java on the branch myself rather than take that on trust, and I agree with it.

What this PR gets right — none of it changes

Collapsing the two per-lead schedules into one is correct and is the point of CB-590. The double-injection race is closed. stopOrRestart's snapshot-diff correctly separates a genuine race arrival from stale backlog, and it now covers the reply arm, which the old code never did at all. The rewritten test suite pins real guarantees.

The regression

decide(lead, reminderCount) stops at reminderCount >= maxReminders (default 5, backoff 15000 ms). That counter is per schedule. After this PR there is one schedule per lead, shared by both sources. So the reply source and the ticket source now draw on a single budget.

Before this PR they could not: replies were keyed by target with their own counter, tickets by lead with theirs. A busy reply stream could never touch a ticket's budget. That isolation is lost here, and that makes it a regression rather than a pre-existing wart.

The traced failure:

t=0,15,30,45,60s   an undrained reply target is nudged 5 times  -> reminderCount hits the cap
t=62s              a ticket for the SAME lead goes terminal
t=75s              tick(): the ticketsBefore snapshot already contains that ticket (it arrived at 62s)
                   decide() -> STOP (cap exhausted); stopOrRestart finds nothing "new" -> no restart

The ticket sits in pendingTickets with no live schedule, and it was never named in any of the five nudges — it did not exist when they were sent. It is resurrected only by some unrelated later trigger for the same lead. Acceptance criterion 2 of the original ticket allows deferred; this is lost.

The fix, dispatched

Per-source reminder budgets, single schedule kept. One schedule per lead as now, but the cap is tracked per source — a reply budget and a ticket budget — and decide() returns INJECT while either source is under its own cap. STOP only when both are exhausted. That restores exactly the isolation this PR removed and changes nothing else.

Also required in that round: a direct test for the reply arm of stopOrRestart (both existing direct tests at ReplyPushLoopTest.java:397 and :417 pass Set.of() for repliesBefore, so only half of a symmetric branch is pinned), a cross-source regression test, and an assertion that isActive() is not false while work is pending — the reviewer showed it can be, which then stops LeadHeartbeatLoop from standing aside.

Explicitly out of scope, filed separately

The wider problem — an item arriving during the 15 s backoff is already inside the "before" snapshot, so at-cap work is abandoned rather than nudged once — exists on main today in the ticket-only loop. It is not this PR's defect and I told the fix worker not to touch it. Filed as #87 (CB-598), to land after this round.

Merging once the per-source fix is in and I have rebuilt it myself.

## Lead review — not merged yet, one regression to fix first I built this branch myself, unpiped: `Tests run: 810, Failures: 0, Errors: 0, Skipped: 0`, BUILD SUCCESS. An independent reviewer, briefed only from the diff, returned a HIGH "do not merge". I read `ReplyPushLoop.java` on the branch myself rather than take that on trust, and I agree with it. ### What this PR gets right — none of it changes Collapsing the two per-lead schedules into one is correct and is the point of CB-590. The double-injection race is closed. `stopOrRestart`'s snapshot-diff correctly separates a genuine race arrival from stale backlog, and it now covers the **reply** arm, which the old code never did at all. The rewritten test suite pins real guarantees. ### The regression `decide(lead, reminderCount)` stops at `reminderCount >= maxReminders` (default 5, backoff 15000 ms). That counter is **per schedule**. After this PR there is one schedule per lead, **shared by both sources**. So the reply source and the ticket source now draw on a single budget. Before this PR they could not: replies were keyed by target with their own counter, tickets by lead with theirs. A busy reply stream could never touch a ticket's budget. That isolation is lost here, and that makes it a regression rather than a pre-existing wart. The traced failure: ``` t=0,15,30,45,60s an undrained reply target is nudged 5 times -> reminderCount hits the cap t=62s a ticket for the SAME lead goes terminal t=75s tick(): the ticketsBefore snapshot already contains that ticket (it arrived at 62s) decide() -> STOP (cap exhausted); stopOrRestart finds nothing "new" -> no restart ``` The ticket sits in `pendingTickets` with no live schedule, and it was never named in any of the five nudges — it did not exist when they were sent. It is resurrected only by some unrelated later trigger for the same lead. Acceptance criterion 2 of the original ticket allows *deferred*; this is *lost*. ### The fix, dispatched **Per-source reminder budgets, single schedule kept.** One schedule per lead as now, but the cap is tracked per source — a reply budget and a ticket budget — and `decide()` returns `INJECT` while *either* source is under its own cap. `STOP` only when both are exhausted. That restores exactly the isolation this PR removed and changes nothing else. Also required in that round: a direct test for the **reply** arm of `stopOrRestart` (both existing direct tests at `ReplyPushLoopTest.java:397` and `:417` pass `Set.of()` for `repliesBefore`, so only half of a symmetric branch is pinned), a cross-source regression test, and an assertion that `isActive()` is not false while work is pending — the reviewer showed it can be, which then stops `LeadHeartbeatLoop` from standing aside. ### Explicitly out of scope, filed separately The wider problem — an item arriving during the 15 s backoff is already inside the "before" snapshot, so at-cap work is abandoned rather than nudged once — **exists on `main` today** in the ticket-only loop. It is not this PR's defect and I told the fix worker not to touch it. Filed as #87 (CB-598), to land after this round. Merging once the per-source fix is in and I have rebuilt it myself.
Owner

Merged as part of #90, which stacked the per-source budget fix on top of this branch. This branch's head 78ca24d is now an ancestor of origin/main. Closing as delivered, not as rejected — the schedule collapse in here is the substance of CB-590 and it shipped unchanged.

Merged as part of #90, which stacked the per-source budget fix on top of this branch. This branch's head `78ca24d` is now an ancestor of `origin/main`. Closing as delivered, not as rejected — the schedule collapse in here is the substance of CB-590 and it shipped unchanged.
ltms closed this pull request 2026-08-16 17:29:07 +02:00
Some checks are pending
CI / build (pull_request) Successful in 1m0s
CI / contract (pull_request) Successful in 1m33s

Pull request closed

Sign in to join this conversation.