diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 17179f7..49a88dc 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -62,7 +62,8 @@ bind: # Two things stop the tab-name convention from becoming a way to claim leadership: the configured # member spaces are excluded from the scan, so nothing bridged places can land in a matching tab; # and startup REFUSES a `tabPrefix` that the fleet tabLabel template, or any per-profile `tabLabel` -# override, also matches — so the two namespaces cannot overlap by accident. +# override, also matches — so the two namespaces cannot overlap by accident. The label is a NAME, +# never a capability: what a pane may do is decided by the role the daemon resolves for it. # CB-551: IDLE-LEAD HEARTBEAT — nudge the single lead back to work when it has been continuously # idle (no open bridge_send driving it) past the quiet period. The fleet is one lead + architects + @@ -252,15 +253,28 @@ fleet: # # `tabPrefix` is the naming convention that finds a lead without pasting a terminal id: label the # tab `lead: ` when you open it and the pane is recognised on the next rescan. Reopen the - # tab later and the id changes; the label does not. bridged NEVER writes these labels — it renames - # member tabs but reads lead tabs read-only, so the tab bar always shows what you typed. + # tab later and the id changes; the label does not. + # + # A lead the daemon launches is labelled BY the daemon, using the same convention, so it is found + # by the same scan. A lead counts as live only when herdr also reports a running agent in that + # tab — a label left behind by a session that died does not block the relaunch. + # + # An auto-launched lead is NOT a member: it gets no worker reply charter, is never registered with + # the session lifecycle (the idle reaper would kill your orchestrator), and stays on the + # subscription — ANTHROPIC_BASE_URL/AUTH_TOKEN are stripped from its env whatever the profile says. # leaders: # opus-5.0: # profile: opus # omit to never create this lead, only recognise it - # instances: 1 # desired live count; only the shortfall is launched - # terminal: term_0123456789abcd # optional hand-pin; usually found by tabPrefix instead + # instances: 1 # desired live count; only the shortfall is launched. 0 = off + # terminal: term_0123456789abcd # optional hand-pin; usually found by tabPrefix instead. + # # A running agent on this terminal also counts as live, so a + # # lead you opened by hand is not relaunched under you. # tabPrefix: "lead:" # `lead: opus-5.0` ⇒ a lead named opus-5.0 (case-insensitive) # scanIntervalSeconds: 10 # rescan cadence, and the worst case before a new tab is seen + # workspace: leads # where a launched lead's tab is created (default "leads"). + # # MUST NOT be a member workspace — those are excluded from the + # # scan, so a lead placed in one is never found again. + # cwd: /path/to/repo # the launched lead's working directory (default: bridged's own) # kind: claude # descriptive; reported by bridge_whoami # gpt-sol-5.6: # terminal: term_fedcba9876543 diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 46b0c81..31678a9 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -6,6 +6,7 @@ import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.HerdrClient; import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.herdr.LeadTabScanner; +import dev.ltms.bridged.lead.LeadLauncher; import dev.ltms.bridged.herdr.PaneLocator; import dev.ltms.bridged.herdr.UnixSocketHerdrClient; import dev.ltms.bridged.herdr.WorkspaceControl; @@ -145,7 +146,8 @@ public final class Bridged { // the first thing that actually talks to herdr, so without this wait a boot-order race // would crash the daemon into a restart loop. Wait, then degrade rather than die: serving // with /healthz reporting "degraded" is strictly more useful than exiting. - if (awaitHerdr(herdr)) { + boolean herdrUp = awaitHerdr(herdr); + if (herdrUp) { // 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. workers.reapOrphanWorkers(); @@ -220,6 +222,17 @@ public final class Bridged { leads = () -> leadTerminals; } + // CB-558: start any declared lead that is not already running. After the scanner is built, + // because both read the same tab labels and the ordering makes that dependency visible; and + // only when herdr answered, because the launcher's whole safety property is that it can + // count live leads first — it must never guess and risk a second orchestrator. + if (herdrUp && !leaders.isEmpty()) { + int launched = new LeadLauncher(agents, spaces, cfg).ensureLeads(); + if (launched > 0) { + log.info("lead auto-launch: {} lead(s) started", launched); + } + } + // CB-548: config-declared architect slots. Config supplies only the stable name → profile // map; the terminal → slot binding is owned by the registry and is empty at startup, so no // pane resolves to an architect until the later spawn lifecycle binds one. The registry is 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 2e6015c..b513bc8 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -439,18 +439,40 @@ public record BridgedConfig( */ @JsonIgnoreProperties(ignoreUnknown = true) public record Leader(String profile, String terminal, Integer instances, String tabPrefix, - Integer scanIntervalSeconds, String kind, String model) { + Integer scanIntervalSeconds, String kind, String model, + String workspace, String cwd) { + + /** + * Where an auto-launched lead's tab is created (CB-558). It must NOT be a member workspace: + * {@code LeadTabScanner} excludes those wholesale, so a lead placed in one would never be + * discovered and the daemon would relaunch it on every boot. + */ + public static final String DEFAULT_WORKSPACE = "leads"; + public Leader { instances = (instances == null || instances < 0) ? 1 : instances; tabPrefix = (tabPrefix == null || tabPrefix.isBlank()) ? "lead:" : tabPrefix.strip(); scanIntervalSeconds = (scanIntervalSeconds == null || scanIntervalSeconds <= 0) ? 10 : scanIntervalSeconds; + workspace = (workspace == null || workspace.isBlank()) + ? DEFAULT_WORKSPACE : workspace.strip(); + } + + /** Back-compat 7-arg form — no workspace or cwd, so both take their defaults. */ + public Leader(String profile, String terminal, Integer instances, String tabPrefix, + Integer scanIntervalSeconds, String kind, String model) { + this(profile, terminal, instances, tabPrefix, scanIntervalSeconds, kind, model, null, null); } /** True when this lead may be launched by the daemon rather than only recognised. */ public boolean isCreatable() { return profile != null && !profile.isBlank() && instances > 0; } + + /** The tab label an auto-launched instance of this lead gets — what the scanner reads back. */ + public String tabLabel(String name) { + return tabPrefix + " " + name; + } } /** diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/LeadTabScanner.java b/bridged/src/main/java/dev/ltms/bridged/herdr/LeadTabScanner.java index 92deac6..522d581 100644 --- a/bridged/src/main/java/dev/ltms/bridged/herdr/LeadTabScanner.java +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/LeadTabScanner.java @@ -26,15 +26,26 @@ import java.util.function.Supplier; *

Direction of trust. The label names the lead; it never grants * anything a pane could take for itself. Three properties keep that honest: *

    - *
  1. bridged never renames a lead tab. The operator's label is read-only input, so what is in - * the tab bar is always what the human wrote — no round-trip where the daemon's own rename - * becomes the evidence for its next decision.
  2. *
  3. Worker spaces are excluded wholesale ({@code excludedWorkspaceLabels}), so a worker cannot * become a lead by being placed — as a split, say — inside a matching tab.
  4. *
  5. A worker cannot rename a tab: {@code tab.rename} is reachable only through * {@link WorkspaceControl}, which no {@code bridge_*} tool exposes. The label is writable by * the human at the terminal and by nobody the bridge is defending against.
  6. + *
  7. The label is a name, not a capability. What a pane may do is decided by + * {@code Authz} against the role {@code CallerResolver} returns; a tab that calls itself a + * lead still cannot act as one unless the daemon's own registry agrees.
  8. *
+ * + *

CB-558 — bridged now writes lead labels too. This class used to be able to say + * that bridged never renames a lead tab, so the label was always the human's own writing and there + * was no round-trip from the daemon's rename back into its next decision. + * {@code dev.ltms.bridged.lead.LeadLauncher} ends that: an auto-launched lead is labelled by the + * daemon and found again by this scan. The trust direction above is unaffected — bridged writing a + * name for a lead it just started is not a pane promoting itself — but staleness becomes + * real: a label left behind by a session that has since died would read as a live lead forever. + * This scanner does not solve that (its job is naming, and a stale name costs nothing here); the + * launcher does, by requiring a running agent in the tab before it counts the lead as live. If you + * ever make a decision that removes something based on this map, add the same check. * The remaining hazard is an operator one — a worker {@code tabLabel} template that * happens to start with the same prefix would promote the whole fleet — and that is refused at * startup by {@code BridgedConfig.validateLeadScan} rather than documented here. diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java b/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java index 69ed9a5..ce2c1fc 100644 --- a/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java @@ -41,6 +41,16 @@ public final class WorkspaceControl { return out; } + /** Every tab in {@code workspaceId}, in herdr's order. */ + public List listTabs(String workspaceId) { + JsonNode result = herdr.call("tab.list", Map.of("workspace_id", workspaceId)); + List out = new ArrayList<>(); + for (JsonNode t : result.path("tabs")) { + out.add(Tab.from(t)); + } + return out; + } + /** The first workspace with this exact label, if any. */ public Optional findByLabel(String label) { return listWorkspaces().stream() diff --git a/bridged/src/main/java/dev/ltms/bridged/lead/LeadLauncher.java b/bridged/src/main/java/dev/ltms/bridged/lead/LeadLauncher.java new file mode 100644 index 0000000..0693893 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/lead/LeadLauncher.java @@ -0,0 +1,304 @@ +package dev.ltms.bridged.lead; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.Agent; +import dev.ltms.bridged.herdr.AgentControl; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Starts the leads {@code fleet.leaders:} declares, when none is already running (CB-558). + * + *

A lead is not a member, and this class exists to keep it that way. Every other + * spawn path in the daemon goes through {@code HerdrPeerLauncher}, which does three things a lead + * must never receive: + *

    + *
  1. it appends the reply charter — "you are an off-subscription worker … end every turn + * with {@code bridge_reply}". A lead is the orchestrator; telling it that it is a worker is + * exactly backwards.
  2. + *
  3. it registers the session with {@code SessionManager}, which subjects it to the idle reaper, + * the context cap and the shutdown drain. An idle lead is the normal state of a lead, so the + * reaper would kill the orchestrator for doing its job.
  4. + *
  5. it can move a peer off the subscription via {@code ANTHROPIC_BASE_URL}. A lead stays on the + * operator's subscription, always.
  6. + *
+ * So this launcher talks to {@link AgentControl}/{@link WorkspaceControl} directly. The duplication + * with the member launchers (argv and env assembly) is deliberate and is the cheaper half of the + * trade: entangling the worker path with a not-a-worker case is how the three rules above get + * broken later, quietly. + * + *

Liveness, and the label round-trip. {@code LeadTabScanner} used to be able to + * promise that bridged never writes a lead label. That is no longer true — an auto-launched lead is + * labelled by this class, and the scanner reads that label back. The risk this opens is not + * privilege escalation (the tab label never granted anything a pane could take for itself; see that + * class's javadoc), but staleness: a label left behind by a crashed session would otherwise + * read as a live lead forever, and the lead would never be relaunched. So a lead counts as live only + * when herdr also reports a running agent in that tab — see {@link #liveLeads}. A labelled + * tab with no agent in it is not a lead. + */ +public final class LeadLauncher { + + private static final Logger log = LoggerFactory.getLogger(LeadLauncher.class); + + private final AgentControl agents; + private final WorkspaceControl spaces; + private final BridgedConfig cfg; + + /** + * @param agents herdr agent control (start, list) + * @param spaces workspace / tab control (ensure, create, label, list) + * @param cfg the loaded config — {@code fleet.leaders}, {@code profiles} and the lead pins + */ + public LeadLauncher(AgentControl agents, WorkspaceControl spaces, BridgedConfig cfg) { + this.agents = agents; + this.spaces = spaces; + this.cfg = cfg; + } + + /** + * Bring every declared lead up to its {@code instances} count, and return how many were started. + * + *

Never throws: a daemon that cannot start a lead must still serve. herdr being unreachable, + * a profile that does not exist, a failed {@code agent.start} — each is logged and skipped, and + * the remaining leads are still attempted. + */ + public int ensureLeads() { + Map leaders = cfg.fleet().leaders(); + if (leaders.isEmpty()) { + return 0; + } + + Map live; + try { + live = liveLeads(leaders); + } catch (HerdrException e) { + // Counting is the whole safety mechanism against double-spawning. If we cannot count, we + // must not guess — spawning a second orchestrator is worse than starting none. + log.warn("lead auto-launch skipped — cannot tell which leads are live: {}", e.getMessage()); + return 0; + } + + int started = 0; + for (Map.Entry e : leaders.entrySet()) { + String name = e.getKey(); + BridgedConfig.Leader lead = e.getValue(); + int running = live.getOrDefault(name, 0); + int wanted = lead.instances(); + + if (running >= wanted) { + log.info("lead '{}': {} live, {} wanted — nothing to start", name, running, wanted); + continue; + } + if (!lead.isCreatable()) { + // A lead with a `terminal:` pin and no `profile:` is recognise-only by design: the + // operator opens it by hand. Say so once rather than looking like a silent failure. + log.info("lead '{}' is not live, and names no profile — it can be recognised but not " + + "launched. Add `profile:` under fleet.leaders.{} to have bridged start it.", + name, name); + continue; + } + + BridgedConfig.Profile profile = cfg.profiles().get(lead.profile()); + if (profile == null) { + log.warn("lead '{}' names profile '{}', which is not configured — not launching", + name, lead.profile()); + continue; + } + + for (int i = running; i < wanted; i++) { + if (launch(name, lead, profile)) { + started++; + } + } + } + return started; + } + + /** + * How many live leads exist per configured name. + * + *

Two independent pieces of evidence, because either alone double-spawns: + *

+ * Member workspaces are excluded, exactly as the scanner excludes them: a member must not be + * counted as a lead because it happens to sit in a matching tab. + */ + private Map liveLeads(Map leaders) { + Set memberSpaces = cfg.profiles().values().stream() + .map(BridgedConfig.Profile::workspace) + .filter(w -> w != null && !w.isBlank()) + .collect(Collectors.toSet()); + + // tabId → the lead name its label declares. + Map nameByTab = new LinkedHashMap<>(); + for (Workspace ws : spaces.listWorkspaces()) { + if (ws.workspaceId() == null || memberSpaces.contains(ws.label())) { + continue; + } + for (Tab tab : spaces.listTabs(ws.workspaceId())) { + String declared = leadNameOf(tab.label(), leaders); + if (declared != null && tab.tabId() != null) { + nameByTab.put(tab.tabId(), declared); + } + } + } + + // terminalId → the lead name the config pins it to. + Map nameByPinnedTerminal = new LinkedHashMap<>(); + leaders.forEach((name, lead) -> { + if (lead.terminal() != null && !lead.terminal().isBlank()) { + nameByPinnedTerminal.put(lead.terminal().strip(), name); + } + }); + + Map counts = new LinkedHashMap<>(); + for (Agent a : agents.list()) { + String name = nameByTab.get(a.tabId()); + if (name == null) { + name = nameByPinnedTerminal.get(a.terminalId()); + } + if (name != null) { + counts.merge(name, 1, Integer::sum); + } + } + return counts; + } + + /** + * The configured lead a tab label names, or {@code null} for a label that names none. + * + *

Matched against the declared lead names rather than by splitting on the prefix, so an + * operator's {@code "lead: something-else"} tab is not mistaken for a configured lead. + */ + private String leadNameOf(String label, Map leaders) { + if (label == null) { + return null; + } + String l = label.strip(); + for (Map.Entry e : leaders.entrySet()) { + if (l.equalsIgnoreCase(e.getValue().tabLabel(e.getKey()).strip())) { + return e.getKey(); + } + } + return null; + } + + /** Start one lead. Returns false (having logged) rather than throwing on any failure. */ + private boolean launch(String name, BridgedConfig.Leader lead, BridgedConfig.Profile profile) { + String label = lead.tabLabel(name); + String cwd = (lead.cwd() == null || lead.cwd().isBlank()) + ? System.getProperty("user.dir") : lead.cwd(); + + Tab.Created tab = null; + try { + Workspace ws = spaces.ensureWorkspace(lead.workspace()); + tab = spaces.createTab(ws.workspaceId(), cwd, leadEnv(profile)); + if (tab.rootPaneId() == null) { + throw new IllegalStateException("tab " + tab.tab().tabId() + + " came back with no seed pane — nowhere to start the lead"); + } + // Same shape as the member launchers: herdr resolves the executable from `kind`, so + // argv[0] (the configured launcher, e.g. `ccs`) is dropped and only the rest is passed. + List argv = leadArgv(profile); + Agent started = agents.start("lead-" + name, herdrKind(profile), + argv.isEmpty() ? argv : argv.subList(1, argv.size()), tab.rootPaneId()); + + // Label AFTER the start succeeds. A label written before would survive a failed start + // and then read back as a live lead on the next boot, which is the exact staleness the + // agent-liveness check exists to prevent — no need to create the case ourselves. + spaces.renameTab(tab.tab().tabId(), label); + + log.info("lead '{}' launched: profile={} tab={} pane={} terminal={} label='{}' cwd={}", + name, profile.profile(), tab.tab().tabId(), started.paneId(), + started.terminalId(), label, cwd); + return true; + } catch (RuntimeException e) { + log.warn("lead '{}' failed to launch on profile '{}': {}", + name, profile.profile(), e.getMessage()); + if (tab != null && tab.tab() != null && tab.tab().tabId() != null) { + try { + spaces.closeTab(tab.tab().tabId()); + } catch (RuntimeException cleanup) { + log.warn("could not close the orphaned lead tab {}: {}", + tab.tab().tabId(), cleanup.getMessage()); + } + } + return false; + } + } + + /** + * The herdr agent kind for this profile — the same value the matching member adapter passes, so + * herdr resolves the same executable for a lead as it does for a member on that backend. + */ + private static String herdrKind(BridgedConfig.Profile profile) { + return profile.isOpenCode() ? "opencode" : "claude"; + } + + /** + * The lead's argv: the profile's own command, the model pin, and the bridge MCP mount. + * + *

No {@code --append-system-prompt}. That flag carries the worker reply charter, and a lead + * is not a worker — it reads its orchestration rules from the project's {@code CLAUDE.md} like + * any other primary. This is the single most important difference from the member launchers; + * do not "unify" it back. + */ + private List leadArgv(BridgedConfig.Profile profile) { + List argv = new ArrayList<>(profile.argv()); + if (profile.hasMcp()) { + argv.add("--mcp-config"); + argv.add("{\"mcpServers\":{\"bridge\":{\"type\":\"http\",\"url\":\"" + + profile.mcpUrl() + "\"}}}"); + } + // Appended last, for the same reason the member launcher does it (CB-533): the argv is + // usually a wrapper such as `ccs `, which exports its own model family over + // whatever it inherited, and --model outranks the environment. + if (profile.model() != null && !profile.model().isBlank()) { + argv.add("--model"); + argv.add(profile.model()); + } + return argv; + } + + /** + * The lead's environment: the profile's {@code env:} block, and nothing that could move it off + * the subscription. + * + *

{@code ANTHROPIC_BASE_URL} and {@code ANTHROPIC_AUTH_TOKEN} are stripped unconditionally — + * not defaulted, not guarded, stripped. A lead runs on the operator's subscription by + * definition, so there is no configuration under which pointing it elsewhere is correct, and a + * profile that carries them (a member profile reused as a lead's backend) must not leak them in. + */ + private Map leadEnv(BridgedConfig.Profile profile) { + Map out = new LinkedHashMap<>(); + if (profile.env() != null) { + out.putAll(profile.env()); + } + out.remove("ANTHROPIC_BASE_URL"); + out.remove("ANTHROPIC_AUTH_TOKEN"); + if (profile.configDir() != null && !profile.configDir().isBlank()) { + out.put("CLAUDE_CONFIG_DIR", profile.configDir()); + } + // Deliberately no git token: a lead reviews and merges through the operator's own + // credentials, and never needs the scoped write:repository token a member is granted. + out.values().removeIf(Objects::isNull); + return out; + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java index fe57395..2ec0c5a 100644 --- a/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Recording fake {@link HerdrClient} for unit/acceptance tests. Returns canned frames @@ -24,6 +26,8 @@ public final class FakeHerdr implements HerdrClient { private boolean healthy = true; private final List extraWorkspaces = new ArrayList<>(); private final List extraAgents = new ArrayList<>(); + /** workspaceId → extra tabs that {@code tab.list} reports for it (CB-558 lead scans). */ + private final Map> extraTabs = new LinkedHashMap<>(); private int agentNameTakenFor = 0; private int agentPaneBusyFor = 0; private int workerTabPaneCount = 1; @@ -109,6 +113,18 @@ public final class FakeHerdr implements HerdrClient { return this; } + /** + * Seed a labelled tab into {@code tab.list} for one workspace (e.g. an existing {@code lead: x} + * tab). Pair it with {@link #withAgent} on the same {@code tabId} to make the lead live; + * seeding the tab alone models the stale-label case. + */ + public FakeHerdr withTab(String workspaceId, String tabId, String label) { + extraTabs.computeIfAbsent(workspaceId, _ -> new ArrayList<>()) + .add(("{\"tab_id\":\"%s\",\"workspace_id\":\"%s\",\"label\":\"%s\",\"pane_count\":1}") + .formatted(tabId, workspaceId, label)); + return this; + } + /** Seed an additional workspace into {@code workspace.list} (e.g. a pre-existing worker space). */ public FakeHerdr withWorkspace(String id, String label) { extraWorkspaces.add(("{\"workspace_id\":\"%s\",\"label\":\"%s\",\"focused\":false," @@ -228,11 +244,19 @@ public final class FakeHerdr implements HerdrClient { case "tab.rename" -> mapper.readTree(""" {"type":"tab_info","tab":{"tab_id":"w9:t2","workspace_id":"w9", "label":"worker: ltms-local","pane_count":1}}"""); - case "tab.list" -> mapper.readTree((""" + case "tab.list" -> { + // The base pair is returned for every workspace, exactly as before. Seeded tabs + // are appended only for the workspace they were registered against, so a test + // that seeds none sees the historical response byte for byte. + Object wsId = params instanceof java.util.Map m ? m.get("workspace_id") : null; + List seeded = extraTabs.getOrDefault(String.valueOf(wsId), List.of()); + yield mapper.readTree((""" {"type":"tab_list","tabs":[ {"tab_id":"w9:t1","workspace_id":"w9","label":"1","pane_count":1}, - {"tab_id":"w9:t2","workspace_id":"w9","label":"worker: ltms-local","pane_count":%d}]}""") - .formatted(workerTabPaneCount)); + {"tab_id":"w9:t2","workspace_id":"w9","label":"worker: ltms-local","pane_count":%d}%s]}""") + .formatted(workerTabPaneCount, + seeded.isEmpty() ? "" : "," + String.join(",", seeded))); + } case "tab.close" -> mapper.readTree("{\"type\":\"ok\"}"); case "pane.get" -> mapper.readTree(""" {"type":"pane_info","pane":{"pane_id":"w9:pW","workspace_id":"w9", diff --git a/bridged/src/test/java/dev/ltms/bridged/lead/LeadLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/lead/LeadLauncherTest.java new file mode 100644 index 0000000..03f8bb6 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/lead/LeadLauncherTest.java @@ -0,0 +1,255 @@ +package dev.ltms.bridged.lead; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CB-558 — the daemon starts a declared lead when none is running. + * + *

Two properties carry the whole feature. It must not double-spawn (a second orchestrator is + * worse than none), and what it starts must be a lead and not a member: no reply charter, + * no off-subscription env, and never registered with the session lifecycle. + */ +class LeadLauncherTest { + + /** The lead's backend: a subscription profile with the bridge mounted, as `opus` really is. */ + private static BridgedConfig.Profile opusProfile() { + return new BridgedConfig.Profile( + "opus", null, "claude-opus-5", null, "BRIDGED_WORKER_TOKEN", + List.of("ccs", "ltms"), "tab", "bridged-workers", null, + "http://127.0.0.1:8765/mcp", null, null, + null, null, null, + Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "300000"), null, null, true); + } + + private static BridgedConfig configWith(BridgedConfig.Leader lead) { + Map leaders = new LinkedHashMap<>(); + leaders.put("opus", lead); + BridgedConfig.Fleet fleet = + new BridgedConfig.Fleet(leaders, Map.of(), Map.of(), Map.of(), null); + return new BridgedConfig( + null, null, Map.of("opus", opusProfile()), null, null, null, null, null, + null, null, fleet, null, "fixed", null).withDefaults(); + } + + private static BridgedConfig.Leader lead(String profile, String terminal, int instances) { + return new BridgedConfig.Leader(profile, terminal, instances, "lead:", 10, null, null, + "leads", "/repo"); + } + + private static LeadLauncher launcher(FakeHerdr herdr, BridgedConfig cfg) { + return new LeadLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), cfg); + } + + @SuppressWarnings("unchecked") + private static List startedArgs(FakeHerdr herdr) { + return (List) ((Map) herdr.lastCall("agent.start").params()).get("args"); + } + + private static String startedName(FakeHerdr herdr) { + return (String) ((Map) herdr.lastCall("agent.start").params()).get("name"); + } + + @SuppressWarnings("unchecked") + private static Map tabEnv(FakeHerdr herdr) { + return (Map) ((Map) herdr.lastCall("tab.create").params()).get("env"); + } + + // ── it starts a lead when none is live ──────────────────────────────────────────────────── + + @Test + void startsTheDeclaredLeadWhenNoneIsRunning() { + FakeHerdr herdr = new FakeHerdr(); + + assertEquals(1, launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads()); + assertTrue(herdr.called("agent.start"), "a lead must actually be started"); + assertEquals("lead-opus", startedName(herdr)); + } + + /** The tab is labelled so the scanner finds the lead on the next resolve. */ + @Test + void labelsTheTabWithThePrefixTheScannerReadsBack() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(); + + assertEquals("lead: opus", + ((Map) herdr.lastCall("tab.rename").params()).get("label")); + } + + /** `instances: 2` with none live means two starts, not one. */ + @Test + void startsAsManyInstancesAsAreDeclared() { + FakeHerdr herdr = new FakeHerdr(); + + assertEquals(2, launcher(herdr, configWith(lead("opus", null, 2))).ensureLeads()); + assertEquals(2, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count()); + } + + // ── it must not double-spawn ────────────────────────────────────────────────────────────── + + /** A labelled tab WITH a running agent in it is a live lead — leave it alone. */ + @Test + void doesNotStartASecondLeadWhenOneIsAlreadyRunning() { + FakeHerdr herdr = new FakeHerdr() + .withWorkspace("wL", "leads") + .withTab("wL", "wL:t1", "lead: opus") + .withAgent("lead-opus", "term_lead", "wL:p1", "wL:t1"); + + assertEquals(0, launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads()); + assertFalse(herdr.called("agent.start"), "the live lead must not be duplicated"); + } + + /** + * The reason liveness is not "does the label exist". A tab left labelled by a session that has + * since died must not block the relaunch, or one crash disables auto-launch permanently. + */ + @Test + void aLabelledTabWithNoRunningAgentIsNotALiveLead() { + FakeHerdr herdr = new FakeHerdr() + .withWorkspace("wL", "leads") + .withTab("wL", "wL:t1", "lead: opus"); // label only — nothing running in it + + assertEquals(1, launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(), + "a stale label is not a lead; the lead must be relaunched"); + } + + /** + * A lead the operator opened by hand and pinned with `terminal:` is live even though its tab + * carries no matching label. Counting labels alone would relaunch it on every boot. + */ + @Test + void aPinnedTerminalWithARunningAgentCountsAsLive() { + FakeHerdr herdr = new FakeHerdr() + .withAgent("hand-opened", "term_pinned", "wX:p1", "wX:t1"); + + assertEquals(0, launcher(herdr, configWith(lead("opus", "term_pinned", 1))).ensureLeads()); + assertFalse(herdr.called("agent.start")); + } + + /** A member sitting in a matching tab must never be counted — or spawn a lead — as one. */ + @Test + void aMemberWorkspaceIsNeverScannedForLeads() { + FakeHerdr herdr = new FakeHerdr() + .withWorkspace("wM", "bridged-workers") // a configured member space + .withTab("wM", "wM:t1", "lead: opus") // a member tab that looks like a lead + .withAgent("claude-opus-x", "term_m", "wM:p1", "wM:t1"); + + assertEquals(1, launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(), + "a member in a lead-labelled tab is not a lead, so the real lead is still missing"); + } + + /** If herdr cannot be counted, start nothing: guessing risks a second orchestrator. */ + @Test + void anUncountableHerdrStartsNothing() { + FakeHerdr herdr = new FakeHerdr().healthy(false); + + assertEquals(0, launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads()); + assertFalse(herdr.called("agent.start")); + } + + // ── what it starts is a LEAD, not a member ──────────────────────────────────────────────── + + /** + * The single most important assertion here. The worker charter tells its reader it is an + * off-subscription worker that must end every turn with bridge_reply — the opposite of what an + * orchestrator is. A lead must never receive it. + */ + @Test + void theLeadNeverReceivesTheWorkerReplyCharter() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(); + + List args = startedArgs(herdr); + assertFalse(args.contains("--append-system-prompt"), + "the reply charter is a worker contract and must not be injected into a lead"); + assertTrue(args.stream().noneMatch(a -> a.contains("bridge_reply")), args.toString()); + } + + /** It still mounts the bridge — a lead that cannot orchestrate is pointless. */ + @Test + void theLeadMountsTheBridgeMcpAndPinsItsModel() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(); + + List args = startedArgs(herdr); + assertTrue(args.contains("--mcp-config")); + assertTrue(args.stream().anyMatch(a -> a.contains("http://127.0.0.1:8765/mcp")), args.toString()); + assertEquals("claude-opus-5", args.get(args.indexOf("--model") + 1)); + assertTrue(args.indexOf("--model") > args.indexOf("--mcp-config"), + "--model is appended last so it outranks the ccs wrapper (CB-533)"); + } + + /** A lead runs on the operator's subscription. Nothing may move it off. */ + @Test + void theLeadEnvCarriesNoAnthropicBinding() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(); + + Map env = tabEnv(herdr); + assertNull(env.get("ANTHROPIC_BASE_URL")); + assertNull(env.get("ANTHROPIC_AUTH_TOKEN")); + assertEquals("300000", env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"), + "the profile's own env: still applies"); + } + + /** The lead's tab goes in its own workspace, never a member one — the scanner skips those. */ + @Test + void theLeadTabIsCreatedOutsideEveryMemberWorkspace() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, configWith(lead("opus", null, 1))).ensureLeads(); + + String label = (String) ((Map) herdr.lastCall("workspace.create").params()).get("label"); + assertEquals("leads", label); + assertNotEquals("bridged-workers", label); + } + + // ── recognise-only and misconfiguration ─────────────────────────────────────────────────── + + /** A lead with a pin but no profile is recognise-only by design — not an error, not a launch. */ + @Test + void aLeadThatNamesNoProfileIsRecognisedButNeverLaunched() { + FakeHerdr herdr = new FakeHerdr(); + + assertEquals(0, launcher(herdr, configWith(lead(null, "term_dead", 1))).ensureLeads()); + assertFalse(herdr.called("agent.start")); + } + + /** `instances: 0` is a deliberate off switch. */ + @Test + void zeroInstancesLaunchesNothing() { + FakeHerdr herdr = new FakeHerdr(); + + assertEquals(0, launcher(herdr, configWith(lead("opus", null, 0))).ensureLeads()); + assertFalse(herdr.called("agent.start")); + } + + /** A profile name with no matching profile is logged and skipped, never a daemon crash. */ + @Test + void anUnknownProfileIsSkippedRatherThanThrown() { + FakeHerdr herdr = new FakeHerdr(); + + assertEquals(0, launcher(herdr, configWith(lead("nope", null, 1))).ensureLeads()); + assertFalse(herdr.called("agent.start")); + } + + /** No leads declared at all: not a herdr call in sight. */ + @Test + void noLeadersConfiguredTouchesHerdrNotAtAll() { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig cfg = new BridgedConfig( + null, null, Map.of("opus", opusProfile()), null, null, null, null, null, + null, null, null, null, "fixed", null).withDefaults(); + + assertEquals(0, launcher(herdr, cfg).ensureLeads()); + assertTrue(herdr.calls.isEmpty(), "nothing declared ⇒ nothing scanned"); + } +}