CB-205: coalesce duplicate bridge_ask calls onto one turn
A worker's tool execution is single-threaded, so two bridge_ask calls from the same session can only be a transport retry — yet openAsk minted a fresh turnId each time and both raced to resolve the primary's single forward waiter, the loser returning NO_WAITER and leaving a dangling turn (observed as turnId #2 in the live e2e). Now openAsk is idempotent per session: a second open ask coalesces onto the existing turnId + answer future (fresh=false), and only the fresh owner surfaces the question and tears the turn down. closeAsk clears the per-session index (conditional by value) so a later ask reopens fresh. Implemented by an off-subscription worker delegated over the bridge; reviewed and validated on the primary (IDE-clean, mvn clean install 149 green, +2 tests: concurrent double-ask coalescing + post-close reopen).
This commit is contained in:
@@ -145,6 +145,44 @@ class MessageServiceTest {
|
||||
assertEquals("done", done.text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateAsksFromTheSameSessionCoalesceToOneTurn() throws Exception {
|
||||
CompletableFuture<MessageService.Reply> send = sendAsync();
|
||||
awaitWaiting();
|
||||
injector.onStatus(T, AgentStatus.IDLE); // deliver
|
||||
injector.onStatus(T, AgentStatus.WORKING); // worker picks it up, then pauses to ask
|
||||
|
||||
// A transport retry: two concurrent bridge_ask calls from the same worker session.
|
||||
CompletableFuture<MessageService.AskResult> ask1 =
|
||||
CompletableFuture.supplyAsync(() -> messages.ask(T, "which config file?", 5000));
|
||||
CompletableFuture<MessageService.AskResult> ask2 =
|
||||
CompletableFuture.supplyAsync(() -> messages.ask(T, "which config file?", 5000));
|
||||
|
||||
// The primary's single blocked send surfaces exactly ONE question (one turnId).
|
||||
MessageService.Reply q = send.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(MessageService.Outcome.QUESTION, q.outcome());
|
||||
assertEquals("which config file?", q.text());
|
||||
assertNotNull(q.turnId(), "only one turnId should be minted");
|
||||
|
||||
// The primary answers that one turnId; both asks unblock with the same answer.
|
||||
CompletableFuture<MessageService.Reply> answer =
|
||||
CompletableFuture.supplyAsync(() -> messages.answer(q.turnId(), "config.yaml", 5000));
|
||||
|
||||
MessageService.AskResult a1 = ask1.get(5, TimeUnit.SECONDS);
|
||||
MessageService.AskResult a2 = ask2.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(MessageService.AskOutcome.ANSWERED, a1.outcome());
|
||||
assertEquals("config.yaml", a1.answer());
|
||||
assertEquals(MessageService.AskOutcome.ANSWERED, a2.outcome());
|
||||
assertEquals("config.yaml", a2.answer());
|
||||
|
||||
// The resumed worker finishes with a structured reply, resolving the answering send.
|
||||
awaitWaiting();
|
||||
assertTrue(rendezvous.resolve(T, "done"), "the worker's final reply resolves the answering send");
|
||||
MessageService.Reply done = answer.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(MessageService.Outcome.REPLIED, done.outcome());
|
||||
assertEquals("done", done.text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void askWithNoOpenDelegationReturnsNoWaiter() {
|
||||
MessageService.AskResult r = messages.ask(T, "anyone listening?", 500);
|
||||
|
||||
@@ -21,12 +21,23 @@ class RendezvousTest {
|
||||
private final Rendezvous rendezvous = new Rendezvous();
|
||||
|
||||
@Test
|
||||
void openAskMintsAUniqueTurnScopedToItsSession() {
|
||||
void openAskMintsAUniqueTurnScopedToItsSessionAndCoalescesDuplicates() {
|
||||
Rendezvous.AskTicket t1 = rendezvous.openAsk(W);
|
||||
Rendezvous.AskTicket t2 = rendezvous.openAsk(W);
|
||||
assertNotEquals(t1.turnId(), t2.turnId(), "each ask gets its own turnId");
|
||||
assertEquals(t1.turnId(), t2.turnId(), "duplicate asks from the same session coalesce onto one turn");
|
||||
assertTrue(t1.fresh(), "the first ask freshly opens the turn");
|
||||
assertFalse(t2.fresh(), "the coalesced ask rides the existing turn");
|
||||
assertTrue(t1.turnId().startsWith(W + "#"), "the turnId is scoped to the worker session");
|
||||
assertEquals(W, rendezvous.askSession(t1.turnId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openAskAfterCloseMintsANewTurn() {
|
||||
Rendezvous.AskTicket t1 = rendezvous.openAsk(W);
|
||||
rendezvous.closeAsk(t1.turnId());
|
||||
Rendezvous.AskTicket t2 = rendezvous.openAsk(W);
|
||||
assertNotEquals(t1.turnId(), t2.turnId(), "after closing, a new ask gets a fresh turnId");
|
||||
assertTrue(t2.fresh(), "the reopened ask is fresh");
|
||||
assertEquals(W, rendezvous.askSession(t2.turnId()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user