CB-114: readiness-gate timeout + presence cleanup (delegated review findings)
An off-sub worker's review of CB-113 (delegated through the bridge) surfaced two real gaps in the readiness gate: - A worker that herdr reports idle but whose Claude never connects the bridge MCP (crashed during boot, or wedged on a startup prompt) left its message queued forever: ready.test() never passed, the target was polled indefinitely, and the caller's future never completed (async waiter hung for the full 30-min window). Injector now counts injectable-but-not-ready samples and, after a ~60s grace (READINESS_GRACE_POLLS, deliberately longer than the UNKNOWN stall grace since a first boot is slower than an in-turn blip), fails the queued messages, fires onTurnFailed so blocking/async waiters resolve WORKER_FAILED, and reclaims the target. Mirrors the CB-109 UNKNOWN-stall path. - WorkerPresence.forget had no caller, so a worker's readiness lingered past its life. Injector now clears presence via a forget callback on drop() (pane crash) and on the readiness timeout. +3 InjectorTest cases (never-ready failure, ready-within-grace delivery, drop clears presence). 105 tests green.
This commit is contained in:
@@ -308,6 +308,58 @@ class InjectorTest {
|
||||
assertEquals(List.of(T), cap.failed, "a delivery that vanishes before pickup still fails its send");
|
||||
}
|
||||
|
||||
// ~60s of idle-but-not-ready at the 250ms prod poll interval; enough to trip the readiness grace.
|
||||
private static final int READINESS_SAMPLES = 245;
|
||||
|
||||
@Test
|
||||
void failsAQueuedMessageWhoseWorkerNeverBecomesReady() {
|
||||
// CB-114: herdr keeps reporting the worker idle, but its Claude never connects the bridge MCP,
|
||||
// so the readiness gate never opens. The message must not be held (and the target polled)
|
||||
// forever — after the grace it fails, the caller unblocks via the worker-failure path, the
|
||||
// target is reclaimed, and the never-set presence is cleared.
|
||||
Captor cap = new Captor();
|
||||
List<String> forgotten = new ArrayList<>();
|
||||
Injector inj = new Injector(new AgentControl(herdr), cap, _ -> false, forgotten::add);
|
||||
CompletableFuture<Void> f = inj.enqueue(T, "task");
|
||||
|
||||
for (int i = 0; i < READINESS_SAMPLES; i++) inj.onStatus(T, AgentStatus.IDLE);
|
||||
|
||||
assertEquals(List.of(), sent(), "a never-ready worker is never delivered to");
|
||||
assertTrue(f.isCompletedExceptionally(), "the caller's future fails instead of hanging forever");
|
||||
assertEquals(List.of(T), cap.failed, "the awaiting send resolves through the worker-failure path");
|
||||
assertEquals(List.of(), cap.completed, "a never-ready worker is a failure, not a completion");
|
||||
assertEquals(List.of(T), forgotten, "the never-ready worker's presence is cleared");
|
||||
assertTrue(inj.activeTargets().isEmpty(), "the target is reclaimed, not polled forever");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWorkerThatBecomesReadyWithinTheGraceIsDeliveredNormally() {
|
||||
// The readiness grace must not fail a worker that is merely slow to boot: once it becomes
|
||||
// available before the grace elapses, delivery proceeds as usual (the counter resets).
|
||||
Set<String> ready = new java.util.HashSet<>();
|
||||
Injector inj = new Injector(new AgentControl(herdr), TurnListener.NOOP, ready::contains, _ -> {
|
||||
});
|
||||
inj.enqueue(T, "task");
|
||||
|
||||
for (int i = 0; i < 100; i++) inj.onStatus(T, AgentStatus.IDLE); // still booting, well under grace
|
||||
assertEquals(List.of(), sent());
|
||||
|
||||
ready.add(T); // MCP connects before the grace elapses
|
||||
inj.onStatus(T, AgentStatus.IDLE);
|
||||
assertEquals(List.of("task"), sent(), "a worker that connects within the grace is delivered to");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropClearsWorkerPresence() {
|
||||
// CB-114 (finding #1): a vanished worker's readiness must be forgotten so a stale entry cannot
|
||||
// linger past the worker's life (WorkerPresence.forget had no caller before this).
|
||||
List<String> forgotten = new ArrayList<>();
|
||||
Injector inj = new Injector(new AgentControl(herdr), TurnListener.NOOP, _ -> true, forgotten::add);
|
||||
inj.enqueue(T, "orphan");
|
||||
inj.drop(T, new HerdrException("worker gone", "pane_not_found", null));
|
||||
assertEquals(List.of(T), forgotten, "drop clears the gone worker's presence");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollerDeliversToAnIdleWorker() throws Exception {
|
||||
// End-to-end through the poller: idle worker → message delivered without manual onStatus.
|
||||
|
||||
Reference in New Issue
Block a user