diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index d0ec531..18b23ff 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -20,6 +20,7 @@ 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.peer.PeerLauncher; import dev.ltms.bridged.session.SessionReaper; import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; @@ -56,7 +57,7 @@ public final class Bridged { AgentControl agents = new AgentControl(herdr); WorkspaceControl spaces = new WorkspaceControl(herdr); - WorkerService workers = new WorkerService(agents, spaces, guard, + PeerLauncher workers = new WorkerService(agents, spaces, guard, cfg.workerProfiles(), cfg.defaultProfile(), System::getenv); // CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died with // the previous process — reap those leaked orphans now, before we start serving. @@ -120,7 +121,8 @@ public final class Bridged { // Caller identity is resolved from the connection (peer PID → herdr pane), not arguments. ConnectionIdentity identity = new ConnectionIdentity( new PaneLocator(herdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup()); - BridgeMcp mcp = new BridgeMcp(messages, rendezvous, workers, sessions, identity, presence); + // Cast to WorkerService: BridgeMcp is not yet migrated to PeerLauncher (Stage A scope). + BridgeMcp mcp = new BridgeMcp(messages, rendezvous, (WorkerService) workers, sessions, identity, presence); // CB-303 part 3: single ordered shutdown hook. Drain sessions first while herdr is still // open (so releases reach the daemon), then stop poller/message/mcp/reaper, and close herdr @@ -134,7 +136,7 @@ public final class Bridged { herdr.close(); })); - Javalin app = new BridgedApp(herdr, workers, sessions, messages, rendezvous, presence, mcp.servlet()).build(); + Javalin app = new BridgedApp(herdr, (WorkerService) workers, sessions, messages, rendezvous, presence, mcp.servlet()).build(); app.start(cfg.bind().host(), cfg.bind().port()); log.info("bridged listening on {}:{}, herdr socket {}", cfg.bind().host(), cfg.bind().port(), socket); diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java new file mode 100644 index 0000000..14adfbc --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/Capability.java @@ -0,0 +1,35 @@ +package dev.ltms.bridged.peer; + +/** + * Declared capabilities of a {@link PeerLauncher}. The protocol is the union across all + * configured launchers; a verb invoked against a peer that lacks the capability returns a clean + * "unsupported for this peer" rather than a crash. Capabilities keep the protocol honest as peers + * diversify and prevent the core from assuming "every peer is a Claude in a worktree." + */ +public enum Capability { + + /** + * The peer supports {@code bridge_ask} rendezvous — pausing its delegated turn to ask + * the primary a question, then resuming once answered. All Claude Code peers support this. + */ + MID_TURN_ASK, + + /** + * The peer can open its own PR at the end of an implementation turn (CB-302). Opt-in per + * profile: granted only when the profile carries a git-forge token ({@code gitTokenEnv}). + */ + SELF_PR, + + /** + * The peer can run inside a provisioned isolated git worktree. All CLI-based peers support + * this since their cwd is set at spawn time. + */ + WORKTREE, + + /** + * The spawner can reconcile orphaned peers on boot — workers that outlived a prior daemon + * process and whose pane ids died with it (CB-117). Claude Code over herdr supports this + * via name-based matching against the herdr agent list. + */ + ORPHAN_REAP +} diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java new file mode 100644 index 0000000..dd8ae7c --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -0,0 +1,29 @@ +package dev.ltms.bridged.peer; + +/** + * An opaque handle returned by {@link PeerLauncher#spawn(SpawnRequest)}. The core routes on + * {@link #id()} (the registry/routing key) and uses {@link #terminalId()} for session tracking; + * launcher-private coordinates beyond these are reachable through the concrete implementation. + * + *

A {@link PeerHandle} is returned after the peer process is live — the launcher + * has already completed subscription-guarded env/vfs setup, process start, and placement. The + * handle is a ticket the core exchanges for the running peer, not a lazy/delayed reference. + */ +public interface PeerHandle { + + /** + * The registry/routing key — an opaque, launcher-assigned identifier. For the herdr-backed + * launcher this is the herdr pane id; for other launchers it is whatever their transport + * uses. Guaranteed to be non-null and unique among live peers within a single daemon process. + */ + String id(); + + /** + * The transport-level session identifier used for message routing and presence tracking. + * For the herdr launcher this is the herdr terminal UUID. A non-herdr launcher may return + * its own analogous identifier, or {@code null} if the concept does not apply. + */ + default String terminalId() { + return null; + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java new file mode 100644 index 0000000..c987ab4 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java @@ -0,0 +1,85 @@ +package dev.ltms.bridged.peer; + +import java.util.List; +import java.util.Set; + +/** + * SPI for materializing a connected peer — the only way the bridge core creates or tears down + * a peer process. Every launcher is a first-party, in-tree adapter selected by (future) profile + * config; today's single adapter is the {@code WorkerService} / Claude Code over herdr. + * + *

The core delegates spawn and teardown to this interface without knowing how the peer is set + * up. Environment variables, CLI flags, subscription guards, transport (herdr tab/pane) layout, + * and naming conventions are all adapter-private — the core sees only the returned + * {@link PeerHandle} whose {@code id()} is the registry/routing key. + * + *

The interface is a superset of what {@code SessionManager} and {@code Bridged.main} call + * on the concrete launcher today. + */ +public interface PeerLauncher { + + /** + * The set of {@link Capability capabilities} this launcher declares. A peer whose profile + * opts into a git-forge token should include {@link Capability#SELF_PR}; the base set for + * the Claude Code herdr adapter is always {@code MID_TURN_ASK, WORKTREE, ORPHAN_REAP}. + */ + Set capabilities(); + + /** + * {@code profileName}/requestedCwd null/blank → default resolution. Returns after the peer + * process is live (env + argv + placement complete). Never returns {@code null}. + * + * @param req the spawn parameters (profile, requested cwd, caller cwd) + * @return a handle whose {@link PeerHandle#id()} is the registry/routing key + * @throws IllegalArgumentException if the profile is unknown and no default is configured + */ + PeerHandle spawn(SpawnRequest req); + + /** + * The configured worker profile names — the set of names {@code spawn(profileName)} accepts. + */ + Set profiles(); + + /** + * The profile a no-argument {@link #spawn(SpawnRequest)} uses, or {@code null} if none is configured. + */ + String defaultProfile(); + + /** + * Resolve the effective working directory for a spawn {@code req} without actually spawning. + * Resolution order: requestedCwd → profile cwd → callerCwd → daemon cwd. + * + * @return the resolved absolute path, never null/blank + */ + String effectiveCwd(SpawnRequest req); + + /** + * The parity-overlay file list for {@code profileName} (default list when unset). Used by + * worktree provisioning to copy config files into the isolated checkout before spawning. + */ + List parityOverlay(String profileName); + + /** + * The set of all agents this launcher currently tracks, transport-specific. Each element + * exposes at minimum a pane-like {@code id()} matching this launcher's {@link PeerHandle} + * scheme, plus transport-level status. Callers merge this set with the session registry to + * build a live roster view. + */ + List list(); + + /** + * Reap orphaned peers left behind by a prior daemon process. Only peers whose naming scheme + * matches this launcher's and whose nonce differs from the current process are eligible. + * Best-effort: a failure to list or to stop any one peer is logged and never aborts startup. + * + * @return the number of orphaned peers reaped + */ + int reapOrphanWorkers(); + + /** + * Tear a peer down by its registry/routing key ({@link PeerHandle#id()}). Tolerates an + * already-gone peer. Also cleans up launcher-private resources (e.g. empty dedicated tabs) + * when safe to do so. + */ + void stop(String id); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java new file mode 100644 index 0000000..7aab58a --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/SpawnRequest.java @@ -0,0 +1,13 @@ +package dev.ltms.bridged.peer; + +/** + * Parameters for a {@link PeerLauncher#spawn(SpawnRequest)} call — the peer-neutral + * aggregation of what the core knows at delegation time: which profile to use, the caller's + * requested working directory, and the caller's own cwd (to inherit when no other cwd is set). + * + *

A null or blank {@code profileName} means "use the launcher's default profile." + * A null or blank {@code requestedCwd} means "inherit from config or caller." + * A null {@code callerCwd} means "the request came from the daemon itself (not a primary)." + */ +public record SpawnRequest(String profileName, String requestedCwd, String callerCwd) { +} 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 f1883a2..83da133 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -3,7 +3,9 @@ package dev.ltms.bridged.session; import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.WorkerPresence; -import dev.ltms.bridged.worker.WorkerService; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.PeerLauncher; +import dev.ltms.bridged.peer.SpawnRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,8 +21,9 @@ import java.util.function.LongSupplier; /** * Authoritative in-daemon registry of the worker sessions this {@code bridged} process spawned. - * Wraps {@link WorkerService} (it still performs subscription-guarded spawn/teardown) and adds - * lifecycle tracking, ownership, and deterministic teardown on top. + * Delegates spawn/teardown to a {@link PeerLauncher} (which performs subscription-guarded env + * setup and process/materialization) and adds lifecycle tracking, ownership, and deterministic + * teardown on top. * *

The state machine is intentionally one-shot / no-reuse: every acquired worker is fresh, * and a finished or released worker is torn down, never pooled. {@link #recycle} is a convenience @@ -35,7 +38,7 @@ public final class SessionManager implements TurnListener { private static final Logger log = LoggerFactory.getLogger(SessionManager.class); - private final WorkerService workerService; + private final PeerLauncher launcher; private final Worktrees worktrees; private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); private final WorkerPresence presence; @@ -45,28 +48,28 @@ public final class SessionManager implements TurnListener { private final int contextCap; /** Backward-compatible constructor: shared-tree sessions, production git seam. */ - public SessionManager(WorkerService workerService) { - this(workerService, new GitWorktrees(), System::nanoTime, 0); + public SessionManager(PeerLauncher launcher) { + this(launcher, new GitWorktrees(), System::nanoTime, 0); } /** Backward-compatible constructor with an injectable worktree seam. */ - public SessionManager(WorkerService workerService, Worktrees worktrees) { - this(workerService, worktrees, System::nanoTime, 0); + public SessionManager(PeerLauncher launcher, Worktrees worktrees) { + this(launcher, worktrees, System::nanoTime, 0); } /** Test constructor with an injectable clock. */ - public SessionManager(WorkerService workerService, Worktrees worktrees, LongSupplier nowNanos) { - this(workerService, worktrees, nowNanos, 0); + public SessionManager(PeerLauncher launcher, Worktrees worktrees, LongSupplier nowNanos) { + this(launcher, worktrees, nowNanos, 0); } /** Production constructor with a configured context turn cap. */ - public SessionManager(WorkerService workerService, Worktrees worktrees, int contextCap) { - this(workerService, worktrees, System::nanoTime, contextCap); + public SessionManager(PeerLauncher launcher, Worktrees worktrees, int contextCap) { + this(launcher, worktrees, System::nanoTime, contextCap); } - public SessionManager(WorkerService workerService, Worktrees worktrees, LongSupplier nowNanos, + public SessionManager(PeerLauncher launcher, Worktrees worktrees, LongSupplier nowNanos, int contextCap) { - this.workerService = workerService; + this.launcher = launcher; this.worktrees = worktrees; this.presence = new PresenceBridge(this); this.nowNanos = nowNanos; @@ -101,15 +104,17 @@ public final class SessionManager implements TurnListener { public WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal, WorktreeRequest wt) { if (wt == null) { - Agent worker = workerService.spawn(profile, requestedCwd, callerCwd); + SpawnRequest req = new SpawnRequest(profile, requestedCwd, callerCwd); + PeerHandle handle = launcher.spawn(req); String resolvedProfile = (profile == null || profile.isBlank()) - ? workerService.defaultProfile() : profile; + ? launcher.defaultProfile() : profile; + String cwd = launcher.effectiveCwd(req); long now = nowNanos.getAsLong(); WorkerSession session = new WorkerSession( - worker.paneId(), - worker.terminalId(), + handle.id(), + handle.terminalId(), resolvedProfile, - resolveCwd(requestedCwd, profile, callerCwd), + cwd, ownerTerminal, now, now, @@ -117,9 +122,9 @@ public final class SessionManager implements TurnListener { 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()); + registry.put(handle.id(), session); + log.debug("acquired session id={} terminal={} profile={} owner={}", + handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal()); return session; } return acquireWithWorktree(profile, requestedCwd, callerCwd, ownerTerminal, wt); @@ -132,7 +137,7 @@ public final class SessionManager implements TurnListener { log.debug("releasing session pane={} terminal={} state={}", removed.paneId(), removed.terminalId(), removed.state()); } - workerService.stop(paneId); + launcher.stop(paneId); if (removed != null && removed.worktree() != null) { worktrees.remove(worktrees.repoRoot(removed.cwd()), removed.worktree()); } @@ -141,15 +146,15 @@ public final class SessionManager implements TurnListener { private WorkerSession acquireWithWorktree(String profile, String requestedCwd, String callerCwd, String ownerTerminal, WorktreeRequest wt) { String resolvedProfile = (profile == null || profile.isBlank()) - ? workerService.defaultProfile() : profile; + ? launcher.defaultProfile() : profile; String repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd)); String branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce(); String path = null; - Agent worker; + PeerHandle handle; try { path = worktrees.add(repoRoot, branch, wt.baseRef()); - worktrees.overlayParity(repoRoot, path, workerService.parityOverlay(resolvedProfile)); - worker = workerService.spawn(profile, path, callerCwd); + worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(resolvedProfile)); + handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd)); } catch (RuntimeException e) { if (path != null) { try { @@ -162,8 +167,8 @@ public final class SessionManager implements TurnListener { } long now = nowNanos.getAsLong(); WorkerSession session = new WorkerSession( - worker.paneId(), - worker.terminalId(), + handle.id(), + handle.terminalId(), resolvedProfile, resolveCwd(path, profile, callerCwd), ownerTerminal, @@ -173,9 +178,9 @@ public final class SessionManager implements TurnListener { 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()); + registry.put(handle.id(), session); + log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", + handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree()); return session; } @@ -386,7 +391,7 @@ public final class SessionManager implements TurnListener { } private String resolveCwd(String requestedCwd, String profileName, String callerCwd) { - return workerService.effectiveCwd(profileName, requestedCwd, callerCwd); + return launcher.effectiveCwd(new SpawnRequest(profileName, requestedCwd, callerCwd)); } /** WorkerPresence bridge that also drives the manager's READY transition. */ 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 96a734d..7b7fc50 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -8,11 +8,16 @@ import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.herdr.Tab; import dev.ltms.bridged.herdr.Workspace; import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.PeerLauncher; +import dev.ltms.bridged.peer.SpawnRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -37,7 +42,7 @@ import java.util.regex.Pattern; * clutter the user's real work spaces. Teardown removes the worker's pane and its * now-empty tab, tolerating an already-gone worker so a repeated DELETE is harmless. */ -public final class WorkerService { +public final class WorkerService implements PeerLauncher { private static final Logger log = LoggerFactory.getLogger(WorkerService.class); @@ -93,11 +98,13 @@ public final class WorkerService { } /** The configured worker profile names (what {@code spawn(profile)} accepts). */ + @Override public Set profiles() { return profiles.keySet(); } /** The parity-overlay file list for {@code profileName} (default list when unset). */ + @Override public List parityOverlay(String profileName) { String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName; if (name == null || name.isBlank()) { @@ -108,6 +115,7 @@ public final class WorkerService { } /** The profile a no-argument {@link #spawn()} uses, or {@code null} if none is configured. */ + @Override public String defaultProfile() { return defaultProfile; } @@ -318,6 +326,7 @@ public final class WorkerService { } /** All herdr-tracked agents — discovery for "what workers exist". */ + @Override public List list() { return agents.list(); } @@ -336,6 +345,7 @@ public final class WorkerService { * * @return the number of orphaned workers reaped */ + @Override public int reapOrphanWorkers() { List all; try { @@ -397,6 +407,7 @@ public final class WorkerService { * (repeated DELETE, crashed worker) is treated as success; any other failure propagates so * a genuinely failed teardown is not reported as done. */ + @Override public void stop(String paneId) { // Teardown knows only the paneId, not which profile spawned it. Attempt tab cleanup when any // profile uses tab placement (so the bridge may have created a dedicated worker tab); the @@ -426,6 +437,44 @@ public final class WorkerService { return e.code() != null && e.code().endsWith("_not_found"); } + // --- PeerLauncher SPI ------------------------------------------------------------------- + + @Override + public Set capabilities() { + Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP); + if (hasGitTokenProfile()) { + caps.add(Capability.SELF_PR); + } + return Set.copyOf(caps); + } + + /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */ + private boolean hasGitTokenProfile() { + return profiles.values().stream().anyMatch(BridgedConfig.Worker::hasGitToken); + } + + /** + * {@inheritDoc} + * + *

Delegates to the three-arg {@link #spawn(String, String, String)} and wraps the + * resulting herdr {@link Agent} in a {@link WorkerHandle} whose {@link PeerHandle#id()} + * equals the agent's paneId. + */ + @Override + public PeerHandle spawn(SpawnRequest req) { + Agent agent = spawn(req.profileName(), req.requestedCwd(), req.callerCwd()); + return new WorkerHandle(agent.paneId(), agent.terminalId()); + } + + /** A concrete {@link PeerHandle} wrapping herdr agent coordinates. */ + private record WorkerHandle(String id, String terminalId) implements PeerHandle { + } + + @Override + public String effectiveCwd(SpawnRequest req) { + return effectiveCwd(req.profileName(), req.requestedCwd(), req.callerCwd()); + } + private static void putIfPresent(Map m, String k, String v) { if (v != null && !v.isBlank()) { m.put(k, v); 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 200aefc..6cd5a4e 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -5,6 +5,9 @@ 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.peer.Capability; +import dev.ltms.bridged.peer.PeerHandle; +import dev.ltms.bridged.peer.SpawnRequest; import org.junit.jupiter.api.Test; import java.util.List; @@ -219,4 +222,100 @@ class WorkerServiceTest { FakeHerdr herdr = new FakeHerdr().healthy(false); // agent.list throws assertEquals(0, multiProfile(herdr).reapOrphanWorkers(), "a listing failure reaps nothing and does not throw"); } + + // --- PeerHandle indirection ---------------------------------------------------------------- + + @Test + void spawnReturnsPeerHandleWithIdEqualToPaneId() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + + PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null)); + + assertNotNull(handle, "spawn must return a non-null handle"); + assertEquals("w9:pW_1", handle.id(), "handle.id() must equal the agent's paneId"); + } + + @Test + void spawnReturnsPeerHandleWithCorrectTerminalId() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + + PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, "/caller")); + + assertEquals("term_new_1", handle.terminalId(), "handle.terminalId() must equal the agent's terminalId"); + } + + @Test + void capabilitiesIncludeMidTurnAskWorktreeOrphanReap() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + + Set caps = svc.capabilities(); + + assertTrue(caps.contains(Capability.MID_TURN_ASK), "every Claude Code peer supports mid-turn ask"); + assertTrue(caps.contains(Capability.WORKTREE), "every CLI peer supports worktree cwd"); + assertTrue(caps.contains(Capability.ORPHAN_REAP), "every herdr launcher supports orphan reap"); + } + + @Test + void capabilitiesIncludeSelfPrWhenProfileHasGitToken() { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Worker cfg = new BridgedConfig.Worker( + "impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", + List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null, + "GITEA_ACCESS_TOKEN", null); + WorkerService svc = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of("impl", cfg), "impl", + _ -> "tok"); + + assertTrue(svc.capabilities().contains(Capability.SELF_PR), + "a profile with a git token grants SELF_PR"); + } + + @Test + void capabilitiesExcludeSelfPrWhenNoGitToken() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + + assertFalse(svc.capabilities().contains(Capability.SELF_PR), + "no git token profile → no SELF_PR capability"); + } + + @Test + void effectiveCwdViaSpawnRequestMatchesExistingResolution() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + + String cwd = svc.effectiveCwd(new SpawnRequest("ltms-local", "/work/proj", "/caller/home")); + + assertEquals("/work/proj", cwd, "effectiveCwd via SpawnRequest must match the three-arg resolution"); + } + + @Test + void profilesViaPeerLauncherMatchesExistingApi() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = multiProfile(herdr); + + assertEquals(Set.of("gx10", "ollama"), svc.profiles(), "profiles() via PeerLauncher must match"); + } + + @Test + void defaultProfileViaPeerLauncherMatches() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = multiProfile(herdr); + + assertEquals("gx10", svc.defaultProfile(), "defaultProfile() via PeerLauncher must match"); + } + + @Test + void stopViaPeerLauncherTearsDownByHandleId() { + FakeHerdr herdr = new FakeHerdr(); + WorkerService svc = service(herdr, List.of("ccs", "ltms-local"), null); + PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null)); + + svc.stop(handle.id()); + + assertTrue(herdr.called("pane.close"), "stop via handle.id() must close the pane"); + } }