Files
fleetd/bridged/src/test/java/dev/ltms/bridged/session/SessionReaperTest.java
T
Dai Ha 4875127daa CB-557: adopt the member taxonomy in the API surface
Every spawned peer is now a member with a role, and the role travels with it
from the spawn call to the roster.

MCP:
  bridge_spawn gains role: architect | dev | reviewer (default dev). An
    unknown role is refused with the valid spellings in the message.
  bridge_list returns "members" instead of "workers"; each row carries both
    role (what it is for) and profile (which backend it runs on).
  The spawn result echoes the role back, so a spawn that fell back to dev is
    visible rather than silent.

REST:
  GET/POST /members and DELETE /members/{paneId} replace /workers.
  POST accepts role= as a query param or a body field; an unknown role is 400.

Code:
  dev.ltms.bridged.worker package -> dev.ltms.bridged.member
  WorkerSession   -> MemberSession, plus a MemberRole role component
  WorkerPresence  -> MemberPresence
  SessionManager.acquire gains a role parameter; the existing overloads keep
    working and default to DEV, which is exactly what "worker" used to mean.

ClaudeCodeLauncher and OpenCodeLauncher keep their names on purpose — they
are named after the backend, not the role.

Not done here: the launch charter is still one string for every role, so a
member is told its role by nobody yet. That is the next ticket.

mvn clean install: 583 tests, 0 failures, 0 errors, BUILD SUCCESS.
2026-08-14 07:08:26 +02:00

132 lines
5.7 KiB
Java

package dev.ltms.bridged.session;
import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.guard.SubscriptionGuard;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.member.ClaudeCodeLauncher;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Wrapper-behaviour tests for {@link SessionReaper} (the thread lifecycle). The TTL policy itself
* (SessionManager.reapIdle) is covered by SessionManagerTest and is deliberately not retested here.
* A real SessionManager is used, built the same way the rest of this package's tests do.
*/
class SessionReaperTest {
private static final long IDLE_TTL_SECONDS = 60;
private static final long SHORT_INTERVAL_MILLIS = 20;
private static ClaudeCodeLauncher launcher() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"worker: {profile} #{n}", null, null, null);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
}
/** A manager on the fake worktree seam — these tests never touch a real git checkout. */
private static SessionManager sessionManager() {
return new SessionManager(launcher(), new FakeWorktrees());
}
private static SessionReaper reaper() {
return new SessionReaper(sessionManager(), IDLE_TTL_SECONDS, SHORT_INTERVAL_MILLIS);
}
/**
* A double {@code start()} must leave exactly one live loop, so a single {@code stop()} still
* silences it. Asserting only "no throw" would pass against a reaper that never started at
* all — and against one that started twice — which is the entire point of the guard.
*/
@Test
void startIsIdempotent() throws InterruptedException {
AtomicLong ticks = new AtomicLong();
SessionReaper reaper = new SessionReaper(countingManager(ticks),
IDLE_TTL_SECONDS, SHORT_INTERVAL_MILLIS);
assertDoesNotThrow(() -> {
reaper.start();
reaper.start();
}, "a second start() must not throw");
assertTrue(awaitTicks(ticks, 2), "the loop is running after a double start()");
// One stop() for two start() calls: if the second start had spawned its own loop, a
// surviving thread would keep the counter climbing past this point.
reaper.stop();
Thread.sleep(SHORT_INTERVAL_MILLIS * 4);
long settled = ticks.get();
Thread.sleep(SHORT_INTERVAL_MILLIS * 4);
assertEquals(settled, ticks.get(),
"a single stop() must silence the reaper even after two start() calls");
}
/** A manager whose clock counts reads — every {@code reapIdle} reads it exactly once. */
private static SessionManager countingManager(AtomicLong ticks) {
return new SessionManager(launcher(), new FakeWorktrees(), () -> {
ticks.incrementAndGet();
return System.nanoTime();
});
}
/** Bounded wait for the loop to tick at least {@code n} times; avoids fixed-sleep flakiness. */
private static boolean awaitTicks(AtomicLong ticks, long n) throws InterruptedException {
long deadline = System.currentTimeMillis() + 2000;
while (ticks.get() < n && System.currentTimeMillis() < deadline) {
Thread.sleep(10);
}
return ticks.get() >= n;
}
@Test
void stopIsIdempotentAndSafeBeforeStart() {
SessionReaper reaper = reaper();
assertDoesNotThrow(reaper::stop, "stop() before start() must not throw");
assertDoesNotThrow(reaper::stop, "a second stop() must not throw");
}
/**
* The loop must actually iterate, and {@code stop()} must actually end it.
*
* <p>Observed through an injected clock rather than by sleeping and hoping: every
* {@code reapIdle} call reads {@code nowNanos} exactly once, so the tick count <em>is</em> the
* iteration count. Asserting merely "nothing threw" would pass even if {@code start()} were a
* no-op, which is the whole behaviour under test.
*/
@Test
void theLoopRunsRepeatedlyAndStopEndsIt() throws InterruptedException {
AtomicLong ticks = new AtomicLong();
SessionReaper reaper = new SessionReaper(countingManager(ticks),
IDLE_TTL_SECONDS, SHORT_INTERVAL_MILLIS);
reaper.start();
// Bounded wait rather than a fixed sleep + exact count: proves repetition without pinning
// a timing-derived number that would flake on a loaded machine.
boolean iterated = awaitTicks(ticks, 2);
long whileRunning = ticks.get();
reaper.stop();
assertTrue(iterated,
"the reaper loop must iterate repeatedly; observed " + whileRunning + " tick(s)");
// After stop() the loop must go quiet. Allow one in-flight iteration to finish, then
// confirm the count has stopped advancing.
Thread.sleep(SHORT_INTERVAL_MILLIS * 4);
long settled = ticks.get();
Thread.sleep(SHORT_INTERVAL_MILLIS * 4);
assertEquals(settled, ticks.get(), "stop() must end the loop, not just flag it");
}
}