diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java index c6a740b..a93d5b1 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java @@ -7,6 +7,8 @@ import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.herdr.AgentControl; 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 java.io.IOException; import java.io.UncheckedIOException; @@ -72,6 +74,24 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** Root under which per-spawn opencode config dirs are created (injectable for tests). */ private final Path configRoot; + /** + * The current spawn's resume-target session id, threaded from {@link #spawn(SpawnRequest)} to + * {@link #buildLaunch} across the base's {@code spawn -> spawnInternal -> buildLaunch} chain, + * which carries no request. A plain field would race under concurrent spawns (the base supports + * them), so it is thread-local: each spawn captures its own request's id on its own thread, and + * {@code buildLaunch}, synchronous and same-thread, reads exactly that one. Set only around the + * {@code super.spawn} call and cleared in {@code finally}, so a paused/leftover value can never + * bleed into the next spawn. + */ + private final ThreadLocal resumeSessionId = new ThreadLocal<>(); + + /** + * Session discovery against opencode's on-disk storage ({@link OpenCodeSessionDiscovery}) — + * the one seam that knows opencode's private session-file layout. Its root is injectable for + * tests so they never touch the operator's real {@code ~/.local/share/opencode}. + */ + private final OpenCodeSessionDiscovery discovery; + /** * Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it * matches the legacy non-blocking spawn semantics. Config dirs are created under the JVM temp dir. @@ -81,7 +101,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { Function env) { this(agents, spaces, profiles, defaultProfile, env, 0, System::currentTimeMillis, () -> sleepUninterruptibly(300), - defaultConfigRoot()); + defaultConfigRoot(), defaultDiscoveryRoot()); } /** @@ -94,7 +114,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { long spawnReadyTimeoutMs, long spawnReadyPollMs) { this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs), - defaultConfigRoot()); + defaultConfigRoot(), defaultDiscoveryRoot()); } /** @@ -112,21 +132,31 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * @param sleeper sleep/wait hook (encodes the poll interval; never called when the * gate is disabled) * @param configRoot existing directory under which per-spawn config dirs are created + * @param discoveryRoot opencode's on-disk storage root to scan for session records + * (injectable for tests; opencode's layout is matched at + * {@link OpenCodeSessionDiscovery}) */ public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, - LongSupplier nowMillis, Runnable sleeper, Path configRoot) { + LongSupplier nowMillis, Runnable sleeper, + Path configRoot, Path discoveryRoot) { super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, nowMillis, sleeper); this.configRoot = configRoot; + this.discovery = new OpenCodeSessionDiscovery(discoveryRoot); } private static Path defaultConfigRoot() { return Path.of(System.getProperty("java.io.tmpdir")); } + /** The default opencode storage root: {@code ~/.local/share/opencode} (the XDG data dir). */ + private static Path defaultDiscoveryRoot() { + return Path.of(System.getProperty("user.home"), ".local", "share", "opencode"); + } + /** * {@inheritDoc} * @@ -144,7 +174,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString()); } applyGitToken(workerEnv, cfg); - return new Launch(workerEnv, argvWithModel(argvWithAuto(cfg), cfg)); + return new Launch(workerEnv, argvWithResume(argvWithModel(argvWithAuto(cfg), cfg))); } /** @@ -177,6 +207,24 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { return argv; } + /** + * The launch argv plus, on a resumed spawn, opencode's {@code -s } flag to continue a prior + * conversation by its session id. {@code -s, --session } resumes an existing session; on a + * fresh spawn (no resume target) no flag is added, letting opencode start a brand-new session. + * The id comes from the current spawn request's {@code resumeSessionId}, threaded per-thread by + * {@link #spawn(SpawnRequest)}. + */ + private List argvWithResume(List argv) { + String id = resumeSessionId.get(); + if (id == null || id.isBlank()) { + return argv; + } + List withResume = mutableArgv(argv); + withResume.add("-s"); + withResume.add(id); + return withResume; + } + /** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */ private List argvWithModel(List argv, BridgedConfig.Worker cfg) { if (cfg.model() != null && !cfg.model().isBlank()) { @@ -285,6 +333,82 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { return afterScheme.contains("/") ? trimmed : trimmed + "/v1"; } + /** + * {@inheritDoc} + * + *

adds this adapter's session-identity work around the base's spawn — as opencode cannot be + * told its session id at spawn (see {@link Capability#SESSION_RESUME} vs + * {@link Capability#SESSION_NAME}), identity is only ever adopted after the fact: + *

    + *
  • the request's {@code resumeSessionId} is remembered for {@link #buildLaunch} to turn + * into {@code -s }; and
  • + *
  • the returned handle is wrapped so its + * {@link dev.ltms.bridged.peer.PeerHandle#agentSessionId()} performs lazy session + * discovery against opencode's storage (see {@link OpenCodeSessionDiscovery}) — always + * non-blocking, {@code null} until opencode has persisted the session record.
  • + *
+ */ + @Override + public PeerHandle spawn(SpawnRequest req) { + resumeSessionId.set(req.resumeSessionId()); + try { + PeerHandle inner = super.spawn(req); + return new SessionAwareHandle(inner, discovery, effectiveCwd(req)); + } finally { + // Never let a paused/leftover resume id bleed into the next spawn on this thread. + resumeSessionId.remove(); + } + } + + /** + * A {@link PeerHandle} that delegates everything to the base's worker handle but resolves + * {@link #agentSessionId()} lazily through opencode session discovery. Delegate-only, so the + * base's id/terminalId/profile semantics (CB-519's host-unique routing key, herdr coordinates) + * are untouched — only the opencode-specific identity answer is added. {@code sessionName()} + * stays null: opencode has no display-name seam, so the logical name lives only in the bridge's + * roster (see the SESSION_NAME capability). + */ + private static final class SessionAwareHandle implements PeerHandle { + private final PeerHandle delegate; + private final OpenCodeSessionDiscovery discovery; + private final String cwd; + + SessionAwareHandle(PeerHandle delegate, OpenCodeSessionDiscovery discovery, String cwd) { + this.delegate = delegate; + this.discovery = discovery; + this.cwd = cwd; + } + + @Override + public String id() { + return delegate.id(); + } + + @Override + public String terminalId() { + return delegate.terminalId(); + } + + @Override + public String profile() { + return delegate.profile(); + } + + @Override + public String sessionName() { + return delegate.sessionName(); + } + + @Override + public String agentSessionId() { + // Lazy + retried, never a spawn-time blocker: opencode writes the session record only + // when the session is first persisted, so null here is the correct interim answer and + // the caller re-calls later (each call re-scans, picking up a record that has since + // appeared). + return discovery.sessionIdForDirectory(cwd); + } + } + // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) --- /** Spawn a worker for the default profile in the resolved default cwd. */ @@ -306,10 +430,15 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { @Override public Set capabilities() { - Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP); + Set caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, + Capability.ORPHAN_REAP, Capability.SESSION_RESUME); if (hasGitTokenProfile()) { caps.add(Capability.SELF_PR); } + // Deliberately NOT SESSION_NAME: opencode has no display-name flag, so the bridge's logical + // name can't surface in the peer's own UI — declaring the capability would hide that + // asymmetry rather than make it honest. For opencode the name lives only in the bridge's + // roster (see PeerHandle.sessionName() returning null). return Set.copyOf(caps); } diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeSessionDiscovery.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeSessionDiscovery.java new file mode 100644 index 0000000..c0f9b21 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeSessionDiscovery.java @@ -0,0 +1,122 @@ +package dev.ltms.bridged.worker; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +/** + * Resolves the opencode session id for a bridged worker from opencode's on-disk storage — the + * only place this adapter touches opencode's private layout, and deliberately the only + * class that does. + * + *

Why this is isolated behind one seam. The layout is version-coupled and not a + * stable contract: opencode writes one JSON file per session under + * {@code /session//}, and each record carries a + * {@code "version"} field (e.g. {@code "1.1.31"}), so the exact directory shape, file naming, and + * field names can move between opencode releases. opencode also ships a headless HTTP server that + * may supersede file scanning entirely. Everything this adapter knows about that private storage — + * its shape, naming, and field names — lives here, so a layout change, or a switch to the HTTP + * server, changes exactly one class and nothing in {@link OpenCodeLauncher}. + * + *

The determinism that makes this useful is structural, not a guess: every bridged worker runs + * in its own unique git worktree, so the record's {@code directory} (its project root) equals the + * worker's cwd identifies its session unambiguously. We match on {@code directory} rather + * than diffing {@code opencode session list} before/after — that races under concurrent spawns, and + * the CLI listing does not even show the directory. + * + *

All reads are best-effort and never throw: a missing or unreadable storage root, a record that + * fails to parse, or a directory with no record yet all yield {@code null}, and the caller (the + * session handle) treats that as "identity not resolved yet" and retries later. + */ +final class OpenCodeSessionDiscovery { + + private final Path storageRoot; // e.g. ~/.local/share/opencode (injectable for tests) + private final ObjectMapper json; + + OpenCodeSessionDiscovery(Path storageRoot) { + this.storageRoot = storageRoot; + this.json = new ObjectMapper(); + } + + /** + * The opencode session id whose record references {@code directory} (the worker's cwd), or + * {@code null} when no record matches yet. When several records share the directory — e.g. + * repeated spawns into the same worktree — the most recently modified one wins: it is + * the session the pane most likely corresponds to. + * + *

Never throws: a missing {@code storageRoot}, an unreadable/malformed record, or a + * directory that has not been persisted yet all resolve to {@code null} rather than failing a + * spawn. A bridged worker's session record is written lazily (when the session is first + * persisted), so {@code null} here is the normal answer right after the pane is ready, and the + * caller retries later. + * + * @param directory the worker's cwd, as resolved for this spawn + * @return the matching session id, or {@code null} if none is known yet + */ + String sessionIdForDirectory(String directory) { + if (directory == null || directory.isBlank()) { + return null; + } + Path sessionRoot = storageRoot.resolve("session"); + if (!Files.isDirectory(sessionRoot)) { + return null; + } + String best = null; + long bestMtime = Long.MIN_VALUE; + try (Stream projectDirs = Files.list(sessionRoot)) { + for (Path projectDir : projectDirs.filter(Files::isDirectory).toList()) { + try (Stream records = Files.list(projectDir)) { + for (Path record : records.toList()) { + String id = matchId(record, directory); + if (id == null) { + continue; + } + long mtime = lastModifiedEpochMillis(record); + if (mtime > bestMtime) { + bestMtime = mtime; + best = id; + } + } + } catch (IOException ignored) { + // one project dir unreadable — skip it; another may still match + } + } + } catch (IOException ignored) { + // storage root vanished or became unreadable — "no session known yet" + return null; + } + return best; + } + + /** + * The record's session id when it references {@code directory}, else {@code null}. A record + * that is not JSON, lacks {@code id}/{@code directory}, or points at a different directory is + * simply not our session; a malformed one is skipped, never fatal. + */ + private String matchId(Path record, String directory) { + try { + JsonNode node = json.readTree(record.toFile()); + JsonNode id = node == null ? null : node.get("id"); + JsonNode dir = node == null ? null : node.get("directory"); + if (id == null || dir == null || !directory.equals(dir.asText())) { + return null; + } + return id.asText(); + } catch (IOException e) { + return null; + } + } + + /** The record's last-modified epoch ms, or {@code Long.MIN_VALUE} if unreadable (never wins). */ + private static long lastModifiedEpochMillis(Path record) { + try { + return Files.getLastModifiedTime(record).toMillis(); + } catch (IOException e) { + return Long.MIN_VALUE; + } + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java index d1a2fc2..3d93939 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java @@ -37,7 +37,7 @@ class OpenCodeLauncherTest { private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker cfg) { return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null, - 0, System::currentTimeMillis, () -> { }, configRoot); + 0, System::currentTimeMillis, () -> { }, configRoot, configRoot); } @SuppressWarnings("unchecked") @@ -130,14 +130,65 @@ class OpenCodeLauncherTest { @Test void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr(); - assertEquals(java.util.Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP), + assertEquals(java.util.Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP, + Capability.SESSION_RESUME), service(herdr, root, opencodeCfg(null, null, null)).capabilities(), - "no git token → no SELF_PR"); + "opencode can be resumed by its own session id, so SESSION_RESUME is always declared"); + assertFalse(service(herdr, root, opencodeCfg(null, null, null)) + .capabilities().contains(Capability.SESSION_NAME), + "opencode has no display-name flag, so SESSION_NAME must NOT be declared"); assertTrue(service(herdr, root, opencodeCfg(null, null, "GITEA_ACCESS_TOKEN")) .capabilities().contains(Capability.SELF_PR), "a git-token profile adds SELF_PR"); } + // --- CB-547: resume + post-hoc session discovery -------------------------------------------- + + @Test + void aResumeSpawnPassesTheSessionIdAsDashS(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)) + .spawn(new SpawnRequest(null, null, null, null, "ses_41b79fc90ffeI9E8uZv6VprUn2")); + + List args = startArgs(herdr); + int s = args.indexOf("-s"); + assertTrue(s >= 0, "a resumed spawn carries opencode's -s flag"); + assertEquals("ses_41b79fc90ffeI9E8uZv6VprUn2", args.get(s + 1), + "the resume target id follows -s"); + } + + @Test + void aFreshSpawnCarriesNoSessionFlag(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg(null, null, null)) + .spawn(new SpawnRequest(null, null, null, null, null)); + + assertFalse(startArgs(herdr).contains("-s"), + "no resume target → a fresh session with no -s flag"); + } + + @Test + void theHandleDiscoversTheSessionIdForTheWorkersCwdOnlyAfterItAppears(@TempDir Path root, + @TempDir Path discRoot) + throws Exception { + FakeHerdr herdr = new FakeHerdr(); + OpenCodeLauncher launcher = new OpenCodeLauncher(new AgentControl(herdr), + new WorkspaceControl(herdr), Map.of("gemini", opencodeCfg(null, null, null)), + "gemini", _ -> null, 0, System::currentTimeMillis, () -> { }, root, discRoot); + + PeerHandle handle = launcher.spawn(new SpawnRequest(null, "/work/dir", null)); + + // opencode writes the record only when the session is first persisted — the instant the + // pane is ready it does not exist, so agentSessionId() is null (never a spawn failure). + assertNull(handle.agentSessionId(), "no record yet → null, not a spawn-time block"); + // Once the record appears (here: same cwd), lazy discovery resolves it — the handle's + // session id matches its own worktree, not another's. + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "p1", "ses_a.json", + "ses_resolved", "/work/dir", 1000L); + assertEquals("ses_resolved", handle.agentSessionId(), + "agentSessionId() re-scans and picks up a record that has since been written"); + } + @Test void foreignWorkerMatchesOpencodePrefixButNotClaude() { String nonce = "abc123"; @@ -169,7 +220,7 @@ class OpenCodeLauncherTest { long[] clock = {0}; OpenCodeLauncher svc = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of("gemini", opencodeCfg(null, null, null)), "gemini", _ -> null, - 1000, () -> clock[0], () -> clock[0] += 50, root); + 1000, () -> clock[0], () -> clock[0] += 50, root, root); PeerUnreachableException ex = assertThrows(PeerUnreachableException.class, () -> svc.spawn(new SpawnRequest(null, null, null))); diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeSessionDiscoveryTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeSessionDiscoveryTest.java new file mode 100644 index 0000000..afe2350 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeSessionDiscoveryTest.java @@ -0,0 +1,90 @@ +package dev.ltms.bridged.worker; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link OpenCodeSessionDiscovery} matches an opencode session record by the worker's cwd (its + * {@code directory}) against opencode's on-disk storage. These tests populate a TEMP storage root + * themselves — never the operator's real {@code ~/.local/share/opencode}. + */ +class OpenCodeSessionDiscoveryTest { + + /** + * Write a session record {@code {"id":..., "directory":...}} under + * {@code /session//} and stamp it with a known last-modified time, + * so "most recently modified wins" is deterministic. Static so the launcher test can reuse it. + */ + static void writeRecord(Path root, String projectId, String fileName, String id, + String directory, long lastModifiedEpochMillis) throws Exception { + Path dir = root.resolve("session").resolve(projectId); + Files.createDirectories(dir); + Path file = dir.resolve(fileName); + Files.writeString(file, "{\"id\":\"" + id + "\",\"directory\":\"" + directory + + "\",\"projectID\":\"" + projectId + "\",\"version\":\"1.1.31\"}"); + Files.setLastModifiedTime(file, FileTime.fromMillis(lastModifiedEpochMillis)); + } + + @Test + void findsTheRecordWhoseDirectoryEqualsTheCwd(@TempDir Path root) throws Exception { + writeRecord(root, "p1", "ses_a.json", "ses_aaa", "/w/a", 1000L); + writeRecord(root, "p2", "ses_b.json", "ses_bbb", "/w/b", 2000L); + + assertEquals("ses_bbb", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/b"), + "the record whose directory equals the cwd is the one found"); + assertEquals("ses_aaa", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a")); + } + + @Test + void aNonMatchingDirectoryYieldsNullRatherThanAMismatch(@TempDir Path root) throws Exception { + writeRecord(root, "p1", "ses_a.json", "ses_aaa", "/w/a", 1000L); + + assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/other"), + "no record for this cwd yet → null, not a wrong session"); + } + + @Test + void prefersTheMostRecentlyModifiedRecordWhenSeveralMatch(@TempDir Path root) throws Exception { + writeRecord(root, "p1", "old.json", "ses_old", "/w/a", 1000L); + writeRecord(root, "p2", "new.json", "ses_new", "/w/a", 5000L); + + assertEquals("ses_new", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"), + "the freshest record for the cwd wins"); + } + + @Test + void aMissingOrEmptyStorageRootYieldsNullWithoutThrowing(@TempDir Path root) throws Exception { + // Missing: no session dir at all under the root. + assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a")); + + // Present but empty: a session dir with nothing in it produces no match, not a throw. + Path emptyRoot = root.resolve("empty"); + Files.createDirectories(emptyRoot.resolve("session")); + assertNull(new OpenCodeSessionDiscovery(emptyRoot).sessionIdForDirectory("/w/a")); + } + + @Test + void aBlankOrNullDirectoryYieldsNull(@TempDir Path root) { + OpenCodeSessionDiscovery discovery = new OpenCodeSessionDiscovery(root); + assertNull(discovery.sessionIdForDirectory(null)); + assertNull(discovery.sessionIdForDirectory(" ")); + } + + @Test + void aMalformedRecordIsSkippedRatherThanFatal(@TempDir Path root) throws Exception { + // A record that fails to parse must not abort the scan of its siblings. + Path dir = root.resolve("session").resolve("p1"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("broken.json"), "{not valid json"); + writeRecord(root, "p1", "good.json", "ses_good", "/w/a", 1000L); + + assertEquals("ses_good", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"), + "an unreadable record is skipped; a later valid one still matches"); + } +}