From 8308c0b68f1116e289be1ca96b78e2e95eee49da Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 3 Sep 2026 16:11:27 +0700 Subject: [PATCH] fleetd #116: recover the already-gone-worktree teardown regression test Ports the intent of the lost CB-576 commit c393600 (worker/cb576-01a04b-17, never merged, package dev.ltms.bridged.*) onto main's dev.ltms.fleet.* tree. Adds FakeWorktrees.markGone (tracks which add()'d worktree paths still "exist", mirroring GitWorktrees.hasUncommitted's Files.exists guard for the already-gone case) and a regression test, releaseStillStopsPaneAndNotifiesWhenWorktreeIsGone, asserting that SessionManager.release still fires notifyReleased, stops the pane, and falls through to remove() when the worktree is already gone. --- .../dev/ltms/fleet/session/FakeWorktrees.java | 18 ++++++++++- .../session/WorktreeSessionManagerTest.java | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java index 847514f..7c1d000 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java @@ -45,6 +45,9 @@ public final class FakeWorktrees implements Worktrees { private final List overlayShareOrder = new CopyOnWriteArrayList<>(); private final Set existingPaths = ConcurrentHashMap.newKeySet(); private final Set trackedPaths = ConcurrentHashMap.newKeySet(); + /** Worktree paths that currently exist, mirroring GitWorktrees' {@code Files.exists} check for + * the already-gone case (CB-576 review, fleetd #116). */ + private final Set worktreePaths = ConcurrentHashMap.newKeySet(); private final AtomicLong snapshotSeq = new AtomicLong(); private volatile RuntimeException addFailure; private volatile RuntimeException snapshotFailure; @@ -115,7 +118,15 @@ public final class FakeWorktrees implements Worktrees { } // The branch already carries a unique nonce, so the derived path is distinct per acquire // without an extra counter — keep it a pure function of the branch the test can predict. - return prefix + "/" + branch.replace('/', '_'); + String path = prefix + "/" + branch.replace('/', '_'); + worktreePaths.add(path); + return path; + } + + /** Model an operator / {@code git worktree prune} removing the worktree before release. */ + public FakeWorktrees markGone(String worktreePath) { + worktreePaths.remove(worktreePath); + return this; } @Override @@ -125,6 +136,11 @@ public final class FakeWorktrees implements Worktrees { @Override public boolean hasUncommitted(String worktreePath) { + // A path that was never added, or was marked gone, is reported clean, mirroring + // GitWorktrees' already-gone guard — never an error, so teardown still completes. + if (!worktreePaths.contains(worktreePath)) { + return false; + } return dirty; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java index 4d5504d..07a0732 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java @@ -258,6 +258,38 @@ class WorktreeSessionManagerTest { } } + /** + * CB-576 review (fleetd #116). A worktree that is already gone (operator cleanup, + * {@code git worktree prune}, an earlier half-completed release) must not break teardown. + * {@code hasUncommitted} reports the missing path clean (mirroring {@code GitWorktrees}), so + * {@code release} still runs {@code notifyReleased} (the CB-516 fast-fail for a blocked + * {@code fleet_send} caller) and {@code launcher.stop} (so the pane is not orphaned), and falls + * through to the already-gone-tolerant {@code remove}. Since CB-581 this all happens because the + * notify-and-stop work sits in {@code release}'s {@code finally}/post-try block rather than a + * checked branch — this test pins that shape by construction. + */ + @Test + void releaseStillStopsPaneAndNotifiesWhenWorktreeIsGone() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + List released = new java.util.concurrent.CopyOnWriteArrayList<>(); + sessions.onRelease(released::add); + MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-576g", null)); + + worktrees.markGone(s.worktree()); + sessions.release(s.paneId()); + + assertEquals(1, released.size(), + "notifyReleased must still fire when the worktree is already gone (CB-516)"); + assertEquals(s.terminalId(), released.getFirst().terminalId()); + assertTrue(herdr.called("pane.close"), + "the pane must still be stopped when the worktree is already gone"); + assertEquals(1, worktrees.removeCalls().size(), + "release still calls the already-gone-tolerant remove"); + } + @Test void drainAllPreservesWorktreeOfIdleSession() { FakeHerdr herdr = new FakeHerdr();