08968bb1b7
bridge_ask blocks the worker's turn for ~55s by default (BridgedApp.java,
BridgeMcp.java) — a value deliberately kept just under the worker's own MCP
client's ~60s call cap so the daemon can return a clean timeout before the
client severs the call, not a value that can usefully be widened. A lead
following the charter's wait:false + poll cadence is minutes away, so the
window closes long before a poll would ever see the question — and until now
bridge_poll on such a ticket just read as ordinary "pending" progress.
bridge_poll(ticket) already surfaced Phase.ASKING with the question and
turnId (CB-205); this ships the two pieces that were still missing:
- The lead's own pane is now nudged the instant a question opens, reusing
the CB-588 ReplyPushLoop push mechanism (a third source alongside queued
replies and terminal tickets) rather than a new path. The nudge is capped
by the loop's existing maxReminders budget, and stops the moment the
question is answered or lapses.
- bridge_status(sessionId) and REST GET /sessions/{id}/status now also show
an open question and how to answer it, via a new
MessageService.pendingAsk() lookup — covering the case where a lead checks
status directly rather than the ticket.
- The REST /tasks/{ticket} endpoint was silently missing turnId on an ASKING
phase (only the MCP layer's formatted text carried it) — fixed as part of
making the state genuinely visible over both surfaces.
An unanswered question still behaves as today: the worker proceeds and its
reply says the ask went unanswered — not a hard failure.
976 lines
43 KiB
Java
976 lines
43 KiB
Java
package dev.ltms.bridged.msg;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import dev.ltms.bridged.herdr.AgentControl;
|
|
import dev.ltms.bridged.herdr.HerdrClient;
|
|
import dev.ltms.bridged.mcp.PrimaryRegistry;
|
|
import dev.ltms.bridged.metrics.BridgedMetrics;
|
|
import dev.ltms.bridged.metrics.Metrics;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.CountDownLatch;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.ScheduledExecutorService;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* Unit tests for {@link ReplyPushLoop}: decision logic, nudge injection, idempotency,
|
|
* bounded reminders, and stop conditions.
|
|
*
|
|
* <p>CB-590 collapsed the CB-307 reply-nudge schedule and the CB-588 ticket-nudge schedule into
|
|
* one schedule per lead ({@link ReplyPushLoop#decide}), so most tests below register pending work
|
|
* through the public entry points ({@code onReplyQueued} / {@code onTicketTerminal}) before
|
|
* exercising {@code decide} directly, mirroring how the two entry points now share one decision
|
|
* function keyed by the lead terminal rather than by worker target.
|
|
*
|
|
* <p>Uses a {@link RecordingHerdrClient} that synchronizes access to its call list so the
|
|
* scheduler thread and test thread never have memory ordering issues. The {@code decide()}
|
|
* tests use a simple client with no concurrency concern.
|
|
*/
|
|
class ReplyPushLoopTest {
|
|
|
|
private static final String PRIMARY = "term_primary";
|
|
private static final String WORKER = "term_worker";
|
|
private static final String WORKER2 = "term_worker2";
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
|
private PrimaryRegistry registry;
|
|
private AgentControl agents;
|
|
private InMemoryReplyInbox inbox;
|
|
private ScheduledExecutorService scheduler;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
registry = new PrimaryRegistry(PRIMARY);
|
|
inbox = new InMemoryReplyInbox();
|
|
inbox.own(WORKER); // CB-520: the inbox only peeks/acks targets it owns
|
|
scheduler = Executors.newSingleThreadScheduledExecutor();
|
|
}
|
|
|
|
@AfterEach
|
|
void tearDown() {
|
|
scheduler.shutdownNow();
|
|
}
|
|
|
|
// --- decide() logic ------------------------------------------------------------------------
|
|
|
|
@Test
|
|
void onReplyQueuedWithNoKnownLeadNeverStartsASchedule() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = new ReplyPushLoop(new PrimaryRegistry(null), agents, inbox, scheduler, 5, 50);
|
|
|
|
loop.onReplyQueued(WORKER); // no lead known -> never registered, never scheduled
|
|
|
|
assertFalse(loop.isActive(), "no lead known means nothing to nudge yet");
|
|
Thread.sleep(150);
|
|
assertEquals(0, rec.sendCount(), "must not nudge when no lead is known to be waiting");
|
|
}
|
|
|
|
@Test
|
|
void decideWithNothingPendingIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop().decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideAtCapIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(2, 100);
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 2, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideUnderCapWithInjectablePrimaryIsInject() {
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideUnderCapWithBlockedPrimaryIsInject() {
|
|
agents = agentWithStatus("blocked");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0),
|
|
"BLOCKED is injectable");
|
|
}
|
|
|
|
@Test
|
|
void decideUnderCapWithDonePrimaryIsInject() {
|
|
agents = agentWithStatus("done");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0),
|
|
"DONE is injectable");
|
|
}
|
|
|
|
@Test
|
|
void decideUnderCapWithBusyPrimaryIsWaitBusy() {
|
|
agents = agentWithStatus("working");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideUnderCapWithUnknownPrimaryIsWaitBusy() {
|
|
agents = agentWithStatus("unknown");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideStopsAfterInboxIsEmptied() {
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop();
|
|
loop.onReplyQueued(WORKER);
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0));
|
|
inbox.ack(WORKER, "m1");
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
// --- onReplyQueued integration -------------------------------------------------------------
|
|
|
|
@Test
|
|
void injectablePrimaryCausesExactlyOneNudge() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
|
|
loop(1, 50).onReplyQueued(WORKER);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
|
"one nudge (1 agent.prompt call) should have been sent");
|
|
|
|
// Exactly one nudge = exactly 1 agent.prompt call (it submits itself)
|
|
assertEquals(1, rec.sendCount());
|
|
assertTrue(rec.sentParams().stream()
|
|
.anyMatch(e -> e.getValue().toString().contains("bridge_poll")),
|
|
"nudge text should contain bridge_poll");
|
|
}
|
|
|
|
@Test
|
|
void onReplyQueuedIsIdempotentPerTarget() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
|
|
var loop = loop(1, 100);
|
|
loop.onReplyQueued(WORKER);
|
|
loop.onReplyQueued(WORKER); // second call — should be a no-op
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
|
"expected exactly one nudge (1 prompt)");
|
|
Thread.sleep(200);
|
|
assertEquals(1, rec.sendCount(),
|
|
"second onReplyQueued must not trigger another nudge");
|
|
}
|
|
|
|
@Test
|
|
void sendsUpToCapThenStops() throws Exception {
|
|
int cap = 2;
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
rec.sendLatch = new CountDownLatch(cap);
|
|
|
|
loop(cap, 50).onReplyQueued(WORKER);
|
|
|
|
assertTrue(rec.sendLatch.await(5, TimeUnit.SECONDS),
|
|
cap + " nudges (" + cap + " prompts) should have fired");
|
|
Thread.sleep(300);
|
|
assertEquals(cap, rec.sendCount(),
|
|
"exactly " + cap + " agent.prompt calls (cap=" + cap + ")");
|
|
}
|
|
|
|
// --- nudge format --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
void isActiveReflectsALiveScheduleForTheHeartbeatStandDown() {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
ReplyPushLoop loop = loop(1, 100_000); // long backoff so the tick cannot fire mid-test
|
|
|
|
loop.onReplyQueued(WORKER);
|
|
assertTrue(loop.isActive(), "CB-551: the heartbeat must stand aside while a reminder is live");
|
|
|
|
loop.stop();
|
|
assertFalse(loop.isActive(), "stopping clears the active schedule");
|
|
}
|
|
|
|
@Test
|
|
void nudgeFormatIsCorrect() {
|
|
String nudge = ReplyPushLoop.NUDGE_FORMAT.formatted(WORKER, WORKER);
|
|
assertTrue(nudge.contains("Worker term_worker"));
|
|
assertTrue(nudge.contains("bridge_poll(target=term_worker)"));
|
|
}
|
|
|
|
@Test
|
|
void repliesNudgeFormatIsCorrect() {
|
|
String multi = ReplyPushLoop.REPLIES_NUDGE_FORMAT.formatted(2, "term_worker1, term_worker2");
|
|
assertTrue(multi.contains("2 workers"));
|
|
assertTrue(multi.contains("bridge_poll(target=...)"));
|
|
}
|
|
|
|
// --- CB-588: async ticket terminal nudges — decide() logic on tickets -----------------------
|
|
|
|
@Test
|
|
void decideTicketsWithNothingPendingIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop().decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideTicketsAtCapIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
var loop = loop(2, 100_000);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 0, 2));
|
|
}
|
|
|
|
@Test
|
|
void decideTicketsUnderCapWithInjectableLeadIsInject() {
|
|
agents = agentWithStatus("idle");
|
|
var loop = loop(5, 100_000);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideTicketsUnderCapWithBusyLeadIsWaitBusy() {
|
|
agents = agentWithStatus("working");
|
|
var loop = loop(5, 100_000);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
assertEquals(ReplyPushLoop.Action.WAIT_BUSY, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideTicketsWithoutAKnownLeadIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
var loop = new ReplyPushLoop(new PrimaryRegistry(null), agents, inbox, scheduler, 5, 100_000);
|
|
loop.onTicketTerminal("task-1", WORKER, false); // no lead known -> never registered as pending
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 0, 0));
|
|
}
|
|
|
|
// --- CB-588: onTicketTerminal integration ---------------------------------------------------
|
|
|
|
@Test
|
|
void onTicketTerminalCausesExactlyOneNudgeNamingTheTicketAndThePollCall() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
|
|
loop(1, 50).onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one ticket nudge should have been sent");
|
|
assertEquals(1, rec.sendCount());
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("task-1"), "nudge should name the ticket");
|
|
assertTrue(nudge.contains("bridge_poll(ticket="), "nudge should name the exact ticket-poll call");
|
|
assertFalse(nudge.contains("bridge_poll(target="), "a ticket-only nudge must not tell the lead to run the target-poll call");
|
|
}
|
|
|
|
@Test
|
|
void aFailedTicketNudgeSaysItWasAFailure() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
|
|
loop(1, 50).onTicketTerminal("task-1", WORKER, true);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS));
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.toUpperCase().contains("FAILED"), "a failed ticket's nudge must say so: " + nudge);
|
|
}
|
|
|
|
@Test
|
|
void severalTicketsFinishingTogetherProduceOneCoalescedNudge() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = loop(1, 300); // backoff wide enough that both onTicketTerminal calls land first
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
loop.onTicketTerminal("task-2", WORKER, true); // arrives while the schedule is already active
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one coalesced nudge should have been sent");
|
|
assertEquals(1, rec.sendCount(), "two tickets finishing together must produce ONE nudge, not two");
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("task-1") && nudge.contains("task-2"),
|
|
"the coalesced nudge should name both tickets: " + nudge);
|
|
assertTrue(nudge.contains("2 tickets"), "the coalesced nudge should name the count: " + nudge);
|
|
}
|
|
|
|
@Test
|
|
void onTicketTerminalIsIdempotentPerLeadWhileActive() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
rec.sendLatch = new CountDownLatch(1);
|
|
|
|
var loop = loop(1, 100);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
loop.onTicketTerminal("task-1", WORKER, false); // duplicate — should not start a second schedule
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS));
|
|
Thread.sleep(200);
|
|
assertEquals(1, rec.sendCount(), "a duplicate onTicketTerminal for the same lead must not double-nudge");
|
|
}
|
|
|
|
@Test
|
|
void aTicketAlreadyCollectedProducesNoNudge() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = loop(1, 100);
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
loop.ticketCollected("task-1"); // the lead polled before the first tick fired
|
|
|
|
Thread.sleep(300); // let the scheduled tick run
|
|
assertEquals(0, rec.sendCount(), "an already-collected ticket must never be nudged");
|
|
}
|
|
|
|
@Test
|
|
void ticketNudgesSendUpToCapThenStop() throws Exception {
|
|
int cap = 2;
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
rec.sendLatch = new CountDownLatch(cap);
|
|
|
|
loop(cap, 50).onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertTrue(rec.sendLatch.await(5, TimeUnit.SECONDS), cap + " ticket nudges should have fired");
|
|
Thread.sleep(300);
|
|
assertEquals(cap, rec.sendCount(), "exactly " + cap + " ticket nudges (cap=" + cap + ")");
|
|
}
|
|
|
|
@Test
|
|
void isActiveReflectsALiveTicketScheduleForTheHeartbeatStandDown() {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
ReplyPushLoop loop = loop(1, 100_000); // long backoff so the tick cannot fire mid-test
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
assertTrue(loop.isActive(), "an active ticket-reminder schedule must also stand off the heartbeat");
|
|
|
|
loop.stop();
|
|
assertFalse(loop.isActive(), "stopping clears the active ticket schedule too");
|
|
}
|
|
|
|
@Test
|
|
void aTicketStillPendingWhenTheLoopStopsIsNotStranded() {
|
|
// Regression for the race a reviewer found in gitea PR #73: onTicketTerminal's
|
|
// activeLeads.putIfAbsent can see the lead's slot as still occupied a moment before
|
|
// decide's STOP releases it, so the ticket coalesces onto a schedule that is about to
|
|
// die and nothing ever nudges about it. Forcing that exact thread interleaving is not
|
|
// reliable, so this drives stopOrRestart — the STOP path's own release-and-recheck —
|
|
// directly, arranging the state it must not lose a ticket in: a ticket pending for the lead
|
|
// that was NOT part of the pre-decision snapshot (ticketsBefore=empty), standing in for one
|
|
// that races in during the decision-to-release window.
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
ReplyPushLoop loop = loop(1, 100_000); // long backoff — no natural tick fires during this test
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false); // pendingTickets={task-1}; activeLeads={PRIMARY}
|
|
|
|
// Stand in for the scheduler thread reaching decide==STOP for this lead — with nothing
|
|
// pending at decide time — while task-1 races in before the release below runs.
|
|
loop.stopOrRestart(PRIMARY, Set.of(), Set.of());
|
|
|
|
assertTrue(loop.isActive(), "a ticket that raced the loop's stop must reclaim the schedule "
|
|
+ "slot, not be stranded with no schedule left to ever nudge about it");
|
|
}
|
|
|
|
@Test
|
|
void aStaleUncollectedTicketAtCapDoesNotRestartTheLoop() {
|
|
// The other direction of the same fix: restarting on ANY non-empty pending set would be
|
|
// wrong. When STOP is reached because the reminder cap was hit, the same never-collected
|
|
// ticket is expected to still be there — that is the cap doing its job (acceptance criterion
|
|
// #5: no spin / nudges stay bounded). task-1 here was already accounted for at decide time
|
|
// (it is in ticketsBefore), so it must not restart the loop just because it is still sitting
|
|
// there.
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
ReplyPushLoop loop = loop(1, 100_000);
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false); // pendingTickets={task-1}; activeLeads={PRIMARY}
|
|
|
|
loop.stopOrRestart(PRIMARY, Set.of(), Set.of("task-1"));
|
|
|
|
assertFalse(loop.isActive(), "a stale ticket already accounted for at decide time must not "
|
|
+ "restart the loop — that would defeat the reminder cap");
|
|
}
|
|
|
|
// --- mirror of the two stopOrRestart tests above, for the reply arm ------------------------
|
|
//
|
|
// Both tests above only ever passed Set.of() for repliesBefore, so racedIn's reply branch
|
|
// (`pendingReplyTargetsFor(lead).stream().anyMatch(t -> !repliesBefore.contains(t))`) was
|
|
// never exercised by anything other than an always-empty snapshot. The reviewer flagged this:
|
|
// racedIn is symmetric in the code, and only half of it was pinned by a test.
|
|
|
|
@Test
|
|
void aReplyStillPendingWhenTheLoopStopsIsNotStranded() {
|
|
// Mirrors aTicketStillPendingWhenTheLoopStopsIsNotStranded: a reply target that raced in
|
|
// during the decision-to-release window (absent from the "before" snapshot) must reclaim
|
|
// the schedule slot rather than being stranded with no schedule left to nudge about it.
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
ReplyPushLoop loop = loop(1, 100_000); // long backoff — no natural tick fires during this test
|
|
|
|
loop.onReplyQueued(WORKER); // pendingReplies={term_worker}; activeLeads={PRIMARY}
|
|
|
|
loop.stopOrRestart(PRIMARY, Set.of(), Set.of());
|
|
|
|
assertTrue(loop.isActive(), "a reply that raced the loop's stop must reclaim the schedule "
|
|
+ "slot, not be stranded with no schedule left to ever nudge about it");
|
|
}
|
|
|
|
@Test
|
|
void aStaleUncollectedReplyAtCapDoesNotRestartTheLoop() {
|
|
// Mirrors aStaleUncollectedTicketAtCapDoesNotRestartTheLoop: a reply target already
|
|
// accounted for at decide time (present in repliesBefore) must not restart the loop —
|
|
// that is the reminder cap doing its job, not a race.
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
ReplyPushLoop loop = loop(1, 100_000);
|
|
|
|
loop.onReplyQueued(WORKER); // pendingReplies={term_worker}; activeLeads={PRIMARY}
|
|
|
|
loop.stopOrRestart(PRIMARY, Set.of(WORKER), Set.of());
|
|
|
|
assertFalse(loop.isActive(), "a stale reply target already accounted for at decide time "
|
|
+ "must not restart the loop — that would defeat the reminder cap");
|
|
}
|
|
|
|
@Test
|
|
void ticketNudgeFormatIsCorrect() {
|
|
String single = ReplyPushLoop.TICKET_NUDGE_FORMAT.formatted("task-1", "", "task-1");
|
|
assertTrue(single.contains("Ticket task-1"));
|
|
assertTrue(single.contains("bridge_poll(ticket=task-1)"));
|
|
|
|
String multi = ReplyPushLoop.TICKETS_NUDGE_FORMAT.formatted(2, "", "task-1, task-2");
|
|
assertTrue(multi.contains("2 tickets"));
|
|
assertTrue(multi.contains("bridge_poll(ticket=...)"));
|
|
}
|
|
|
|
// --- CB-307 nudge path is unchanged (regression) --------------------------------------------
|
|
|
|
@Test
|
|
void inboxNudgeStillUsesTheOriginalTargetPollCall() {
|
|
String nudge = ReplyPushLoop.NUDGE_FORMAT.formatted(WORKER, WORKER);
|
|
assertTrue(nudge.contains("bridge_poll(target=" + WORKER + ")"),
|
|
"CB-588/CB-590 must not change the CB-307 inbox nudge's call shape");
|
|
}
|
|
|
|
// --- CB-590: one schedule per lead — no overlap, no lost nudges -----------------------------
|
|
|
|
@Test
|
|
void replyAndTicketForTheSameLeadCoalesceIntoOneSendNeverOverlapping() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(1, 300); // backoff wide enough that both entry points land before the first tick
|
|
|
|
loop.onReplyQueued(WORKER);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one combined nudge should have been sent");
|
|
Thread.sleep(300);
|
|
assertEquals(1, rec.sendCount(),
|
|
"a reply and a ticket for the same lead must coalesce onto ONE schedule — "
|
|
+ "two nudge injections into the same lead pane must never overlap");
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("bridge_poll(target=" + WORKER + ")"),
|
|
"the combined nudge must still mention the reply: " + nudge);
|
|
assertTrue(nudge.contains("task-1"), "the combined nudge must still mention the ticket: " + nudge);
|
|
}
|
|
|
|
@Test
|
|
void aReplyQueuedWhileTheLeadIsBusyIsNotLostWhenATicketArrivesToo() throws Exception {
|
|
// The lead is busy for its first two status checks, then becomes injectable. A reply is
|
|
// queued while busy; a ticket for the same lead arrives before the lead frees up. Neither
|
|
// may be dropped — deferred is fine, lost is not (acceptance criterion #2).
|
|
var rec = new BusyThenIdleHerdrClient(2);
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(1, 50); // cap=1: WAIT_BUSY doesn't count against it, so exactly one send once injectable
|
|
|
|
loop.onReplyQueued(WORKER); // schedule starts, first tick(s) WAIT_BUSY
|
|
loop.onTicketTerminal("task-1", WORKER, false); // coalesces onto the same waiting schedule
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
|
"once the lead becomes injectable, the deferred work must still be nudged");
|
|
Thread.sleep(200);
|
|
assertEquals(1, rec.sendCount(), "exactly one nudge once injectable — reply and ticket coalesced");
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("bridge_poll(target=" + WORKER + ")"), "the reply must not be dropped: " + nudge);
|
|
assertTrue(nudge.contains("task-1"), "the ticket must not be dropped: " + nudge);
|
|
}
|
|
|
|
// --- CB-590 follow-up: per-source reminder budgets — the regression this round exists for ---
|
|
|
|
@Test
|
|
void oneExhaustedSourceDoesNotBlockANudgeForTheOtherSource() {
|
|
// The live trace this ticket was filed from: an undrained reply target got nudged up to
|
|
// its cap (5 reminders), then a ticket for the SAME lead went terminal shortly before the
|
|
// next scheduled tick — so it coalesced onto the still-active schedule (arriving BEFORE
|
|
// that tick's "before" snapshot, not during the decision-to-release race stopOrRestart
|
|
// guards). With CB-590's single shared reminder counter, that tick's decide() saw
|
|
// reminderCount already at the cap and returned STOP regardless of the ticket, and because
|
|
// the ticket was already present in that tick's "before" snapshot, stopOrRestart's
|
|
// racedIn check (proven correct on its own above) did not save it either — it is not a
|
|
// race, it looks like ordinary stale backlog. The ticket was then stranded: pending
|
|
// forever with no live schedule, never named in any nudge.
|
|
//
|
|
// Fixed by giving each source its own counter. Here the reply source is AT its cap (2/2)
|
|
// and the ticket source has NEVER been nudged (0/2) — decide() must still return INJECT,
|
|
// because the ticket is still eligible on its own budget.
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(2, 100_000); // huge backoff — this test drives decide()/isActive() directly
|
|
loop.onReplyQueued(WORKER);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 2, 0),
|
|
"the reply source is exhausted (2/2), but the ticket source has never been "
|
|
+ "nudged (0/2) — the lead must still be injected so the ticket is not "
|
|
+ "lost, exactly the CB-590 follow-up regression");
|
|
|
|
// isActive() (criterion #5): a real tick that takes the INJECT branch above never calls
|
|
// stopOrRestart, so the schedule started by onTicketTerminal above stays live — the
|
|
// ticket is not left stranded with isActive()==false while it is still pending.
|
|
assertTrue(loop.isActive(), "the schedule must stay active while the ticket source still "
|
|
+ "has budget left, even though the reply source sharing it is exhausted");
|
|
}
|
|
|
|
@Test
|
|
void bothSourcesExhaustedIsStillStop() {
|
|
// The flip side: per-source budgets must not turn into unbounded nudging. When BOTH
|
|
// sources are at their cap, decide() must still STOP — a per-source budget is still a
|
|
// budget.
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(2, 100_000);
|
|
loop.onReplyQueued(WORKER);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 2, 2),
|
|
"both the reply and the ticket source are at their own cap — must still stop");
|
|
}
|
|
|
|
// --- CB-598: work arriving during a backoff must not read as stale backlog -------------------
|
|
|
|
@Test
|
|
void aTargetArrivingDuringTheBackoffGetsNudgedDespiteAnAlreadyCappedSibling() {
|
|
// The bug: reminder counts used to be a single counter per lead per source, carried
|
|
// forward across scheduled ticks (scheduleNext(lead, count + 1, ...)) rather than tracked
|
|
// per pending item. WORKER gets nudged once here, which — with cap=1 — exhausts the
|
|
// shared reply-source counter for this lead. WORKER2 then queues a reply for the SAME
|
|
// lead "during the backoff": while the schedule from WORKER's tick is still active, before
|
|
// the next tick's own start-of-tick snapshot runs. At that next tick, the OLD code passed
|
|
// the already-exhausted shared counter into decide() regardless of WORKER2 never having
|
|
// been named in any nudge, and — because WORKER2 was already present in that tick's
|
|
// "before" snapshot — stopOrRestart's race check (proven correct on its own elsewhere in
|
|
// this file) does not save it either: it looks like ordinary stale backlog, not a race.
|
|
// WORKER2 was then stranded forever with no live schedule and no nudge ever naming it.
|
|
//
|
|
// tick() is driven directly (package-private, same reasoning as stopOrRestart being
|
|
// directly testable) so the exact interleaving is deterministic instead of racing the
|
|
// scheduler thread over a real ~15s backoff.
|
|
//
|
|
// Before the fix, this test fails on the second assertEquals: rec.sendCount() stays at 1
|
|
// (decide() returns STOP on the second tick(), so injectNudge is never called a second
|
|
// time) and the "must still get one" assertion never even runs.
|
|
int cap = 1;
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.own(WORKER2);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
var loop = loop(cap, 100_000); // huge backoff — nothing fires on its own; we drive tick()
|
|
|
|
loop.onReplyQueued(WORKER);
|
|
loop.tick(PRIMARY); // first tick: nudges WORKER alone; WORKER's own count reaches the cap
|
|
assertEquals(1, rec.sendCount(), "the first tick should nudge about WORKER");
|
|
|
|
// WORKER2 "arrives during the backoff": queued for the same lead while the schedule from
|
|
// the tick above is still active (activeLeads still holds PRIMARY), before the next tick
|
|
// (simulated below) takes its own start-of-tick snapshot.
|
|
inbox.publish(WORKER2, "m2", "hello2");
|
|
loop.onReplyQueued(WORKER2);
|
|
|
|
loop.tick(PRIMARY); // the tick that would fire once that backoff elapsed
|
|
|
|
assertEquals(2, rec.sendCount(),
|
|
"WORKER2 was never named in any nudge yet and must still get one, even though "
|
|
+ "WORKER's own reminder count is already at the cap");
|
|
String secondNudge = rec.sentParams().get(1).getValue().toString();
|
|
assertTrue(secondNudge.contains(WORKER2), "the never-named target must be named: " + secondNudge);
|
|
|
|
// Criterion #3: isActive() must reflect that this lead still had a live nudge to give —
|
|
// the second tick took the INJECT branch, so the schedule stayed live rather than being
|
|
// torn down under WORKER2.
|
|
assertTrue(loop.isActive(), "the schedule must stay active after nudging the fresh target");
|
|
}
|
|
|
|
@Test
|
|
void aTicketArrivingDuringTheBackoffGetsNudgedDespiteAnAlreadyCappedSibling() {
|
|
// Mirrors the reply-side test above for the ticket source.
|
|
int cap = 1;
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = loop(cap, 100_000);
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
loop.tick(PRIMARY); // first tick: nudges task-1 alone; its count reaches the cap
|
|
assertEquals(1, rec.sendCount(), "the first tick should nudge about task-1");
|
|
|
|
loop.onTicketTerminal("task-2", WORKER, false); // arrives during the backoff, same lead
|
|
loop.tick(PRIMARY);
|
|
|
|
assertEquals(2, rec.sendCount(),
|
|
"task-2 was never named in any nudge yet and must still get one, even though "
|
|
+ "task-1's reminder count is already at the cap");
|
|
String secondNudge = rec.sentParams().get(1).getValue().toString();
|
|
assertTrue(secondNudge.contains("task-2"), "the never-named ticket must be named: " + secondNudge);
|
|
assertTrue(loop.isActive(), "the schedule must stay active after nudging the fresh ticket");
|
|
}
|
|
|
|
// --- CB-582: bridge_ask question-open nudges -------------------------------------------------
|
|
|
|
@Test
|
|
void onQuestionOpenedWithNoKnownLeadNeverStartsASchedule() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = new ReplyPushLoop(new PrimaryRegistry(null), agents, inbox, scheduler, 5, 50);
|
|
|
|
loop.onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
|
|
assertFalse(loop.isActive(), "no lead known means nothing to nudge yet");
|
|
Thread.sleep(150);
|
|
assertEquals(0, rec.sendCount(), "must not nudge when no lead is known to be waiting");
|
|
}
|
|
|
|
@Test
|
|
void decideQuestionsWithNothingPendingIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop().decide(PRIMARY, 0, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void decideQuestionsAtCapIsStop() {
|
|
agents = agentWithStatus("idle");
|
|
var loop = loop(2, 100_000);
|
|
loop.onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 0, 0, 2));
|
|
}
|
|
|
|
@Test
|
|
void decideQuestionsUnderCapWithInjectableLeadIsInject() {
|
|
agents = agentWithStatus("idle");
|
|
var loop = loop(5, 100_000);
|
|
loop.onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0, 0));
|
|
}
|
|
|
|
@Test
|
|
void onQuestionOpenedCausesExactlyOneNudgeNamingTheTicketAndTurnId() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
|
|
loop(1, 50).onQuestionOpened("task-1", WORKER, "term_worker#1", "which config file?");
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one question nudge should have been sent");
|
|
assertEquals(1, rec.sendCount());
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("task-1"), "nudge should name the ticket: " + nudge);
|
|
assertTrue(nudge.contains("term_worker#1"), "nudge should name the turnId: " + nudge);
|
|
assertTrue(nudge.contains("bridge_send(turnId="), "nudge should name the exact answer call: " + nudge);
|
|
assertTrue(nudge.contains("which config file?"), "nudge should include the question text: " + nudge);
|
|
}
|
|
|
|
@Test
|
|
void questionClosedPreventsFurtherNudging() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = loop(1, 100);
|
|
|
|
loop.onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
loop.questionClosed("term_worker#1"); // answered/lapsed before the first tick fired
|
|
|
|
Thread.sleep(300); // let the scheduled tick run
|
|
assertEquals(0, rec.sendCount(), "an already-closed question must never be nudged");
|
|
}
|
|
|
|
@Test
|
|
void questionNudgesSendUpToCapThenStop() throws Exception {
|
|
int cap = 2;
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
rec.sendLatch = new CountDownLatch(cap);
|
|
|
|
loop(cap, 50).onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
|
|
assertTrue(rec.sendLatch.await(5, TimeUnit.SECONDS), cap + " question nudges should have fired");
|
|
Thread.sleep(300);
|
|
assertEquals(cap, rec.sendCount(), "exactly " + cap + " question nudges (cap=" + cap + ")");
|
|
}
|
|
|
|
@Test
|
|
void questionAndTicketForTheSameLeadCoalesceIntoOneSend() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
var loop = loop(1, 300); // backoff wide enough that both entry points land before the first tick
|
|
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
loop.onQuestionOpened("task-2", WORKER, "term_worker#1", "which config?");
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one combined nudge should have been sent");
|
|
Thread.sleep(300);
|
|
assertEquals(1, rec.sendCount(),
|
|
"a ticket and a question for the same lead must coalesce onto ONE schedule");
|
|
String nudge = rec.sentParams().getFirst().getValue().toString();
|
|
assertTrue(nudge.contains("task-1"), "the combined nudge must still mention the ticket: " + nudge);
|
|
assertTrue(nudge.contains("term_worker#1"), "the combined nudge must still mention the question: " + nudge);
|
|
}
|
|
|
|
@Test
|
|
void oneExhaustedQuestionSourceDoesNotBlockANudgeForTheOtherSources() {
|
|
// Mirrors oneExhaustedSourceDoesNotBlockANudgeForTheOtherSource for the question source:
|
|
// the question source is at its cap (2/2), but the ticket source has never been nudged
|
|
// (0/2) — decide() must still INJECT so the ticket is not stranded.
|
|
agents = agentWithStatus("idle");
|
|
var loop = loop(2, 100_000);
|
|
loop.onQuestionOpened("task-1", WORKER, "term_worker#1", "which config?");
|
|
loop.onTicketTerminal("task-2", WORKER, false);
|
|
|
|
assertEquals(ReplyPushLoop.Action.INJECT, loop.decide(PRIMARY, 0, 0, 2),
|
|
"the question source is exhausted (2/2), but the ticket source has never been "
|
|
+ "nudged (0/2) — the lead must still be injected so the ticket is not lost");
|
|
}
|
|
|
|
@Test
|
|
void questionNudgeFormatIsCorrect() {
|
|
String single = ReplyPushLoop.QUESTION_NUDGE_FORMAT.formatted(
|
|
WORKER, "task-1", "term_worker#1", "which config?");
|
|
assertTrue(single.contains("Worker term_worker"));
|
|
assertTrue(single.contains("bridge_send(turnId=\"term_worker#1\""));
|
|
assertTrue(single.contains("which config?"));
|
|
|
|
String multi = ReplyPushLoop.QUESTIONS_NUDGE_FORMAT.formatted(2, "task-1 (turnId=t1), task-2 (turnId=t2)");
|
|
assertTrue(multi.contains("2 workers"));
|
|
assertTrue(multi.contains("bridge_poll(ticket=...)"));
|
|
}
|
|
|
|
// --- metrics (CB-512) ----------------------------------------------------------------------
|
|
|
|
@Test
|
|
void successfulNudgeIncrementsDelivered() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
Metrics metrics = new Metrics();
|
|
|
|
loop(1, 50, metrics).onReplyQueued(WORKER);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
|
"one nudge (1 agent.prompt call) should have been sent");
|
|
// The delivered count is bumped on the scheduler thread right after the send that releases
|
|
// the latch — settle briefly so the counter is published before we read it.
|
|
Thread.sleep(200);
|
|
assertEquals(1, metrics.count(BridgedMetrics.PUSH_NUDGES, "outcome", "delivered"),
|
|
"a successfully sent nudge must count as delivered");
|
|
}
|
|
|
|
@Test
|
|
void reminderCapIncrementsExhausted() {
|
|
agents = agentWithStatus("idle");
|
|
inbox.publish(WORKER, "m1", "hello");
|
|
Metrics metrics = new Metrics();
|
|
var loop = loop(2, 100, metrics);
|
|
loop.onReplyQueued(WORKER);
|
|
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 2, 0));
|
|
|
|
assertEquals(1, metrics.count(BridgedMetrics.PUSH_NUDGES, "outcome", "exhausted"),
|
|
"hitting the reminder cap must count as exhausted");
|
|
assertEquals(0, metrics.count(BridgedMetrics.PUSH_NUDGES, "outcome", "delivered"));
|
|
}
|
|
|
|
@Test
|
|
void successfulTicketNudgeIncrementsDelivered() throws Exception {
|
|
var rec = recordingClient();
|
|
agents = new AgentControl(rec);
|
|
Metrics metrics = new Metrics();
|
|
|
|
loop(1, 50, metrics).onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS), "one ticket nudge should have been sent");
|
|
Thread.sleep(200);
|
|
assertEquals(1, metrics.count(BridgedMetrics.PUSH_NUDGES, "outcome", "delivered"),
|
|
"a successfully sent ticket nudge must count as delivered, same metric as CB-307");
|
|
}
|
|
|
|
@Test
|
|
void ticketReminderCapIncrementsExhausted() {
|
|
agents = agentWithStatus("idle");
|
|
Metrics metrics = new Metrics();
|
|
var loop = loop(2, 100_000, metrics);
|
|
loop.onTicketTerminal("task-1", WORKER, false);
|
|
|
|
assertEquals(ReplyPushLoop.Action.STOP, loop.decide(PRIMARY, 0, 2));
|
|
|
|
assertEquals(1, metrics.count(BridgedMetrics.PUSH_NUDGES, "outcome", "exhausted"),
|
|
"hitting the ticket reminder cap must count as exhausted");
|
|
}
|
|
|
|
// --- helpers -------------------------------------------------------------------------------
|
|
|
|
private ReplyPushLoop loop() {
|
|
return loop(5, 100);
|
|
}
|
|
|
|
private ReplyPushLoop loop(int maxReminders, long backoffMs) {
|
|
return new ReplyPushLoop(registry, agents, inbox, scheduler, maxReminders, backoffMs);
|
|
}
|
|
|
|
private ReplyPushLoop loop(int maxReminders, long backoffMs, Metrics metrics) {
|
|
return new ReplyPushLoop(registry, agents, inbox, scheduler, maxReminders, backoffMs, metrics);
|
|
}
|
|
|
|
private static AgentControl agentWithStatus(String status) {
|
|
return new AgentControl(new FakeHerdrClient(status));
|
|
}
|
|
|
|
/** Non-recording (single-threaded) fake — safe for decide() tests. */
|
|
private static final class FakeHerdrClient implements HerdrClient {
|
|
private final String agentStatus;
|
|
|
|
FakeHerdrClient(String agentStatus) {
|
|
this.agentStatus = agentStatus;
|
|
}
|
|
|
|
@Override
|
|
public JsonNode call(String method, Object params) {
|
|
if ("agent.get".equals(method)) {
|
|
return MAPPER.createObjectNode()
|
|
.set("agent", MAPPER.createObjectNode()
|
|
.put("terminal_id", PRIMARY)
|
|
.put("agent_status", agentStatus));
|
|
}
|
|
return MAPPER.createObjectNode();
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thread-safe recording fake that counts agent.prompt calls (protocol 19: one nudge = one
|
|
* prompt). Uses synchronized access so the scheduler thread and test thread never race.
|
|
*/
|
|
private static final class RecordingHerdrClient implements HerdrClient {
|
|
private final List<Map.Entry<String, Object>> calls =
|
|
Collections.synchronizedList(new ArrayList<>());
|
|
volatile CountDownLatch sendLatch = new CountDownLatch(1);
|
|
|
|
@Override
|
|
public JsonNode call(String method, Object params) {
|
|
if ("agent.get".equals(method)) {
|
|
return MAPPER.createObjectNode()
|
|
.set("agent", MAPPER.createObjectNode()
|
|
.put("terminal_id", PRIMARY)
|
|
.put("agent_status", "idle")); // recording double is always injectable
|
|
}
|
|
if ("agent.prompt".equals(method)) {
|
|
calls.add(Map.entry(method, params));
|
|
sendLatch.countDown();
|
|
}
|
|
return MAPPER.createObjectNode();
|
|
}
|
|
|
|
long sendCount() {
|
|
return calls.size();
|
|
}
|
|
|
|
List<Map.Entry<String, Object>> sentParams() {
|
|
return List.copyOf(calls);
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
}
|
|
|
|
private static RecordingHerdrClient recordingClient() {
|
|
return new RecordingHerdrClient();
|
|
}
|
|
|
|
/**
|
|
* Thread-safe fake that reports {@code working} (not injectable) for its first
|
|
* {@code busyChecks} status calls, then {@code idle} forever after — used to prove work queued
|
|
* while the lead is busy is deferred, not dropped, once it becomes injectable.
|
|
*/
|
|
private static final class BusyThenIdleHerdrClient implements HerdrClient {
|
|
private final List<Map.Entry<String, Object>> calls =
|
|
Collections.synchronizedList(new ArrayList<>());
|
|
private final AtomicInteger statusChecks = new AtomicInteger();
|
|
private final int busyChecks;
|
|
volatile CountDownLatch sendLatch = new CountDownLatch(1);
|
|
|
|
BusyThenIdleHerdrClient(int busyChecks) {
|
|
this.busyChecks = busyChecks;
|
|
}
|
|
|
|
@Override
|
|
public JsonNode call(String method, Object params) {
|
|
if ("agent.get".equals(method)) {
|
|
String status = statusChecks.getAndIncrement() < busyChecks ? "working" : "idle";
|
|
return MAPPER.createObjectNode()
|
|
.set("agent", MAPPER.createObjectNode()
|
|
.put("terminal_id", PRIMARY)
|
|
.put("agent_status", status));
|
|
}
|
|
if ("agent.prompt".equals(method)) {
|
|
calls.add(Map.entry(method, params));
|
|
sendLatch.countDown();
|
|
}
|
|
return MAPPER.createObjectNode();
|
|
}
|
|
|
|
long sendCount() {
|
|
return calls.size();
|
|
}
|
|
|
|
List<Map.Entry<String, Object>> sentParams() {
|
|
return List.copyOf(calls);
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
}
|
|
}
|