From 97ecc7136e610896c6beb31fc0f3e7b53e4570a3 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 17 Jul 2026 06:46:20 +0200 Subject: [PATCH] CB-301-ext: per-worker git worktree + config-parity overlay Opt-in isolated worktree so parallel implementers don't stomp the shared tree, hydrated to config parity so a worker differs from the primary only in LLM provider. - Worktrees seam (interface) behind SessionManager; GitWorktrees shells git via ProcessBuilder (non-zero exit -> WorktreeException), FakeWorktrees for tests. No live git in unit tests. - acquire() 5-arg overload provisions add -> overlayParity -> spawn(cwd=wt) -> register, unwinding the worktree on any failure before registration. 4-arg overload and shared-tree behavior unchanged (backward compatible). - release() removes the checkout but never deletes the branch (it holds the worker's commits + PR, CB-302). - overlayParity copies local config (.mcp.json, settings.local.json, .env/ .envrc) into the worktree; tracked ones get --skip-worktree so a worker can never stage the parity overlay. - WorkerSession gains nullable worktree/branch; BridgedConfig.Worker gains parityOverlay (default list) + top-level worktreeRoot. - bridge_spawn / POST /workers gain an optional worktree(+ticket) arg; the worker view includes worktree/branch only when non-null. Verify fixes on the delegated impl: strip trailing dashes in slug() (^-+|-+$, was ^-+|^-+$); make FakeWorktrees.add a pure fn of the branch (nonce already unique); MCP worktreeRequest treats blank/"false" string as no-worktree, matching the REST builder. 162 tests, 0 failures. --- .../main/java/dev/ltms/bridged/Bridged.java | 4 +- .../ltms/bridged/config/BridgedConfig.java | 27 ++- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 54 +++++- .../dev/ltms/bridged/rest/BridgedApp.java | 30 ++- .../ltms/bridged/session/GitWorktrees.java | 179 ++++++++++++++++++ .../ltms/bridged/session/SessionManager.java | 112 +++++++++-- .../ltms/bridged/session/WorkerSession.java | 7 +- .../bridged/session/WorktreeException.java | 12 ++ .../ltms/bridged/session/WorktreeRequest.java | 6 + .../dev/ltms/bridged/session/Worktrees.java | 18 ++ .../ltms/bridged/worker/WorkerService.java | 10 + .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 4 +- .../dev/ltms/bridged/rest/BridgedAppTest.java | 2 +- .../ltms/bridged/session/FakeWorktrees.java | 130 +++++++++++++ .../bridged/session/SessionManagerTest.java | 2 +- .../session/WorktreeSessionManagerTest.java | 169 +++++++++++++++++ .../bridged/worker/WorkerServiceTest.java | 8 +- 17 files changed, 730 insertions(+), 44 deletions(-) create mode 100644 bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/session/WorktreeException.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/session/WorktreeRequest.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index caeea80..77180ae 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -18,6 +18,7 @@ import dev.ltms.bridged.mcp.LsofProcessCwdLookup; import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.rest.BridgedApp; +import dev.ltms.bridged.session.GitWorktrees; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; @@ -62,7 +63,8 @@ public final class Bridged { workers.reapOrphanWorkers(); // CB-301: authoritative session registry + lifecycle FSM on top of WorkerService. - SessionManager sessions = new SessionManager(workers); + // CB-301-ext: worktree provisioning seam, optionally rooted at a configured directory. + SessionManager sessions = new SessionManager(workers, new GitWorktrees(cfg.worktreeRoot())); // Status-gated injector (CB-103): the single writer into workers, fed by a poller. // The blocking message endpoint (CB-104) is the producer; the poller is inert until then. 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 6116506..eb74547 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -25,6 +25,8 @@ import java.util.Set; * @param defaultWorker which {@code workers} key a no-argument spawn uses ({@code null} → the * single {@code worker}, or the sole/first profile) * @param guard subscription-boundary allowlist + * @param worktreeRoot nullable root directory for provisioned worktrees; defaults to a sibling + * of the repo root */ @JsonIgnoreProperties(ignoreUnknown = true) public record BridgedConfig( @@ -33,7 +35,8 @@ public record BridgedConfig( Worker worker, Map workers, String defaultWorker, - Guard guard) { + Guard guard, + String worktreeRoot) { @JsonIgnoreProperties(ignoreUnknown = true) public record Bind(String host, int port) { @@ -60,29 +63,35 @@ public record BridgedConfig( * @param tabLabel template for a worker tab's label; {@code {profile}}/{@code {model}} * and {@code {n}} (per-worker number, to keep sibling tabs distinct) * are substituted (default {@code "worker: {profile} #{n}"}) - * @param mcpUrl bridge MCP URL to provision into the worker's {@code configDir} so it - * can call {@code bridge_reply} ({@code null}/blank → no provisioning; the - * worker won't reply, only the fallback/timeout resolves the send) - * @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 mcpUrl bridge MCP URL to provision into the worker's {@code configDir} so it + * can call {@code bridge_reply} ({@code null}/blank → no provisioning; the + * worker won't reply, only the fallback/timeout resolves the send) + * @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 */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, - String cwd) { + String cwd, + List parityOverlay) { public Worker { argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase(); workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace; tabLabel = (tabLabel == null || tabLabel.isBlank()) ? "worker: {profile} #{n}" : tabLabel; + parityOverlay = (parityOverlay == null || parityOverlay.isEmpty()) + ? List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc") + : List.copyOf(parityOverlay); } /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ public Worker withProfile(String p) { return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, - mcpUrl, cwd); + mcpUrl, cwd, parityOverlay); } /** True when workers should land in their own tab in the worker space. */ @@ -174,6 +183,6 @@ public record BridgedConfig( public BridgedConfig withDefaults() { Bind b = bind != null ? bind : new Bind(null, 0); Guard g = guard != null ? guard : new Guard(List.of()); - return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g); + return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index ce7c0b0..cdfe69b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -8,6 +8,7 @@ import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.WorkerSession; +import dev.ltms.bridged.session.WorktreeRequest; import dev.ltms.bridged.worker.WorkerService; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; @@ -105,9 +106,10 @@ public final class BridgeMcp { Map a = req.arguments(); // CB-112: worker inherits the primary's cwd unless the call pins one. // CB-301: carry the caller's identity as the session owner (null for the primary). + // CB-301-ext: optional isolated worktree for parallel implementers. String callerCwd = identity.cwdForPid(callerPid(exchange)); return spawn(sessions, str(a, "profile"), str(a, "cwd"), callerCwd, - callerTerminal(exchange)); + callerTerminal(exchange), worktreeRequest(a)); }) .toolCall(listTool(), (_, _) -> listWorkers(workers)) .toolCall(stopTool(), (_, req) -> stop(sessions, str(req.arguments(), "paneId"))) @@ -283,7 +285,7 @@ public final class BridgeMcp { /** {@code bridge_spawn} without cwd/caller context (default resolution). */ static McpSchema.CallToolResult spawn(SessionManager sessions, String profile) { - return spawn(sessions, profile, null, null, null); + return spawn(sessions, profile, null, null, null, null); } /** @@ -291,13 +293,14 @@ public final class BridgeMcp { * profile) and return its session id + pane id. The worker's cwd is {@code requestedCwd} if given, * else the profile's config, else {@code callerCwd} (the primary's directory), else the daemon's. * CB-301: the session is registered with {@code ownerTerminal} as its owner. + * CB-301-ext: {@code worktreeRequest} non-null provisions an isolated git worktree. */ static McpSchema.CallToolResult spawn(SessionManager sessions, String profile, String requestedCwd, String callerCwd, - String ownerTerminal) { + String ownerTerminal, WorktreeRequest worktreeRequest) { try { WorkerSession worker = sessions.acquire(isBlank(profile) ? null : profile, - requestedCwd, callerCwd, ownerTerminal); + requestedCwd, callerCwd, ownerTerminal, worktreeRequest); return text(json(workerView(worker))); } catch (GuardException e) { return error("subscription boundary: " + e.getMessage()); @@ -308,6 +311,34 @@ public final class BridgeMcp { } } + /** Build a {@link WorktreeRequest} from {@code bridge_spawn}'s optional {@code worktree}/{@code ticket} args. */ + private static WorktreeRequest worktreeRequest(Map a) { + Object w = a.get("worktree"); + if (w == null || Boolean.FALSE.equals(w)) { + return null; + } + String ticket = str(a, "ticket"); + if (w instanceof String s) { + if (s.isBlank() || "false".equalsIgnoreCase(s)) { + return null; + } + if ("true".equalsIgnoreCase(s)) { + if (isBlank(ticket)) { + throw new IllegalArgumentException("worktree=true requires a ticket slug"); + } + return new WorktreeRequest(ticket, null); + } + return new WorktreeRequest(s, null); + } + if (w instanceof Boolean b && b) { + if (isBlank(ticket)) { + throw new IllegalArgumentException("worktree=true requires a ticket slug"); + } + return new WorktreeRequest(ticket, null); + } + return null; + } + /** {@code bridge_profiles}: the configured worker profiles and the default. */ static McpSchema.CallToolResult profiles(WorkerService workers) { return text(json(Map.of( @@ -353,6 +384,12 @@ public final class BridgeMcp { m.put("sessionId", s.terminalId()); m.put("paneId", s.paneId()); m.put("status", s.state().name().toLowerCase()); + if (s.worktree() != null) { + m.put("worktree", s.worktree()); + } + if (s.branch() != null) { + m.put("branch", s.branch()); + } return m; } @@ -410,11 +447,14 @@ public final class BridgeMcp { return tool("bridge_spawn", "Spawn a new off-subscription worker session. Pass a profile (from bridge_profiles) to " + "pick the backend, or omit it for the default. The worker opens your current " - + "directory by default; pass cwd to pin a different one. Returns the worker's " - + "sessionId (use with bridge_send) and paneId (use with bridge_stop).", + + "directory by default; pass cwd to pin a different one. Pass worktree:true (with " + + "ticket) or worktree: to provision an isolated git worktree. " + + "Returns the worker's sessionId (use with bridge_send) and paneId (use with bridge_stop).", objectSchema(Map.of( "profile", stringProp("Worker profile to spawn (omit for the default profile)"), - "cwd", stringProp("Working directory for the worker (omit to inherit yours)")), + "cwd", stringProp("Working directory for the worker (omit to inherit yours)"), + "worktree", Map.of("type", "string", "description", "'true' or a ticket slug — requests an isolated git worktree"), + "ticket", stringProp("Ticket slug when worktree:true")), List.of())); } diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index a39ee9c..9d17d71 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -11,6 +11,7 @@ import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.WorkerSession; +import dev.ltms.bridged.session.WorktreeRequest; import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; import io.javalin.http.Context; @@ -137,21 +138,27 @@ public final class BridgedApp { private void spawnWorker(Context ctx) { String profile = ctx.queryParam("profile"); String cwd = ctx.queryParam("cwd"); - if (profile == null || profile.isBlank() || cwd == null || cwd.isBlank()) { + String worktree = ctx.queryParam("worktree"); + String ticket = ctx.queryParam("ticket"); + if (profile == null || profile.isBlank() || cwd == null || cwd.isBlank() + || worktree == null || worktree.isBlank()) { try { String body = ctx.body(); if (!body.isBlank()) { JsonNode b = mapper.readTree(body); if (profile == null || profile.isBlank()) profile = b.path("profile").asText(null); if (cwd == null || cwd.isBlank()) cwd = b.path("cwd").asText(null); + if (worktree == null || worktree.isBlank()) worktree = b.path("worktree").asText(null); + if (ticket == null || ticket.isBlank()) ticket = b.path("ticket").asText(null); } } catch (Exception ignored) { // A malformed/empty body just means "no overrides" → fall through to defaults. } } + WorktreeRequest wt = worktreeRequest(worktree, ticket); try { // No MCP caller over REST, so callerCwd and ownerTerminal are null. - WorkerSession worker = sessions.acquire(blankToNull(profile), blankToNull(cwd), null, null); + WorkerSession worker = sessions.acquire(blankToNull(profile), blankToNull(cwd), null, null, wt); ctx.status(201).json(view(worker)); } catch (GuardException e) { ctx.status(403).json(Map.of("error", "subscription_boundary", "detail", e.getMessage())); @@ -160,6 +167,19 @@ public final class BridgedApp { } } + private static WorktreeRequest worktreeRequest(String worktree, String ticket) { + if (worktree == null || worktree.isBlank() || "false".equalsIgnoreCase(worktree)) { + return null; + } + if ("true".equalsIgnoreCase(worktree)) { + if (ticket == null || ticket.isBlank()) { + throw new IllegalArgumentException("worktree=true requires a ticket slug"); + } + return new WorktreeRequest(ticket, null); + } + return new WorktreeRequest(worktree, null); + } + private static String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s; } @@ -377,6 +397,12 @@ public final class BridgedApp { m.put("cwd", s.cwd()); m.put("ownerTerminal", s.ownerTerminal()); m.put("state", s.state().name().toLowerCase()); + if (s.worktree() != null) { + m.put("worktree", s.worktree()); + } + if (s.branch() != null) { + m.put("branch", s.branch()); + } return m; } } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java new file mode 100644 index 0000000..5ab087c --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java @@ -0,0 +1,179 @@ +package dev.ltms.bridged.session; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.SecureRandom; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * Production {@link Worktrees} implementation that shells {@code git} via {@link ProcessBuilder}. + * Non-zero exits become {@link WorktreeException}. Worktree directories live under a configurable + * root (default: a sibling {@code .bridged-worktrees} of the repo root) so they are never nested + * inside the primary working tree. + */ +public final class GitWorktrees implements Worktrees { + + private static final Logger log = LoggerFactory.getLogger(GitWorktrees.class); + + private final String configuredRoot; + private final SecureRandom random = new SecureRandom(); + private final AtomicLong seq = new AtomicLong(); + + /** Default constructor: worktree root is derived per-repo as {@code /../.bridged-worktrees}. */ + public GitWorktrees() { + this(null); + } + + /** @param configuredRoot nullable absolute or relative path; null/blank derives a sibling of the repo root. */ + public GitWorktrees(String configuredRoot) { + this.configuredRoot = configuredRoot; + } + + @Override + public String add(String repoRoot, String branch, String baseRef) { + String base = (baseRef == null || baseRef.isBlank()) ? "HEAD" : baseRef; + String nonce = nonce(); + Path root = resolveRoot(repoRoot); + Path path = root.resolve(nonce); + try { + Files.createDirectories(root); + } catch (IOException e) { + throw new WorktreeException("cannot create worktree root " + root + ": " + e.getMessage(), e); + } + String wt = path.toAbsolutePath().toString(); + log.info("adding worktree branch={} path={} base={}", branch, wt, base); + exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base); + return wt; + } + + @Override + public void remove(String repoRoot, String worktreePath) { + Path p = Path.of(worktreePath); + if (!Files.exists(p)) { + log.debug("worktree {} already gone — nothing to remove", worktreePath); + return; + } + log.info("removing worktree {}", worktreePath); + exec("git", "-C", repoRoot, "worktree", "remove", "--force", worktreePath); + } + + @Override + public void overlayParity(String repoRoot, String worktreePath, List overlay) { + if (overlay == null || overlay.isEmpty()) { + return; + } + Path srcRoot = Path.of(repoRoot).toAbsolutePath().normalize(); + Path dstRoot = Path.of(worktreePath).toAbsolutePath().normalize(); + for (String rel : overlay) { + Path src = srcRoot.resolve(rel).normalize(); + if (!Files.exists(src)) { + log.debug("parity overlay source missing — skipping {}", rel); + continue; + } + Path dst = dstRoot.resolve(rel).normalize(); + try { + Files.createDirectories(dst.getParent()); + Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + log.debug("copied parity overlay {}", rel); + } catch (IOException e) { + throw new WorktreeException("cannot copy overlay " + rel + ": " + e.getMessage(), e); + } + if (isTracked(dstRoot, rel)) { + exec("git", "-C", worktreePath, "update-index", "--skip-worktree", rel); + log.debug("marked overlay --skip-worktree {}", rel); + } + } + } + + @Override + public String repoRoot(String cwd) { + String out = exec("git", "-C", cwd, "rev-parse", "--show-toplevel"); + return Path.of(out.trim()).toAbsolutePath().normalize().toString(); + } + + /** Resolve the directory that will hold per-session worktree checkouts. */ + private Path resolveRoot(String repoRoot) { + if (configuredRoot != null && !configuredRoot.isBlank()) { + return Path.of(configuredRoot).toAbsolutePath().normalize(); + } + Path repo = Path.of(repoRoot).toAbsolutePath().normalize(); + return repo.resolveSibling(".bridged-worktrees"); + } + + private String nonce() { + return String.format("%06x", random.nextInt(1 << 24)) + "-" + seq.incrementAndGet(); + } + + private boolean isTracked(Path worktreeRoot, String rel) { + return exitCode("git", "-C", worktreeRoot.toString(), "ls-files", "--error-unmatch", rel) == 0; + } + + /** + * Run a command and return its stdout. Non-zero exit → {@link WorktreeException} with both + * stdout and stderr (merged by redirectErrorStream). + */ + private String exec(String... command) { + String out; + int code; + Process p; + try { + p = new ProcessBuilder(command).redirectErrorStream(true).start(); + } catch (IOException e) { + throw new WorktreeException("failed to start " + command[0] + ": " + e.getMessage(), e); + } + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + out = r.lines().collect(Collectors.joining("\n")); + } catch (IOException e) { + p.destroyForcibly(); + throw new UncheckedIOException(e); + } + try { + if (!p.waitFor(30, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new WorktreeException("command timed out: " + String.join(" ", command) + "\n" + out); + } + code = p.exitValue(); + } catch (InterruptedException e) { + p.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new WorktreeException("interrupted waiting for command: " + String.join(" ", command), e); + } + if (code != 0) { + throw new WorktreeException("exit " + code + " for: " + String.join(" ", command) + + (out.isBlank() ? "" : "\n" + out)); + } + return out; + } + + private int exitCode(String... command) { + Process p; + try { + p = new ProcessBuilder(command).redirectErrorStream(true).start(); + } catch (IOException e) { + throw new WorktreeException("failed to start " + command[0] + ": " + e.getMessage(), e); + } + try { + if (!p.waitFor(30, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new WorktreeException("command timed out: " + String.join(" ", command)); + } + return p.exitValue(); + } catch (InterruptedException e) { + p.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new WorktreeException("interrupted waiting for command: " + String.join(" ", command), e); + } + } +} 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 7c85636..42110f9 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -7,9 +7,11 @@ import dev.ltms.bridged.worker.WorkerService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.security.SecureRandom; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; /** * Authoritative in-daemon registry of the worker sessions this {@code bridged} process spawned. @@ -30,11 +32,20 @@ public final class SessionManager implements TurnListener { private static final Logger log = LoggerFactory.getLogger(SessionManager.class); private final WorkerService workerService; + private final Worktrees worktrees; private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); private final WorkerPresence presence; + private final SecureRandom nonceRandom = new SecureRandom(); + private final AtomicLong nonceSeq = new AtomicLong(); + /** Backward-compatible constructor: shared-tree sessions, production git seam. */ public SessionManager(WorkerService workerService) { + this(workerService, new GitWorktrees()); + } + + public SessionManager(WorkerService workerService, Worktrees worktrees) { this.workerService = workerService; + this.worktrees = worktrees; this.presence = new PresenceBridge(this); } @@ -55,21 +66,36 @@ public final class SessionManager implements TurnListener { */ public WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal) { - Agent worker = workerService.spawn(profile, requestedCwd, callerCwd); - String resolvedProfile = (profile == null || profile.isBlank()) - ? workerService.defaultProfile() : profile; - WorkerSession session = new WorkerSession( - worker.paneId(), - worker.terminalId(), - resolvedProfile, - resolveCwd(requestedCwd, profile, callerCwd), - ownerTerminal, - System.nanoTime(), - WorkerSession.State.SPAWNING); - registry.put(session.paneId(), session); - log.debug("acquired session pane={} terminal={} profile={} owner={}", - session.paneId(), session.terminalId(), session.profile(), session.ownerTerminal()); - return session; + return acquire(profile, requestedCwd, callerCwd, ownerTerminal, null); + } + + /** + * Spawn a worker, optionally inside a fresh git worktree. When {@code wt} is non-null the + * worktree is provisioned, parity-overlaid, and its path becomes the worker's cwd. On any + * failure before registration the worktree is removed so no dangling checkout is left. + */ + public WorkerSession acquire(String profile, String requestedCwd, String callerCwd, + String ownerTerminal, WorktreeRequest wt) { + if (wt == null) { + Agent worker = workerService.spawn(profile, requestedCwd, callerCwd); + String resolvedProfile = (profile == null || profile.isBlank()) + ? workerService.defaultProfile() : profile; + WorkerSession session = new WorkerSession( + worker.paneId(), + worker.terminalId(), + resolvedProfile, + resolveCwd(requestedCwd, profile, callerCwd), + ownerTerminal, + System.nanoTime(), + WorkerSession.State.SPAWNING, + null, + null); + registry.put(session.paneId(), session); + log.debug("acquired session pane={} terminal={} profile={} owner={}", + session.paneId(), session.terminalId(), session.profile(), session.ownerTerminal()); + return session; + } + return acquireWithWorktree(profile, requestedCwd, callerCwd, ownerTerminal, wt); } /** Tear a worker down by pane id and remove it from the registry. Idempotent. */ @@ -80,6 +106,62 @@ public final class SessionManager implements TurnListener { removed.paneId(), removed.terminalId(), removed.state()); } workerService.stop(paneId); + if (removed != null && removed.worktree() != null) { + worktrees.remove(worktrees.repoRoot(removed.cwd()), removed.worktree()); + } + } + + private WorkerSession acquireWithWorktree(String profile, String requestedCwd, String callerCwd, + String ownerTerminal, WorktreeRequest wt) { + String resolvedProfile = (profile == null || profile.isBlank()) + ? workerService.defaultProfile() : profile; + String repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd)); + String branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce(); + String path = null; + Agent worker; + try { + path = worktrees.add(repoRoot, branch, wt.baseRef()); + worktrees.overlayParity(repoRoot, path, workerService.parityOverlay(resolvedProfile)); + worker = workerService.spawn(profile, path, callerCwd); + } catch (RuntimeException e) { + if (path != null) { + try { + worktrees.remove(repoRoot, path); + } catch (RuntimeException cleanup) { + log.warn("failed to clean up worktree {} after spawn error: {}", path, cleanup.getMessage()); + } + } + throw e; + } + WorkerSession session = new WorkerSession( + worker.paneId(), + worker.terminalId(), + resolvedProfile, + resolveCwd(path, profile, callerCwd), + ownerTerminal, + System.nanoTime(), + WorkerSession.State.SPAWNING, + path, + branch); + registry.put(session.paneId(), session); + log.debug("acquired worktree session pane={} terminal={} profile={} branch={} path={}", + session.paneId(), session.terminalId(), session.profile(), session.branch(), session.worktree()); + return session; + } + + private String slug(String raw) { + return raw == null ? "ticket" : raw.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-+|-+$", ""); + } + + private String nonce() { + return String.format("%06x", nonceRandom.nextInt(1 << 24)) + "-" + nonceSeq.incrementAndGet(); + } + + private static String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; } /** diff --git a/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java b/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java index 1679443..cd29a1e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java @@ -20,7 +20,9 @@ public record WorkerSession( String cwd, String ownerTerminal, long spawnedAtNanos, - State state) { + State state, + String worktree, + String branch) { /** One-shot worker lifecycle states. */ public enum State { @@ -34,6 +36,7 @@ public record WorkerSession( /** Return a copy of this session in {@code state}. */ public WorkerSession withState(State state) { - return new WorkerSession(paneId, terminalId, profile, cwd, ownerTerminal, spawnedAtNanos, state); + return new WorkerSession(paneId, terminalId, profile, cwd, ownerTerminal, spawnedAtNanos, state, + worktree, branch); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/WorktreeException.java b/bridged/src/main/java/dev/ltms/bridged/session/WorktreeException.java new file mode 100644 index 0000000..ee993ad --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/session/WorktreeException.java @@ -0,0 +1,12 @@ +package dev.ltms.bridged.session; + +/** Non-zero exit or I/O failure from a git worktree operation. */ +public final class WorktreeException extends RuntimeException { + public WorktreeException(String message) { + super(message); + } + + public WorktreeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/session/WorktreeRequest.java b/bridged/src/main/java/dev/ltms/bridged/session/WorktreeRequest.java new file mode 100644 index 0000000..82a48a1 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/session/WorktreeRequest.java @@ -0,0 +1,6 @@ +package dev.ltms.bridged.session; + +/** Ask {@link SessionManager#acquire} to provision an isolated worktree. null ⇒ run in the shared primary tree. */ +public record WorktreeRequest(String ticketSlug, String baseRef) { + // ticketSlug seeds the branch name; baseRef null/blank ⇒ current HEAD of the repo. +} diff --git a/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java new file mode 100644 index 0000000..eaef94e --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java @@ -0,0 +1,18 @@ +package dev.ltms.bridged.session; + +import java.util.List; + +/** Seam between {@link SessionManager} and git worktree operations. Tests use a recording fake. */ +public interface Worktrees { + /** git -C worktree add -b . Returns the worktree path. */ + String add(String repoRoot, String branch, String baseRef); + + /** git -C worktree remove --force . Idempotent (already-gone tolerated). */ + void remove(String repoRoot, String worktreePath); + + /** Copy each existing overlay path repoRoot→worktree; mark tracked ones --skip-worktree. */ + void overlayParity(String repoRoot, String worktreePath, List overlay); + + /** git -C rev-parse --show-toplevel — the repo root that owns cwd. */ + String repoRoot(String cwd); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java index ab719ce..ea134bc 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -97,6 +97,16 @@ public final class WorkerService { return profiles.keySet(); } + /** The parity-overlay file list for {@code profileName} (default list when unset). */ + public List parityOverlay(String profileName) { + String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName; + if (name == null || name.isBlank()) { + return List.of(); + } + BridgedConfig.Worker cfg = profiles.get(name); + return cfg == null ? List.of() : cfg.parityOverlay(); + } + /** The profile a no-argument {@link #spawn()} uses, or {@code null} if none is configured. */ public String defaultProfile() { return defaultProfile; diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index 726c856..c233485 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -39,7 +39,7 @@ class BridgeMcpTest { private static WorkerService workerService(FakeHerdr h, String baseUrl, Set allow) { BridgedConfig.Worker cfg = new BridgedConfig.Worker( "ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, - "tab", "bridged-workers", "worker: {profile} #{n}", null, null); + "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); return new WorkerService(new AgentControl(h), new WorkspaceControl(h), new SubscriptionGuard(allow), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "tok"); } @@ -217,7 +217,7 @@ class BridgeMcpTest { void spawnPassesTheRequestedCwdToTheWorker() { FakeHerdr h = new FakeHerdr(); McpSchema.CallToolResult res = BridgeMcp.spawn( - sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null, null); + sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null, null, null); assertNotEquals(Boolean.TRUE, res.isError()); @SuppressWarnings("unchecked") Map start = (Map) h.lastCall("agent.start").params(); diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index d615237..167ef28 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -55,7 +55,7 @@ class BridgedAppTest { private int start(FakeHerdr herdr, String workerBaseUrl, Set allow, String placement) { BridgedConfig.Worker wcfg = new BridgedConfig.Worker( "ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, - placement, "bridged-workers", "worker: {profile} #{n}", null, null); + placement, "bridged-workers", "worker: {profile} #{n}", null, null, null); AgentControl agents = new AgentControl(herdr); WorkerService workers = new WorkerService( agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow), diff --git a/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java new file mode 100644 index 0000000..4aa1dd3 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java @@ -0,0 +1,130 @@ +package dev.ltms.bridged.session; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Recording fake {@link Worktrees} for CB-301-ext acceptance tests (no live git). */ +public final class FakeWorktrees implements Worktrees { + + public record AddCall(String repoRoot, String branch, String baseRef) { + } + + public record RemoveCall(String repoRoot, String worktreePath) { + } + + public record OverlayCall(String repoRoot, String worktreePath, + List requested, List copied, List skipWorktree) { + } + + public record RepoRootCall(String cwd) { + } + + private final List addCalls = new CopyOnWriteArrayList<>(); + private final List removeCalls = new CopyOnWriteArrayList<>(); + private final List overlayCalls = new CopyOnWriteArrayList<>(); + private final List repoRootCalls = new CopyOnWriteArrayList<>(); + private final Set existingPaths = ConcurrentHashMap.newKeySet(); + private final Set trackedPaths = ConcurrentHashMap.newKeySet(); + private volatile RuntimeException addFailure; + private volatile String repoRoot = "/repo"; + private volatile String prefix = "/worktrees"; + + public FakeWorktrees withRepoRoot(String root) { + this.repoRoot = root; + return this; + } + + public FakeWorktrees withPrefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** Paths that exist in the primary repo and will be copied to the worktree. */ + public FakeWorktrees exists(String... paths) { + Collections.addAll(existingPaths, paths); + return this; + } + + /** Paths that exist AND are tracked, so overlayParity should --skip-worktree them. */ + public FakeWorktrees track(String... paths) { + exists(paths); + Collections.addAll(trackedPaths, paths); + return this; + } + + /** Make subsequent {@link #add} calls throw (simulates git worktree add failure). */ + public FakeWorktrees failAdd(String message) { + this.addFailure = new WorktreeException(message); + return this; + } + + @Override + public String add(String repoRoot, String branch, String baseRef) { + addCalls.add(new AddCall(repoRoot, branch, baseRef)); + if (addFailure != null) { + throw addFailure; + } + // 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('/', '_'); + } + + @Override + public void remove(String repoRoot, String worktreePath) { + removeCalls.add(new RemoveCall(repoRoot, worktreePath)); + } + + @Override + public void overlayParity(String repoRoot, String worktreePath, List overlay) { + List copied = new java.util.ArrayList<>(); + List skipped = new java.util.ArrayList<>(); + for (String rel : overlay) { + if (!existingPaths.contains(rel)) { + continue; // missing source is silently skipped + } + copied.add(rel); + if (trackedPaths.contains(rel)) { + skipped.add(rel); + } + } + overlayCalls.add(new OverlayCall(repoRoot, worktreePath, List.copyOf(overlay), + List.copyOf(copied), List.copyOf(skipped))); + } + + @Override + public String repoRoot(String cwd) { + repoRootCalls.add(new RepoRootCall(cwd)); + return repoRoot; + } + + public List addCalls() { + return List.copyOf(addCalls); + } + + public List removeCalls() { + return List.copyOf(removeCalls); + } + + public List overlayCalls() { + return List.copyOf(overlayCalls); + } + + public List repoRootCalls() { + return List.copyOf(repoRootCalls); + } + + public AddCall lastAdd() { + return addCalls.isEmpty() ? null : addCalls.getLast(); + } + + public RemoveCall lastRemove() { + return removeCalls.isEmpty() ? null : removeCalls.getLast(); + } + + public OverlayCall lastOverlay() { + return overlayCalls.isEmpty() ? null : overlayCalls.getLast(); + } +} 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 5aebbcf..40eeb0d 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -24,7 +24,7 @@ class SessionManagerTest { BridgedConfig.Worker cfg = new BridgedConfig.Worker( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", - "worker: {profile} #{n}", null, null); + "worker: {profile} #{n}", null, null, null); WorkerService workers = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); return new SessionManager(workers); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java new file mode 100644 index 0000000..b928407 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java @@ -0,0 +1,169 @@ +package dev.ltms.bridged.session; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.worker.WorkerService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CB-301-ext acceptance tests for worktree provisioning and config-parity overlay. + * No live git — every Worktrees call is handled by {@link FakeWorktrees} and every herdr + * call by {@link FakeHerdr}, matching the project's fake-based test style. + */ +class WorktreeSessionManagerTest { + + private static WorkerService workerService(FakeHerdr herdr) { + BridgedConfig.Worker cfg = new BridgedConfig.Worker( + "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); + return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + } + + private static String startCwd(FakeHerdr herdr) { + @SuppressWarnings("unchecked") + Map start = (Map) herdr.lastCall("agent.start").params(); + Object cwd = start.get("cwd"); + return cwd == null ? null : cwd.toString(); + } + + @Test + void sharedTreeAcquireMakesNoWorktreesCallsAndRecordsNullWorktree() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees(); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary"); + + assertTrue(worktrees.addCalls().isEmpty(), "shared-tree acquire never adds a worktree"); + assertTrue(worktrees.repoRootCalls().isEmpty(), "shared-tree acquire never resolves a repo root"); + assertTrue(worktrees.overlayCalls().isEmpty(), "shared-tree acquire never overlays parity"); + assertNull(s.worktree(), "shared-tree session has no worktree"); + assertNull(s.branch(), "shared-tree session has no branch"); + assertEquals("/caller/proj", s.cwd(), "shared-tree cwd is the caller's cwd"); + assertEquals("/caller/proj", startCwd(herdr), "spawn receives the caller's cwd"); + } + + @Test + void worktreeAcquireProvisionsAndRecordsPathAndBranch() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary", + new WorktreeRequest("cb-999", null)); + + assertEquals(1, worktrees.addCalls().size(), "one worktree was added"); + FakeWorktrees.AddCall add = worktrees.lastAdd(); + assertNotNull(add); + assertEquals("/repo", add.repoRoot()); + assertTrue(add.branch().startsWith("worker/cb-999-"), "branch is worker/-: " + add.branch()); + assertNull(add.baseRef(), "null baseRef is passed through (HEAD default)"); + + String expectedPath = "/wt/" + add.branch().replace('/', '_'); + assertEquals(expectedPath, s.worktree(), "session records the returned worktree path"); + assertEquals(add.branch(), s.branch(), "session records the branch"); + assertEquals(expectedPath, startCwd(herdr), "spawn receives the worktree path as cwd"); + assertEquals(expectedPath, s.cwd(), "session cwd is the worktree path"); + } + + @Test + void worktreeAcquireRunsParityOverlayWithProfileDefaults() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt") + .track(".mcp.json") + .exists(".claude/settings.local.json"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-888", null)); + + assertEquals(1, worktrees.overlayCalls().size()); + FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay(); + assertNotNull(overlay); + assertEquals("/repo", overlay.repoRoot()); + assertEquals(List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc"), + overlay.requested(), "default parity overlay is used when unset"); + assertEquals(List.of(".mcp.json", ".claude/settings.local.json"), overlay.copied(), + "existing paths are copied; missing paths are skipped"); + assertEquals(List.of(".mcp.json"), overlay.skipWorktree(), + "tracked copied paths are --skip-worktree'd"); + } + + @Test + void releaseRemovesWorktreeButDoesNotDeleteBranch() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-666", null)); + String paneId = s.paneId(); + + sessions.release(paneId); + + assertTrue(herdr.called("pane.close"), "release still tears the worker pane down"); + assertEquals(1, worktrees.removeCalls().size(), "worktree session triggers one remove"); + FakeWorktrees.RemoveCall remove = worktrees.lastRemove(); + assertNotNull(remove); + assertEquals("/repo", remove.repoRoot()); + assertEquals(s.worktree(), remove.worktreePath()); + // The fake records no branch-delete calls because Worktrees.remove only removes the checkout. + assertTrue(sessions.get(paneId).isEmpty(), "released session is no longer retrievable"); + } + + @Test + void sharedTreeReleaseMakesNoWorktreesCalls() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees(); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", null); + + sessions.release(s.paneId()); + + assertTrue(herdr.called("pane.close"), "release tears the worker pane down"); + assertTrue(worktrees.removeCalls().isEmpty(), "shared-tree release never removes a worktree"); + } + + @Test + void failedWorktreeAddUnwindsWithoutRegisteringSessionOrSpawning() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().failAdd("worktree add failed"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + assertThrows(WorktreeException.class, () -> + sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-555", null))); + + assertEquals(0, sessions.size(), "failed acquire leaves no registry entry"); + assertFalse(herdr.called("agent.start"), "spawn is never reached when add fails"); + assertTrue(worktrees.removeCalls().isEmpty(), "no worktree was added, so none is removed"); + } + + @Test + void twoWorktreeAcquiresYieldDistinctBranchesAndPaths() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + WorkerSession a = sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-444", null)); + WorkerSession b = sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-444", null)); + + assertNotEquals(a.branch(), b.branch(), "branches are distinct"); + assertNotEquals(a.worktree(), b.worktree(), "paths are distinct"); + assertEquals(2, worktrees.addCalls().size()); + assertEquals(2, sessions.roster().size()); + } + +} diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java index 7bb290e..f72a70f 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -19,7 +19,7 @@ class WorkerServiceTest { private WorkerService service(FakeHerdr herdr, List argv, String mcpUrl) { BridgedConfig.Worker cfg = new BridgedConfig.Worker( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", - argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null); + argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null, null); return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); } @@ -52,9 +52,9 @@ class WorkerServiceTest { private WorkerService multiProfile(FakeHerdr herdr) { BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder", - null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null, null); + null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null, null, null); BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null, - null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null, null); + null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null, null, null); return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")), Map.of("gx10", gx10, "ollama", ollama), "gx10", _ -> "tok"); @@ -97,7 +97,7 @@ class WorkerServiceTest { FakeHerdr herdr = new FakeHerdr(); BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", - "w #{n}", null, "/pinned/dir"); + "w #{n}", null, "/pinned/dir", null); WorkerService svc = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of("ltms-local", cfg), "ltms-local", _ -> null); svc.spawn("ltms-local", null, "/caller/home");