CB-117: reap orphaned worker panes on startup
herdr keeps worker panes alive across a daemon restart by design, and a worker's paneId is held only by its spawner — so a worker whose owning process exited before its DELETE leaks with nothing tracking it (there is no registry; list() only asks herdr). Observed as three idle claude-ollama panes left in the worker space from earlier runs. On boot, WorkerService.reapOrphanWorkers() scans herdr for agents whose name matches our claude-<profile>-<nonce>-<seq> scheme with a nonce other than this process's nameNonce, and tears each down (pane + its now-empty dedicated tab). A current-nonce worker is ours and live (spared); a user's own claude session carries no such name (untouched). Keyed on the nonce so it survives kill -9 and reaps a *previous* daemon's leaks — the actual case shutdown-hook reaping and an in-memory registry both miss. - Agent now projects herdr's 'name' (was dropped) so the reaper can key on it. - isForeignWorker/workerNonce are pure + package-private for unit testing. - FakeHerdr.withAgent seeds named agents into agent.list. - Wired best-effort into Bridged startup before serving. Closes lms/claude-bridge#1
This commit is contained in:
@@ -55,6 +55,9 @@ public final class Bridged {
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
WorkerService 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.
|
||||
workers.reapOrphanWorkers();
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -15,6 +15,10 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
* @param agentType detected agent kind, e.g. {@code "claude"}, or {@code null} before herdr
|
||||
* has detected it (the start-time shape)
|
||||
* @param status current lifecycle state
|
||||
* @param name the unique label the agent was started with — for a bridge worker this is
|
||||
* {@code claude-<profile>-<nonce>-<seq>} (CB-117 keys orphan reaping on the
|
||||
* nonce); {@code null} for agents the bridge did not start, e.g. a user's own
|
||||
* Claude session
|
||||
*/
|
||||
public record Agent(
|
||||
String terminalId,
|
||||
@@ -23,7 +27,8 @@ public record Agent(
|
||||
String tabId,
|
||||
String sessionId,
|
||||
String agentType,
|
||||
AgentStatus status) {
|
||||
AgentStatus status,
|
||||
String name) {
|
||||
|
||||
/** Project a herdr {@code agent} node. Tolerates the start-time shape (no session yet). */
|
||||
public static Agent from(JsonNode a) {
|
||||
@@ -42,6 +47,7 @@ public record Agent(
|
||||
a.path("tab_id").asText(null),
|
||||
sessionId,
|
||||
type,
|
||||
AgentStatus.fromWire(a.path("agent_status").asText(null)));
|
||||
AgentStatus.fromWire(a.path("agent_status").asText(null)),
|
||||
a.path("name").asText(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Spawns and lists worker sessions — the safe path from a delegation request to a
|
||||
@@ -42,6 +44,14 @@ public final class WorkerService {
|
||||
/** herdr rejects a duplicate agent {@code name}; we retry a bumped name this many times. */
|
||||
private static final int NAME_RETRIES = 8;
|
||||
|
||||
/**
|
||||
* A bridge-spawned worker label {@code claude-<profile>-<nonce>-<seq>} (see
|
||||
* {@link #startUniquelyNamed}); group 1 captures the 6-hex per-process {@code nonce}. The
|
||||
* profile segment may itself contain {@code -}, so the nonce/seq are anchored at the tail.
|
||||
* Names not matching this shape are not workers we started and are never reaped (CB-117).
|
||||
*/
|
||||
private static final Pattern WORKER_NAME = Pattern.compile("claude-.*-([0-9a-f]{6})-\\d+");
|
||||
|
||||
private final AgentControl agents;
|
||||
private final WorkspaceControl spaces;
|
||||
private final SubscriptionGuard guard;
|
||||
@@ -271,6 +281,70 @@ public final class WorkerService {
|
||||
return agents.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap worker panes left behind by an earlier daemon process (CB-117). herdr keeps a worker's
|
||||
* pane alive across a daemon restart <em>by design</em>, and that pane's id is held only by its
|
||||
* spawner — so a worker whose owning process exited before issuing the matching teardown leaks
|
||||
* with nothing tracking it (there is no registry; {@link #list()} only asks herdr). On boot we
|
||||
* scan herdr for agents whose name matches our {@code claude-<profile>-<nonce>-<seq>} scheme with
|
||||
* a nonce <em>other</em> than this process's {@link #nameNonce}, and tear each one down (its pane
|
||||
* and, via {@link #stop}, its now-empty dedicated tab). A current-nonce worker is ours and live,
|
||||
* so it is left running; a user's own {@code claude} session carries no such name and is never
|
||||
* touched. Best-effort: a failed listing, or a failure to stop any one worker, is logged and
|
||||
* never aborts startup.
|
||||
*
|
||||
* @return the number of orphaned workers reaped
|
||||
*/
|
||||
public int reapOrphanWorkers() {
|
||||
List<Agent> all;
|
||||
try {
|
||||
all = agents.list();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("orphan-worker reap skipped — agent.list failed: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
int reaped = 0;
|
||||
for (Agent a : all) {
|
||||
if (!isForeignWorker(a.name(), nameNonce)) continue;
|
||||
try {
|
||||
stop(a.paneId());
|
||||
reaped++;
|
||||
log.info("reaped orphan worker {} (pane={} tab={}) left by a prior daemon",
|
||||
a.name(), a.paneId(), a.tabId());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("could not reap orphan worker {} (pane={}): {}",
|
||||
a.name(), a.paneId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (reaped > 0) {
|
||||
log.info("orphan-worker reap complete — {} stale worker(s) removed at startup", reaped);
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code name} is a bridge worker started by a <em>different</em> process than
|
||||
* {@code currentNonce} — the reap predicate (CB-117). True only for our naming scheme with a
|
||||
* foreign nonce: a non-worker name (no match, e.g. a user session) or our own live nonce is
|
||||
* excluded. Pure and package-private so the decision is unit-testable without herdr.
|
||||
*/
|
||||
static boolean isForeignWorker(String name, String currentNonce) {
|
||||
String nonce = workerNonce(name);
|
||||
return nonce != null && !nonce.equals(currentNonce);
|
||||
}
|
||||
|
||||
/** The 6-hex nonce embedded in a bridge worker name, or {@code null} if {@code name} isn't one. */
|
||||
static String workerNonce(String name) {
|
||||
if (name == null) return null;
|
||||
Matcher m = WORKER_NAME.matcher(name);
|
||||
return m.matches() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
/** This process's worker-name nonce (a label component only; exposed for reaper tests). */
|
||||
String nameNonce() {
|
||||
return nameNonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear a worker down by pane id: close the pane, and close its tab <em>only</em> when the
|
||||
* worker is that tab's sole occupant. The single-pane check is what makes this safe
|
||||
|
||||
Reference in New Issue
Block a user