57f8fa257a
`fleet.leaders.<name>.instances` was descriptive. Now the daemon reads it: a
lead that names a `profile:` is started when fewer than `instances` are running.
A lead with only a `terminal:` stays recognise-only, as before.
A lead is not a member, and LeadLauncher exists to keep it that way. Every other
spawn path goes through HerdrPeerLauncher, which does three things a lead must
never get: it appends the worker reply charter ("you are an off-subscription
worker … end every turn with bridge_reply" — the opposite of an orchestrator);
it registers the session with SessionManager, whose idle reaper would kill a
lead for being idle, which is a lead's normal state; and it can move a peer off
the subscription. So this launcher talks to AgentControl/WorkspaceControl
directly. The duplicated argv/env assembly is the cheaper half of that trade.
Not double-spawning is the safety property, so liveness needs two pieces of
evidence. A running agent in a tab labelled `lead: <name>` finds an
auto-launched lead. A running agent on a pinned `terminal:` finds one the
operator opened by hand — without it, a pinned lead whose tab carries no
matching label would be relaunched on every boot. Member workspaces are
excluded, so a member in a matching tab is never counted. If herdr cannot be
reached, nothing is started: a second orchestrator is worse than none.
Liveness deliberately requires the AGENT, not just the label. LeadTabScanner
used to promise that bridged never writes a lead label, so there was no
round-trip from the daemon's own rename back into its next decision. That is no
longer true, and its javadoc now says so. The trust direction is unaffected — a
label is a name, not a capability — but staleness becomes real: a label left by
a crashed session would otherwise read as a live lead forever and disable
auto-launch permanently.
Two new knobs. `workspace:` (default "leads") is where a launched lead's tab
goes; it must not be a member workspace, because those are excluded from the
scan and a lead placed in one would never be found again. `cwd:` defaults to
bridged's own working directory.
Also: WorkspaceControl.listTabs, and a FakeHerdr tab seeder that leaves the
canned response byte-identical when no tab is seeded.
617 tests pass (16 new), IDE-clean.
299 lines
16 KiB
Java
299 lines
16 KiB
Java
package dev.ltms.bridged.herdr;
|
|
|
|
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
|
|
* matching the real herdr 0.8.0 daemon (protocol 19) and records every call so tests can
|
|
* assert both behaviour and that guard-blocked paths never reached herdr.
|
|
*/
|
|
public final class FakeHerdr implements HerdrClient {
|
|
|
|
public record Call(String method, Object params) {
|
|
}
|
|
|
|
/** The foreground PID of the one agent pane (term_a) in the canned {@code pane.process_info}. */
|
|
public static final long WORKER_PID = 4242;
|
|
|
|
private final ObjectMapper mapper = new ObjectMapper();
|
|
public final List<Call> calls = new ArrayList<>();
|
|
private boolean healthy = true;
|
|
private final List<String> extraWorkspaces = new ArrayList<>();
|
|
private final List<String> extraAgents = new ArrayList<>();
|
|
/** workspaceId → extra tabs that {@code tab.list} reports for it (CB-558 lead scans). */
|
|
private final Map<String, List<String>> extraTabs = new LinkedHashMap<>();
|
|
private int agentNameTakenFor = 0;
|
|
private int agentPaneBusyFor = 0;
|
|
private int workerTabPaneCount = 1;
|
|
private String paneCloseErrorCode = null;
|
|
private String agentSendErrorCode = null;
|
|
private volatile String agentStatus = "idle"; // steady-state agent.get status
|
|
private volatile String readText = "worker transcript tail"; // canned agent.read output
|
|
private int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane
|
|
private String pinnedStartTerminal;
|
|
private String pinnedStartPane;
|
|
|
|
public FakeHerdr healthy(boolean h) {
|
|
this.healthy = h;
|
|
return this;
|
|
}
|
|
|
|
/** Reject the first {@code n} {@code agent.start} calls with {@code agent_name_taken}. */
|
|
public FakeHerdr agentNameTakenTimes(int n) {
|
|
this.agentNameTakenFor = n;
|
|
return this;
|
|
}
|
|
|
|
/** Reject the first {@code n} {@code agent.start} calls with {@code agent_pane_busy}. */
|
|
public FakeHerdr agentPaneBusyTimes(int n) {
|
|
this.agentPaneBusyFor = n;
|
|
return this;
|
|
}
|
|
|
|
/** Make the worker tab (w9:t2) report this many panes in {@code tab.list} (default 1). */
|
|
public FakeHerdr withWorkerTabPaneCount(int n) {
|
|
this.workerTabPaneCount = n;
|
|
return this;
|
|
}
|
|
|
|
/** Make {@code pane.close} fail with this herdr error code. */
|
|
public FakeHerdr paneCloseFailsWith(String code) {
|
|
this.paneCloseErrorCode = code;
|
|
return this;
|
|
}
|
|
|
|
/** Set the {@code agent_status} that {@code agent.get} reports (drives the injector). */
|
|
public FakeHerdr agentStatus(String status) {
|
|
this.agentStatus = status;
|
|
return this;
|
|
}
|
|
|
|
/** The text {@code agent.read} returns (the CB-106 completion scrape). */
|
|
public FakeHerdr readText(String text) {
|
|
this.readText = text;
|
|
return this;
|
|
}
|
|
|
|
/** Make delivery ({@code agent.prompt} / {@code agent.send_keys}) fail with this error code. */
|
|
public FakeHerdr agentSendFailsWith(String code) {
|
|
this.agentSendErrorCode = code;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Force the next {@code n} {@code agent.start} calls to report this terminal/pane coordinate,
|
|
* instead of the fake's usual incrementing {@code term_new_n}/{@code w9:pRoot_n}. Lets a test make
|
|
* two spawns report the <em>same</em> herdr pane, to prove the host-unique id (CB-519) never
|
|
* collides on that coordinate.
|
|
*/
|
|
public FakeHerdr pinNextStarts(int n, String terminalId, String paneId) {
|
|
this.pinnedStarts = n;
|
|
this.pinnedStartTerminal = terminalId;
|
|
this.pinnedStartPane = paneId;
|
|
return this;
|
|
}
|
|
|
|
|
|
/**
|
|
* Seed a named agent into {@code agent.list} (e.g. an orphaned worker for CB-117 reaper tests).
|
|
* The {@code name} carries the worker label the reaper keys on; {@code paneId}/{@code tabId}
|
|
* locate its pane for teardown.
|
|
*/
|
|
public FakeHerdr withAgent(String name, String terminalId, String paneId, String tabId) {
|
|
extraAgents.add(("{\"terminal_id\":\"%s\",\"agent\":\"claude\",\"agent_status\":\"idle\","
|
|
+ "\"name\":\"%s\",\"agent_session\":{\"kind\":\"id\",\"value\":\"sess-%s\"},"
|
|
+ "\"workspace_id\":\"wQ\",\"tab_id\":\"%s\",\"pane_id\":\"%s\"}")
|
|
.formatted(terminalId, name, terminalId, tabId, paneId));
|
|
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 <em>live</em>;
|
|
* 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,"
|
|
+ "\"pane_count\":1,\"active_tab_id\":\"%s:t1\",\"agent_status\":\"unknown\"}")
|
|
.formatted(id, label, id));
|
|
return this;
|
|
}
|
|
|
|
public boolean called(String method) {
|
|
return calls.stream().anyMatch(c -> c.method().equals(method));
|
|
}
|
|
|
|
public Call lastCall(String method) {
|
|
return calls.stream().filter(c -> c.method().equals(method))
|
|
.reduce((_, b) -> b).orElseThrow();
|
|
}
|
|
|
|
@Override
|
|
public JsonNode call(String method, Object params) {
|
|
calls.add(new Call(method, params));
|
|
if (!healthy) throw new HerdrException("herdr unreachable (fake)");
|
|
try {
|
|
return switch (method) {
|
|
case "ping" -> mapper.readTree(
|
|
"{\"type\":\"pong\",\"version\":\"0.8.0\",\"protocol\":19}");
|
|
case "workspace.list" -> mapper.readTree(("""
|
|
{"type":"workspace_list","workspaces":[
|
|
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
|
|
{"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}%s]}""")
|
|
.formatted(extraWorkspaces.isEmpty() ? "" : "," + String.join(",", extraWorkspaces)));
|
|
case "agent.list" -> mapper.readTree(("""
|
|
{"type":"agent_list","agents":[
|
|
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
|
|
"agent_session":{"kind":"id","value":"sess-1111"},
|
|
"workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}%s]}""")
|
|
.formatted(extraAgents.isEmpty() ? "" : "," + String.join(",", extraAgents)));
|
|
case "agent.prompt" -> {
|
|
if (agentSendErrorCode != null) {
|
|
throw new HerdrException("herdr error [" + agentSendErrorCode + "]: agent.prompt failed",
|
|
agentSendErrorCode, null);
|
|
}
|
|
yield mapper.readTree(("""
|
|
{"type":"agent_prompted","agent":{"terminal_id":"term_a","agent":"claude",
|
|
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}}""")
|
|
.formatted(agentStatus));
|
|
}
|
|
case "agent.send_keys" -> {
|
|
if (agentSendErrorCode != null) {
|
|
throw new HerdrException("herdr error [" + agentSendErrorCode + "]: agent.send_keys failed",
|
|
agentSendErrorCode, null);
|
|
}
|
|
yield mapper.readTree("{\"type\":\"ok\"}");
|
|
}
|
|
case "agent.get" -> mapper.readTree(("""
|
|
{"type":"agent_info","agent":{"terminal_id":"term_a","agent":"claude",
|
|
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}}""")
|
|
.formatted(agentStatus));
|
|
case "agent.read" -> mapper.readTree(mapper.writeValueAsString(
|
|
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", readText))));
|
|
case "agent.start" -> {
|
|
// Protocol 19: kind and pane_id are required — reject like the real daemon.
|
|
java.util.Map<?, ?> p = params instanceof java.util.Map<?, ?> m ? m : java.util.Map.of();
|
|
for (String required : new String[]{"kind", "pane_id"}) {
|
|
if (p.get(required) == null) {
|
|
throw new HerdrException(
|
|
"herdr error [invalid_request]: invalid request: missing field `"
|
|
+ required + "`", "invalid_request", null);
|
|
}
|
|
}
|
|
long starts = calls.stream().filter(c -> c.method().equals("agent.start")).count();
|
|
if (starts <= agentPaneBusyFor) {
|
|
throw new HerdrException(
|
|
"herdr error [agent_pane_busy]: agent target pane is not an available shell",
|
|
"agent_pane_busy", null);
|
|
}
|
|
long busyAdjusted = starts - agentPaneBusyFor;
|
|
if (busyAdjusted <= agentNameTakenFor) {
|
|
throw new HerdrException(
|
|
"herdr error [agent_name_taken]: agent name already used",
|
|
"agent_name_taken", null);
|
|
}
|
|
long n = busyAdjusted - agentNameTakenFor;
|
|
boolean pinned = pinnedStarts > 0;
|
|
if (pinned) {
|
|
pinnedStarts--;
|
|
}
|
|
// Protocol 19: the agent starts INTO the requested pane, so its pane_id normally
|
|
// echoes the param. A pin overrides both coordinates, which is the only way to
|
|
// make two spawns report one pane — what CB-519's collision test needs.
|
|
String terminal = pinned ? pinnedStartTerminal : ("term_new_" + n);
|
|
Object pane = pinned ? pinnedStartPane : p.get("pane_id");
|
|
yield mapper.readTree(("""
|
|
{"type":"agent_started","agent":{
|
|
"terminal_id":"%s","name":"claude","agent_status":"unknown",
|
|
"workspace_id":"w9","tab_id":"w9:t2","pane_id":"%s"}}""")
|
|
.formatted(terminal, pane));
|
|
}
|
|
case "pane.split" -> mapper.readTree("""
|
|
{"type":"pane_info","pane":{"pane_id":"w1:pSplit","workspace_id":"w1",
|
|
"tab_id":"w1:t1"}}""");
|
|
case "workspace.create" -> mapper.readTree("""
|
|
{"type":"workspace_created",
|
|
"workspace":{"workspace_id":"w9","label":"bridged-workers","focused":false,
|
|
"pane_count":1,"tab_count":1,"active_tab_id":"w9:t1","agent_status":"unknown"},
|
|
"tab":{"tab_id":"w9:t1","workspace_id":"w9","label":"1","pane_count":1},
|
|
"root_pane":{"pane_id":"w9:p1","workspace_id":"w9","tab_id":"w9:t1"}}""");
|
|
case "tab.create" -> {
|
|
// Each tab gets its own seed pane — under protocol 19 that pane becomes the
|
|
// worker pane, so distinct spawns must yield distinct pane ids.
|
|
long tabs = calls.stream().filter(c -> c.method().equals("tab.create")).count();
|
|
yield mapper.readTree(("""
|
|
{"type":"tab_created",
|
|
"tab":{"tab_id":"w9:t2","workspace_id":"w9","label":"2","pane_count":1},
|
|
"root_pane":{"pane_id":"w9:pRoot_%d","workspace_id":"w9","tab_id":"w9:t2"}}""")
|
|
.formatted(tabs));
|
|
}
|
|
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" -> {
|
|
// 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<String> 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}%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",
|
|
"tab_id":"w9:t2","agent_status":"idle"}}""");
|
|
case "pane.list" -> mapper.readTree("""
|
|
{"type":"pane_list","panes":[
|
|
{"pane_id":"w2:p7","terminal_id":"term_a","workspace_id":"w2","tab_id":"w2:t7","agent":"claude"},
|
|
{"pane_id":"w2:p9","terminal_id":"term_shell","workspace_id":"w2","tab_id":"w2:t8"}]}""");
|
|
case "pane.process_info" -> {
|
|
Object paneId = params instanceof java.util.Map<?, ?> m ? m.get("pane_id") : null;
|
|
yield "w2:p7".equals(paneId)
|
|
? mapper.readTree(("""
|
|
{"type":"pane_process_info","process_info":{"pane_id":"w2:p7","shell_pid":%d,
|
|
"foreground_processes":[{"pid":%d,"name":"node","argv0":"claude"}]}}""")
|
|
.formatted(WORKER_PID, WORKER_PID))
|
|
: mapper.readTree("""
|
|
{"type":"pane_process_info","process_info":{"pane_id":"w2:p9","shell_pid":9001,
|
|
"foreground_processes":[]}}""");
|
|
}
|
|
case "pane.close" -> {
|
|
if (paneCloseErrorCode != null) {
|
|
throw new HerdrException("herdr error [" + paneCloseErrorCode + "]: pane.close failed",
|
|
paneCloseErrorCode, null);
|
|
}
|
|
yield mapper.readTree("{\"type\":\"ok\"}");
|
|
}
|
|
default -> throw new HerdrException("fake has no canned response for " + method);
|
|
};
|
|
} catch (HerdrException e) {
|
|
throw e; // intentional protocol errors (e.g. agent_name_taken) propagate with their code
|
|
} catch (Exception e) {
|
|
throw new HerdrException("fake decode failed for " + method, e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
}
|