diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java index 54f7803..8ec185c 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java @@ -349,6 +349,51 @@ class SessionManagerTest { "onDelivered must refuse a backend-error member"); } + @Test + void losingCompletionDoesNotReleaseOrClearABackendErrorMember() { + assertLosingCompletionLeavesBackendErrorIntact(1, false, + "a stale completion must not release a backend-error member at the context cap"); + assertLosingCompletionLeavesBackendErrorIntact(0, true, + "a stale completion must not clear the context of a backend-error member"); + } + + private void assertLosingCompletionLeavesBackendErrorIntact(int contextCap, boolean clearAfterTurn, + String protectedAction) { + FakeHerdr herdr = new FakeHerdr(); + FleetConfig.Profile cfg = new FleetConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + List.of("ccs", "ltms-local"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, null, null); + ClaudeCodeLauncher delegate = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + ClearContextSpyLauncher launcher = new ClearContextSpyLauncher(delegate); + java.util.concurrent.atomic.AtomicReference manager = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference terminal = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicBoolean armed = new java.util.concurrent.atomic.AtomicBoolean(); + SessionManager sessions = new SessionManager(launcher, new RecordingWorktrees(), () -> { + if (armed.compareAndSet(true, false)) { + manager.get().onBackendError(terminal.get(), "backend exited during completion"); + } + return 1; + }, contextCap, clearAfterTurn); + manager.set(sessions); + MemberSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary"); + terminal.set(session.terminalId()); + sessions.asPresence().markPresent(session.terminalId()); + sessions.onDelivered(session.terminalId(), TestTurnTokens.inert(session.terminalId())); + armed.set(true); + + assertFalse(sessions.onTurnCompleteWithPostAction(session.terminalId()), + "the completion CAS loses after the injected backend error"); + + assertTrue(sessions.get(session.paneId()).isPresent(), protectedAction); + MemberSession updated = sessions.get(session.paneId()).orElseThrow(); + assertEquals(MemberSession.State.BACKEND_ERROR, updated.state()); + assertEquals("backend exited during completion", updated.failureReason()); + assertFalse(herdr.called("pane.close"), protectedAction); + assertEquals(0, launcher.clearContextCalls(), protectedAction); + } + @Test void backendErrorForUnknownTargetIsWarnedAndDoesNotCreateASession() { LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); @@ -1104,6 +1149,76 @@ class SessionManagerTest { } } + /** Delegates all launcher work while recording context clears. */ + private static final class ClearContextSpyLauncher implements PeerLauncher { + private final PeerLauncher delegate; + private int clearContextCalls; + + ClearContextSpyLauncher(PeerLauncher delegate) { + this.delegate = delegate; + } + + @Override + public Set capabilities() { + return delegate.capabilities(); + } + + @Override + public Set capabilitiesFor(String profileName) { + return delegate.capabilitiesFor(profileName); + } + + @Override + public PeerHandle spawn(SpawnRequest req) { + return delegate.spawn(req); + } + + @Override + public Set profiles() { + return delegate.profiles(); + } + + @Override + public String defaultProfile() { + return delegate.defaultProfile(); + } + + @Override + public String effectiveCwd(SpawnRequest req) { + return delegate.effectiveCwd(req); + } + + @Override + public List parityOverlay(String profileName) { + return delegate.parityOverlay(profileName); + } + + @Override + public List list() { + return delegate.list(); + } + + @Override + public int reapOrphanWorkers() { + return delegate.reapOrphanWorkers(); + } + + @Override + public void stop(String id) { + delegate.stop(id); + } + + @Override + public boolean clearContext(String id) { + clearContextCalls++; + return delegate.clearContext(id); + } + + int clearContextCalls() { + return clearContextCalls; + } + } + @Test void acquireRecordsTheAgentSessionIdFromTheHandle() { FakeHerdr herdr = new FakeHerdr();