CB-113: reliable worker readiness gate + submission nudge
Delegating right after spawn failed: herdr reports 'idle' during the worker's
boot, so the injector delivered into a not-ready TUI (paste lost) and wedged the
worker. Two fixes:
- Readiness gate: a worker is 'available' only once its Claude connects the bridge
MCP (the daemon observes it via peer-PID->terminal). WorkerPresence tracks it; the
injector holds the first delivery until present, so it never pastes into the boot
window. Exposed as 'ready' on GET /sessions/{id}/status.
- Submission nudge: the Enter accompanying a delivery can race the paste (esp. right
as the TUI becomes ready), leaving text unsubmitted. While a delivered message
stays idle (not picked up), the injector re-sends Enter each poll until the worker
starts (WORKING) or the grace expires.
Validated live: spawn + immediate delegate now holds during boot (ready=false),
delivers on MCP-connect, re-nudges Enter, worker replies. 102 tests green.
This commit is contained in:
@@ -51,6 +51,47 @@ class InjectorTest {
|
||||
assertEquals(List.of("hello"), sent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdsDeliveryUntilTheWorkerIsAvailable() {
|
||||
// CB-113: idle alone is not enough — hold until the worker's MCP is connected (ready).
|
||||
java.util.Set<String> ready = new java.util.HashSet<>();
|
||||
Injector inj = new Injector(new AgentControl(herdr), TurnListener.NOOP, ready::contains);
|
||||
inj.enqueue(T, "task");
|
||||
|
||||
inj.onStatus(T, AgentStatus.IDLE); // idle but not yet available → held out of the boot window
|
||||
assertEquals(List.of(), sent(), "must not deliver into a not-yet-available worker");
|
||||
|
||||
ready.add(T); // the worker's Claude connects the bridge MCP
|
||||
inj.onStatus(T, AgentStatus.IDLE);
|
||||
assertEquals(List.of("task"), sent(), "delivers once the worker is available");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private long enterKeystrokes() {
|
||||
return herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("agent.send"))
|
||||
.filter(c -> "\r".equals(((Map<String, Object>) c.params()).get("text")))
|
||||
.count();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resubmitsEnterWhenADeliveredMessageIsNotPickedUp() {
|
||||
// CB-113: the Enter at delivery can race the paste; while the worker stays idle (not picked
|
||||
// up), the injector re-nudges Enter so the pending paste submits.
|
||||
injector.enqueue(T, "task");
|
||||
injector.onStatus(T, AgentStatus.IDLE); // deliver: paste + one Enter
|
||||
long afterDeliver = enterKeystrokes();
|
||||
|
||||
injector.onStatus(T, AgentStatus.IDLE); // still idle → re-nudge Enter
|
||||
injector.onStatus(T, AgentStatus.IDLE); // and again
|
||||
assertTrue(enterKeystrokes() > afterDeliver, "an unpicked-up delivery re-nudges Enter");
|
||||
|
||||
injector.onStatus(T, AgentStatus.WORKING); // worker finally starts
|
||||
long atPickup = enterKeystrokes();
|
||||
injector.onStatus(T, AgentStatus.WORKING);
|
||||
assertEquals(atPickup, enterKeystrokes(), "no more nudges once the worker has picked up");
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdsWhileWorkingThenDeliversOnIdle() {
|
||||
injector.enqueue(T, "later");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.ltms.bridged.inject;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The CB-113 worker-availability registry. */
|
||||
class WorkerPresenceTest {
|
||||
|
||||
@Test
|
||||
void tracksPresenceAndForgets() {
|
||||
WorkerPresence p = new WorkerPresence();
|
||||
assertFalse(p.isPresent("term_a"), "unseen worker is not available");
|
||||
|
||||
p.markPresent("term_a");
|
||||
assertTrue(p.isPresent("term_a"), "a worker seen on the MCP is available");
|
||||
|
||||
p.forget("term_a");
|
||||
assertFalse(p.isPresent("term_a"), "a torn-down worker is no longer available");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrBlankMarkIsANoOp() {
|
||||
WorkerPresence p = new WorkerPresence();
|
||||
assertDoesNotThrow(() -> {
|
||||
p.markPresent(null);
|
||||
p.markPresent(" ");
|
||||
});
|
||||
assertFalse(p.isPresent(""), "blank/null contacts (the primary) are never present");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import dev.ltms.bridged.herdr.FakeHerdr;
|
||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.inject.Injector;
|
||||
import dev.ltms.bridged.inject.StatusPoller;
|
||||
import dev.ltms.bridged.inject.WorkerPresence;
|
||||
import dev.ltms.bridged.msg.MessageService;
|
||||
import dev.ltms.bridged.msg.Rendezvous;
|
||||
import dev.ltms.bridged.worker.WorkerService;
|
||||
@@ -36,6 +37,7 @@ class BridgedAppTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final WorkerPresence presence = new WorkerPresence();
|
||||
private Javalin app;
|
||||
private StatusPoller poller;
|
||||
|
||||
@@ -63,7 +65,8 @@ class BridgedAppTest {
|
||||
poller.start();
|
||||
Rendezvous rendezvous = new Rendezvous();
|
||||
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||
app = new BridgedApp(herdr, workers, messages, rendezvous, null).build().start("127.0.0.1", 0);
|
||||
app = new BridgedApp(herdr, workers, messages, rendezvous, this.presence, null)
|
||||
.build().start("127.0.0.1", 0);
|
||||
return app.port();
|
||||
}
|
||||
|
||||
@@ -380,6 +383,16 @@ class BridgedAppTest {
|
||||
assertEquals("blocked", mapper.readTree(res.body()).get("status").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionStatusReportsReadinessFromMcpPresence() throws Exception {
|
||||
int port = start(new FakeHerdr(), "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||
// Not yet seen on the bridge MCP → not ready.
|
||||
assertFalse(mapper.readTree(req(port, "GET", "/sessions/term_a/status").body()).get("ready").asBoolean());
|
||||
// Worker connects its MCP client → available.
|
||||
presence.markPresent("term_a");
|
||||
assertTrue(mapper.readTree(req(port, "GET", "/sessions/term_a/status").body()).get("ready").asBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopWorkerInPanePlacementClosesOnlyThePane() throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
|
||||
Reference in New Issue
Block a user