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 40e3cf6..8e9fe6c 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java
@@ -913,9 +913,14 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* {@link #reapOrphanWorkers() orphan-reap} and spawn-gate-timeout paths, plus any caller that
* passes a pane directly, keep working without an owning id.
*
- *
Resolves the tab from the pane before closing it. An already-gone pane/tab
- * (repeated DELETE, crashed peer) is treated as success; any other failure propagates so a
- * genuinely failed teardown is not reported as done.
+ *
Resolves the tab from the pane before closing it. {@code agents.close} (the pane)
+ * is the one step whose failure means the teardown itself may not have happened: an already-gone
+ * pane (repeated DELETE, crashed peer) is treated as success, but any other failure propagates so
+ * a genuinely failed teardown is not reported as done. {@code spaces.closeTab} (fleetd #293) is
+ * different — by the time it runs the pane is already closed, so it is cosmetic workspace tidying
+ * rather than a real teardown failure, and a failure there is logged and never propagates, so it
+ * cannot mask the two cleanups below it ({@link #releaseZdotdir}, and the caller's worktree
+ * removal in {@code SessionManager.release}).
*/
@Override
public void stop(String idOrPane) {
@@ -934,7 +939,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
log.debug("pane.close({}) ignored — already gone: {}", paneId, e.getMessage());
}
if (loc != null && loc.tabPaneCount() == 1) {
- spaces.closeTab(loc.tabId());
+ // fleetd #293: the pane above is already closed by this point, so a failing tab.close is
+ // cosmetic workspace tidying, not a real teardown failure — it must not mask the two
+ // cleanups below it (releaseZdotdir, and the caller's worktree removal). Unlike
+ // agents.close above, this is not narrowed to "already gone": any failure here, whatever
+ // its cause, is one we continue past, so we log it at WARN (not debug) with the tab id a
+ // person can go close by hand.
+ try {
+ spaces.closeTab(loc.tabId());
+ } catch (HerdrException e) {
+ log.warn("tab.close({}) failed — the pane is already torn down, so continuing; the "
+ + "tab may need manual cleanup: {}", loc.tabId(), e.getMessage());
+ }
} else if (loc != null) {
log.debug("not closing tab {} — it holds {} panes (not a dedicated peer tab)",
loc.tabId(), loc.tabPaneCount());
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 0cfe48d..a3f562b 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java
@@ -43,6 +43,8 @@ public final class FakeHerdr implements HerdrClient {
private int workerTabPaneCount = 1;
private String paneCloseErrorCode = null;
private final Map paneCloseErrorCodeFor = new ConcurrentHashMap<>();
+ private String tabCloseErrorCode = null;
+ private final Map tabCloseErrorCodeFor = new ConcurrentHashMap<>();
private String agentSendErrorCode = null;
private boolean noPanes = false;
private volatile String agentStatus = "idle"; // steady-state agent.get status
@@ -106,6 +108,26 @@ public final class FakeHerdr implements HerdrClient {
return this;
}
+ /** Make {@code tab.close} fail with this herdr error code, for every tab. */
+ public FakeHerdr tabCloseFailsWith(String code) {
+ this.tabCloseErrorCode = code;
+ return this;
+ }
+
+ /**
+ * Make {@code tab.close} fail with this herdr error code, but only for the given {@code
+ * tab_id} — every other tab's {@code tab.close} still succeeds. The {@code tab.close}
+ * counterpart to {@link #paneCloseFailsForPane} (fleetd #290): lets a test make exactly one
+ * session's tab teardown fail while proving the rest of {@code stop()} — {@code
+ * releaseZdotdir}, and the caller's worktree removal — still runs (fleetd #293). Named "ForTab"
+ * rather than "ForPane" (unlike its sibling) because {@code tab.close} keys on {@code tab_id},
+ * not a pane id.
+ */
+ public FakeHerdr tabCloseFailsForTab(String tabId, String code) {
+ this.tabCloseErrorCodeFor.put(tabId, code);
+ return this;
+ }
+
/**
* Make {@code pane.list} report no panes at all — models a second herdr daemon (CB-185) that
* simply does not host the pane a {@link PaneLocator} is searching for.
@@ -352,7 +374,17 @@ public final class FakeHerdr implements HerdrClient {
.formatted(workerTabPaneCount,
seeded.isEmpty() ? "" : "," + String.join(",", seeded)));
}
- case "tab.close" -> mapper.readTree("{\"type\":\"ok\"}");
+ case "tab.close" -> {
+ Object tabIdParam = params instanceof Map, ?> m ? m.get("tab_id") : null;
+ String perTabCode = tabIdParam == null ? null
+ : tabCloseErrorCodeFor.get(String.valueOf(tabIdParam));
+ String code = perTabCode != null ? perTabCode : tabCloseErrorCode;
+ if (code != null) {
+ throw new HerdrException("herdr error [" + code + "]: tab.close failed",
+ code, null);
+ }
+ yield mapper.readTree("{\"type\":\"ok\"}");
+ }
case "pane.get" -> mapper.readTree("""
{"type":"pane_info","pane":{"pane_id":"w9:pW","workspace_id":"w9",
"tab_id":"w9:t2","agent_status":"idle"}}""");
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 258c45e..2352841 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
@@ -925,6 +925,71 @@ class ClaudeCodeLauncherTest {
assertTrue(herdr.called("pane.close"), "stop via handle.id() must close the pane");
}
+ /** A tab-placement launcher with {@code memberCredentials policy=allow-list} under a zsh shell — the
+ * combination that makes {@link HerdrPeerLauncher#spawn} generate a real ZDOTDIR, so {@code
+ * releaseZdotdir}'s effect (the directory's deletion) is observable from a test. */
+ private ClaudeCodeLauncher serviceWithAllowList(FakeHerdr herdr) {
+ 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);
+ Supplier creds = () -> new FleetConfig.MemberCredentials(
+ FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of(), null);
+ Function env = name -> "SHELL".equals(name) ? "/bin/zsh" : null;
+ return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ env, 0, System::currentTimeMillis, () -> { }, null, creds);
+ }
+
+ /**
+ * fleetd #293: {@code stop()} used to run {@code spaces.closeTab} bare — any non-{@code
+ * *_not_found} herdr error propagated straight out of {@code stop()}, skipping {@code
+ * releaseZdotdir} entirely (the pane was already closed by that point, so the tab-close failure
+ * is cosmetic, not a real teardown failure). Proves both halves of the fix: {@code stop()} no
+ * longer throws for this failure, and {@code releaseZdotdir} still runs — observed here by the
+ * generated ZDOTDIR actually being deleted, since {@code releaseZdotdir}'s last line is {@code
+ * EnvAllowListScrub.deleteRecursively(dir)}.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ void stopStillReleasesZdotdirWhenCloseTabFailsWithANonNotFoundCode() {
+ FakeHerdr herdr = new FakeHerdr();
+ ClaudeCodeLauncher svc = serviceWithAllowList(herdr);
+ PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
+ Map tabCreateParams = (Map) herdr.lastCall("tab.create").params();
+ Map tabEnv = (Map) tabCreateParams.get("env");
+ String zdotdir = tabEnv.get("ZDOTDIR");
+ assertNotNull(zdotdir, "policy=allow-list under a zsh shell must have generated a ZDOTDIR: " + tabEnv);
+ Path dir = Path.of(zdotdir);
+ assertTrue(Files.isDirectory(dir), "the generated ZDOTDIR must exist before stop(): " + dir);
+ herdr.tabCloseFailsForTab("w9:t2", "internal_error");
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ assertDoesNotThrow(() -> svc.stop(handle.id()),
+ "fleetd #293: a failing tab.close is cosmetic — it must not propagate out of stop()");
+ } finally {
+ logger.detachAppender(appender);
+ }
+
+ assertTrue(herdr.called("tab.close"), "tab.close was still attempted");
+ assertFalse(Files.exists(dir),
+ "releaseZdotdir must still run and delete the generated ZDOTDIR despite the tab.close "
+ + "failure: " + dir);
+ String warn = appender.list.stream()
+ .filter(e -> e.getLevel().equals(Level.WARN))
+ .map(ILoggingEvent::getFormattedMessage)
+ .filter(m -> m.contains("tab.close") && m.contains("w9:t2"))
+ .findFirst()
+ .orElse(null);
+ assertNotNull(warn, "the failing tab.close must be logged at WARN naming the tab id — a "
+ + "silently swallowed failure with no message is not an improvement. Log lines: "
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ }
+
// --- CB-519: host-unique id, decoupled from the pane coordinate ------------------------------
@Test
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 080be4c..e353519 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
@@ -1156,6 +1156,41 @@ class SessionManagerTest {
"COMPLETED release of a clean worktree still removes it");
}
+ /**
+ * fleetd #293: {@code HerdrPeerLauncher.stop()} used to run {@code spaces.closeTab} bare — a
+ * failing {@code tab.close} (any code other than {@code *_not_found}) propagated straight out
+ * of {@code stop()}. {@code SessionManager.release} calls {@code launcher.stop(paneId)} with
+ * no try/catch (fleetd #283 wrapped the WORKTREE-removal step further down, not this one), so
+ * the throw happened before that worktree-removal step ever ran — and by then {@code
+ * registry.remove(paneId)} had already run, so a second {@code stop} is a no-op: the worktree
+ * leaked with no retry path. The pane itself is already closed by the time {@code tab.close}
+ * runs, so its failure is cosmetic workspace tidying, not a real teardown failure. The fix
+ * wraps {@code closeTab} inside {@code stop()} so it no longer throws for this reason; this
+ * test proves both halves at once: {@code release()} does not throw, and it still removes the
+ * worktree.
+ */
+ @Test
+ void releaseStillRemovesTheWorktreeWhenCloseTabFails() {
+ FakeHerdr herdr = new FakeHerdr();
+ RecordingWorktrees worktrees = new RecordingWorktrees();
+ SessionManager sessions = sessionManager(herdr, worktrees);
+ MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
+ new WorktreeRequest("cb-293a", null));
+ // FakeHerdr's pane.get always answers with tab_id "w9:t2" for a tab-placement spawn.
+ herdr.tabCloseFailsForTab("w9:t2", "internal_error");
+
+ assertDoesNotThrow(() -> sessions.release(s.paneId()),
+ "a failing tab.close is cosmetic (the pane is already closed by then) — it must not "
+ + "propagate out of release()");
+
+ assertTrue(herdr.called("tab.close"), "tab.close was still attempted");
+ assertEquals(List.of(s.worktree()), worktrees.removeCalls(),
+ "release() must still remove the worktree even though tab.close failed — this is "
+ + "the leak fleetd #293 reports: before the fix, release() never reached this "
+ + "step at all");
+ assertTrue(sessions.get(s.paneId()).isEmpty(), "the session is still deregistered");
+ }
+
@Test
void unchangedRegressionDirtyCompletedReleaseStillPreservesTheWorktree() {
FakeHerdr herdr = new FakeHerdr();