diff --git a/.gitignore b/.gitignore index 5a0e8dd..d8308c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,14 @@ # Settings backups inherit the env block — and secrets with it. .claude/settings.local.json.bak* +# The default profile parityOverlay copies these primary→worktree, so they appear in EVERY worker +# worktree. Two reasons they must be ignored. They hold environment values, which is reason enough. +# And since CB-576 a release preserves any worktree that `git status --porcelain` calls dirty — +# untracked files included, deliberately. An untracked overlay file would therefore make every +# COMPLETED release preserve its worktree, and worktrees would pile up with no error to notice. +.env +.envrc + # Daemon runtime artefacts. bridged appends its log wherever it is launched from, so both the # repo root and bridged/ collect one; neither belongs in git. bridged.out diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 6ff7fc5..56e6051 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -172,7 +172,21 @@ public record BridgedConfig( * @param cwd fixed working directory for this profile's workers (CB-112 "told otherwise"); * {@code null}/blank → inherit the primary's cwd, else the daemon's * @param parityOverlay repo-relative paths copied primary→worktree for config parity; null/empty - * defaults to a sensible set of local config files + * defaults to a sensible set of local config files. + *
Every overlay path must be gitignored or tracked-and-skipped. + * CB-576 made {@code release()} preserve a worktree that {@code git status + * --porcelain} reports as dirty, and it deliberately counts untracked files — + * the work lost in CB-576 was a new file nobody had added. So an overlay path + * that is neither gitignored nor tracked lands in every worktree as an + * untracked file, makes every {@code COMPLETED} release preserve, and + * worktrees then accumulate with no error anywhere. + *
Checked on 2026-08-15 (CB-581): inert as configured. Tracked overlay
+ * files carry {@code --skip-worktree} so {@code --porcelain} cannot see them,
+ * {@code bridged.yaml} is gitignored, and the default pair {@code .env} /
+ * {@code .envrc} does not exist in this repo. Note the default applies to
+ * every profile, so creating either file at the repo root is enough
+ * to make it live. Add a new overlay path to {@code .gitignore} in the same
+ * change that adds it here.
* @param gitTokenEnv name of the host env var holding the git-forge API token; when set, its
* value is injected as {@code GITEA_TOKEN} so the worker can open its own PR
* at checkpoint (CB-302). {@code null}/blank ⇒ no token is injected
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
index 0a7563c..ad94c60 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
@@ -197,27 +197,44 @@ public final class SessionManager implements TurnListener {
MemberSession removed = registry.remove(paneId);
boolean preserveWorktree = cause == ReleaseCause.SHUTDOWN;
if (removed != null) {
- memberLifecycle.released(removed.terminalId());
- log.debug("releasing session pane={} terminal={} state={} cause={}",
- removed.paneId(), removed.terminalId(), removed.state(), cause);
- if (preserveWorktree && removed.worktree() != null) {
- logPreservedForShutdown(removed);
- } else if (removed.worktree() != null && worktrees.hasUncommitted(removed.worktree())) {
- // CB-576: a release that would otherwise remove the worktree finds it holding
- // uncommitted work the bridge cannot see. A worker that ends a turn without
- // committing (normally because it stopped to ask a question or refused the turn)
- // has its only copy of that work in the worktree. Remove would --force-delete it,
- // so preserve the directory and tell an operator where to find it.
+ try {
+ memberLifecycle.released(removed.terminalId());
+ log.debug("releasing session pane={} terminal={} state={} cause={}",
+ removed.paneId(), removed.terminalId(), removed.state(), cause);
+ if (preserveWorktree && removed.worktree() != null) {
+ logPreservedForShutdown(removed);
+ } else if (removed.worktree() != null && worktrees.hasUncommitted(removed.worktree())) {
+ // CB-576: a release that would otherwise remove the worktree finds it holding
+ // uncommitted work the bridge cannot see. A worker that ends a turn without
+ // committing (normally because it stopped to ask a question or refused the turn)
+ // has its only copy of that work in the worktree. Remove would --force-delete it,
+ // so preserve the directory and tell an operator where to find it.
+ preserveWorktree = true;
+ log.warn("release {} preserves dirty worktree {} for pane={} terminal={}: "
+ + "the worktree holds uncommitted changes that --force remove would destroy",
+ cause, removed.worktree(), removed.paneId(), removed.terminalId());
+ }
+ } catch (RuntimeException e) {
+ // CB-581: hasUncommitted shells out to `git status` and can throw on a non-zero
+ // exit. We can no longer tell whether the worktree holds uncommitted work, so fail
+ // toward the safe answer and preserve it — deleting on a guess can destroy work
+ // that has no other copy (CB-576), while keeping it on a false alarm only costs
+ // disk. The exception must not propagate: the pane still has to stop below.
preserveWorktree = true;
- log.warn("release {} preserves dirty worktree {} for pane={} terminal={}: "
- + "the worktree holds uncommitted changes that --force remove would destroy",
- cause, removed.worktree(), removed.paneId(), removed.terminalId());
+ log.warn("release {} could not tell whether worktree {} for pane={} terminal={} has "
+ + "uncommitted changes; preserving it rather than risk destroying unsaved work: {}",
+ cause, removed.worktree(), removed.paneId(), removed.terminalId(), e.toString());
+ } finally {
+ // CB-516/CB-581: a send still waiting on this worker can never be answered now, no
+ // matter what happened above. Tell the listener BEFORE the pane is torn down, so a
+ // blocked caller fails fast with a real reason instead of sitting on a rendezvous
+ // nothing will ever resolve.
+ notifyReleased(removed.terminalId());
}
- // CB-516: a send still waiting on this worker can never be answered now. Tell the
- // listener BEFORE the pane is torn down, so a blocked caller fails fast with a real
- // reason instead of sitting on a rendezvous nothing will ever resolve.
- notifyReleased(removed.terminalId());
}
+ // CB-581: the pane must always stop, even if the dirty check above threw. A session removed
+ // from the registry with no pane stop is an orphaned pane — a live terminal burning a fleet
+ // slot that no longer appears in the roster and can never be reclaimed.
launcher.stop(paneId);
if (removed != null && !preserveWorktree && removed.worktree() != null) {
worktrees.remove(worktrees.repoRoot(removed.cwd()), removed.worktree());
@@ -539,8 +556,15 @@ public final class SessionManager implements TurnListener {
log.debug("reaping idle session terminal={} pane={}: idle {}s exceeds the {}s ttl",
s.terminalId(), s.paneId(), TimeUnit.NANOSECONDS.toSeconds(idleNanos),
TimeUnit.NANOSECONDS.toSeconds(idleTtlNanos));
- release(s.paneId());
- reaped++;
+ // CB-581: one session that fails to release must not abort the whole reaping pass —
+ // match drainAll's per-session try/catch so the rest of the roster still gets reaped.
+ try {
+ release(s.paneId());
+ reaped++;
+ } catch (RuntimeException e) {
+ log.warn("reap failed for pane={} terminal={} worktree={}; continuing with "
+ + "remaining sessions", s.paneId(), s.terminalId(), s.worktree(), e);
+ }
}
}
return reaped;
diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
index d8d9009..41ea200 100644
--- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
@@ -46,6 +46,82 @@ class SessionManagerTest {
return sessionManager(herdr, clock, 0);
}
+ private SessionManager sessionManager(FakeHerdr herdr, Worktrees worktrees) {
+ return sessionManager(herdr, worktrees, System::nanoTime);
+ }
+
+ private SessionManager sessionManager(FakeHerdr herdr, Worktrees worktrees, LongSupplier clock) {
+ 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);
+ ClaudeCodeLauncher workers = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
+ return new SessionManager(workers, worktrees, clock);
+ }
+
+ /**
+ * CB-581: a {@link Worktrees} test double whose {@code hasUncommitted} and {@code remove} can
+ * be told to throw, so {@link SessionManager#release} can be exercised against exactly the
+ * failure {@code GitWorktrees} produces when {@code git status}/{@code git worktree remove}
+ * exits non-zero.
+ */
+ private static final class RecordingWorktrees implements Worktrees {
+ private final List