diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index a4fd26b..980d885 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -697,7 +697,20 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { if (paneId == null) { throw new IllegalStateException("pane.split returned no pane — cannot start a peer"); } - Agent peer = startUniquelyNamed(cfg, argv, paneId).agent(); + Agent peer; + try { + peer = startUniquelyNamed(cfg, argv, paneId).agent(); + } catch (RuntimeException e) { + // The peer never started — don't leave the pane we just created orphaned. + // Best-effort cleanup; never let it mask the real spawn failure. + try { + stop(paneId); + } catch (RuntimeException cleanup) { + log.warn("failed to close orphaned pane {} after spawn error: {}", + paneId, cleanup.getMessage()); + } + throw e; + } log.info("{} started pane={} terminal={}", namePrefix, peer.paneId(), peer.terminalId()); return peer; } @@ -996,7 +1009,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * that gap: it stops waiting immediately (never burns the rest of the timeout), runs the same * teardown the timeout path below runs, and throws with a message that says the backend exited * rather than that the pane was slow. Any other {@link HerdrException} still propagates - * unchanged — this gate does not know how to recover from it. + * unchanged — this gate does not interpret or recover from it, but it still closes the pane + * it opened before handing the exception to its caller. */ private void waitUntilInjectableOrThrow(String paneId) { long start = nowMillis.getAsLong(); @@ -1010,7 +1024,15 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { if (isAlreadyGone(e)) { failFastOnGoneBackend(paneId, e, nowMillis.getAsLong() - start); } - throw e; // any other herdr failure is not ours to interpret — let it propagate + // This gate must not interpret an unrelated herdr error, but the caller does not + // receive paneId when spawn throws. Close the pane here before propagating e unchanged. + try { + stop(paneId); + } catch (RuntimeException cleanup) { + log.warn("failed to close orphaned pane {} after readiness-gate error: {}", + paneId, cleanup.getMessage()); + } + throw e; } lastStatus = sample.status(); if (lastStatus.injectable() || refinedInjectable(paneId, sample)) { diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java index a3f562b..3ba4441 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java @@ -40,6 +40,7 @@ public final class FakeHerdr implements HerdrClient { private final Map> extraTabs = new LinkedHashMap<>(); private int agentNameTakenFor = 0; private int agentPaneBusyFor = 0; + private String agentStartErrorCode = null; private int workerTabPaneCount = 1; private String paneCloseErrorCode = null; private final Map paneCloseErrorCodeFor = new ConcurrentHashMap<>(); @@ -84,6 +85,12 @@ public final class FakeHerdr implements HerdrClient { return this; } + /** Make every {@code agent.start} call fail with this herdr error code. */ + public FakeHerdr agentStartFailsWith(String code) { + this.agentStartErrorCode = code; + return this; + } + /** Make the worker tab (w9:t2) report this many panes in {@code tab.list} (default 1). */ public FakeHerdr withWorkerTabPaneCount(int n) { this.workerTabPaneCount = n; @@ -311,6 +318,10 @@ public final class FakeHerdr implements HerdrClient { + required + "`", "invalid_request", null); } } + if (agentStartErrorCode != null) { + throw new HerdrException("herdr error [" + agentStartErrorCode + "]: agent.start failed", + agentStartErrorCode, null); + } long starts = calls.stream().filter(c -> c.method().equals("agent.start")).count(); if (starts <= agentPaneBusyFor) { throw new HerdrException( diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index 2352841..592344a 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -1165,7 +1165,8 @@ class ClaudeCodeLauncherTest { @Test void spawnLetsAnUnrelatedHerdrErrorPropagateUnchanged() { // Fix 1 must only special-case a "*_not_found" answer. Any other herdr failure keeps - // propagating as-is — this gate does not know how to recover from it. + // propagating as-is — this gate does not know how to recover from it. The pane still needs + // closing because spawn throws before it can return the pane id to a caller that could stop it. FakeHerdr herdr = new FakeHerdr(); herdr.agentStatus("unknown"); herdr.agentGetFailsWithAfter(0, "internal_error"); @@ -1182,8 +1183,27 @@ class ClaudeCodeLauncherTest { () -> svc.spawn(new SpawnRequest(null, null, null))); assertEquals("internal_error", ex.code()); - assertEquals(0, paneCloseCount(herdr, "w9:pRoot_1"), - "an error this gate does not recognize is not this gate's teardown to run"); + assertEquals(1, paneCloseCount(herdr, "w9:pRoot_1"), + "the unchanged error leaves spawn without a pane id, so this gate closes its orphaned pane"); + } + + @Test + void panePlacementClosesTheSplitPaneWhenAgentStartFails() { + FakeHerdr herdr = new FakeHerdr().agentStartFailsWith("internal_error"); + FleetConfig.Profile cfg = new FleetConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + List.of("claude"), "pane", "fleetd-workers", "w #{n}", null, null, null); + ClaudeCodeLauncher svc = new ClaudeCodeLauncher( + new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + + dev.ltms.fleet.herdr.HerdrException ex = assertThrows( + dev.ltms.fleet.herdr.HerdrException.class, + () -> svc.spawn(new SpawnRequest(null, null, null))); + + assertEquals("internal_error", ex.code(), "agent.start failure propagates unchanged"); + assertEquals(1, paneCloseCount(herdr, "w1:pSplit"), + "the pane split for a peer that never starts is closed instead of left orphaned"); } // --- fleetd #176 fix 2: corroborated UNKNOWN refinement --------------------------------------