/ses_*.json} in January 2026 — that
+ * tree is now a frozen migration artefact nothing writes, which is why this class no longer reads
+ * it). opencode also ships a headless HTTP server that may supersede both of these entirely.
+ * Everything this adapter knows about that private storage — its shape and column 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 fleetd worker runs
- * in its own unique git worktree, so the record's {@code directory} (its project root) equals the
+ * in its own unique git worktree, so the row'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.
+ *
All reads are best-effort and never throw: a missing or unreadable database, a query that
+ * fails, or a directory with no row yet all yield {@code null}, and the caller (the session
+ * handle) treats that as "identity not resolved yet" and retries later. The database is opened
+ * read-only and never written to: opencode itself may be running and writing it concurrently (WAL
+ * mode), and this class must never disturb that.
*/
final class OpenCodeSessionDiscovery {
+ private static final Logger log = LoggerFactory.getLogger(OpenCodeSessionDiscovery.class);
+
private final Path storageRoot; // e.g. ~/.local/share/opencode (injectable for tests)
- private final ObjectMapper json;
+ private final Path databasePath;
+ private final AtomicBoolean warnedMissingDatabase = new AtomicBoolean(false);
OpenCodeSessionDiscovery(Path storageRoot) {
this.storageRoot = storageRoot;
- this.json = new ObjectMapper();
+ this.databasePath = storageRoot.resolve("opencode.db");
}
/**
- * 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 opencode session id whose row references {@code directory} (the worker's cwd), or
+ * {@code null} when no row matches yet. When several rows share the directory — e.g. repeated
+ * spawns into the same worktree — the row with the highest {@code time_updated} 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 fleetd 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.
+ *
Never throws: a missing {@code opencode.db}, a locked/unreadable database, a query
+ * failure, or a directory that has not been persisted yet all resolve to {@code null} rather
+ * than failing a spawn. A fleetd worker's session row 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
@@ -61,62 +73,37 @@ final class OpenCodeSessionDiscovery {
if (directory == null || directory.isBlank()) {
return null;
}
- Path sessionRoot = storageRoot.resolve("session");
- if (!Files.isDirectory(sessionRoot)) {
+ if (!Files.isRegularFile(databasePath)) {
+ if (warnedMissingDatabase.compareAndSet(false, true)) {
+ log.warn("opencode session database not found at {} — opencode's on-disk layout "
+ + "may have moved again; session discovery will keep returning null",
+ databasePath);
+ }
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
+ // SQLiteConfig.setReadOnly opens with the SQLITE_OPEN_READONLY flag: never creates the
+ // file, never writes, never touches WAL/journal mode. opencode may be running and
+ // writing this database concurrently (WAL mode) — this connection must never disturb it.
+ String url = "jdbc:sqlite:" + databasePath;
+ SQLiteConfig config = new SQLiteConfig();
+ config.setReadOnly(true);
+ String sql = "SELECT id FROM session WHERE directory = ? ORDER BY time_updated DESC LIMIT 1";
+ try (Connection connection = config.createConnection(url);
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, directory);
+ try (ResultSet rows = statement.executeQuery()) {
+ if (rows.next()) {
+ return rows.getString("id");
}
}
- } catch (IOException ignored) {
- // storage root vanished or became unreadable — "no session known yet"
+ } catch (SQLException e) {
+ // Locked, corrupt, or otherwise unreadable — never fatal to a spawn. Not the
+ // "database moved" signal (the file exists), so this stays below WARN.
+ log.debug("opencode session database unreadable at {}: {}", databasePath, e.toString());
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;
- }
+ log.debug("no opencode session row for directory (root={}, directory={})",
+ storageRoot, directory);
+ return null;
}
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
index 6bcc820..29878f6 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java
@@ -334,8 +334,7 @@ class OpenCodeLauncherTest {
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);
+ OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_resolved", "/work/dir", 1000L);
assertEquals("ses_resolved", handle.agentSessionId(),
"agentSessionId() re-scans and picks up a record that has since been written");
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeSessionDiscoveryTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeSessionDiscoveryTest.java
index 1c29a35..de7fc0b 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeSessionDiscoveryTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeSessionDiscoveryTest.java
@@ -5,68 +5,81 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.attribute.FileTime;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
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}.
+ * {@link OpenCodeSessionDiscovery} matches an opencode session row by the worker's cwd (its
+ * {@code directory}) against opencode's {@code opencode.db} SQLite database. These tests build a
+ * SYNTHETIC database themselves, in a JUnit temp directory — never the operator's real
+ * {@code ~/.local/share/opencode/opencode.db}, which a live opencode process may be writing.
*/
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.
+ * Create {@code /opencode.db} with a minimal {@code session} table (just the columns
+ * {@link OpenCodeSessionDiscovery} reads: {@code id}, {@code directory}, {@code time_updated})
+ * and insert one row. Static so {@link OpenCodeLauncherTest} 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));
+ static void writeRecord(Path root, String id, String directory, long timeUpdated) throws Exception {
+ Path db = root.resolve("opencode.db");
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + db);
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE IF NOT EXISTS session ("
+ + "id TEXT PRIMARY KEY, directory TEXT, time_updated INTEGER)");
+ statement.execute("INSERT INTO session (id, directory, time_updated) VALUES ("
+ + "'" + id.replace("'", "''") + "', "
+ + "'" + directory.replace("'", "''") + "', "
+ + timeUpdated + ")");
+ }
}
@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);
+ void findsTheRowWhoseDirectoryEqualsTheCwd(@TempDir Path root) throws Exception {
+ writeRecord(root, "ses_aaa", "/w/a", 1000L);
+ writeRecord(root, "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");
+ "the row 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);
+ writeRecord(root, "ses_aaa", "/w/a", 1000L);
assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/other"),
- "no record for this cwd yet → null, not a wrong session");
+ "no row 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);
+ void prefersTheMostRecentlyUpdatedRowWhenSeveralMatch(@TempDir Path root) throws Exception {
+ writeRecord(root, "ses_old", "/w/a", 1000L);
+ writeRecord(root, "ses_new", "/w/a", 5000L);
assertEquals("ses_new", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"),
- "the freshest record for the cwd wins");
+ "the row with the highest time_updated for the cwd wins");
}
@Test
- void aMissingOrEmptyStorageRootYieldsNullWithoutThrowing(@TempDir Path root) throws Exception {
- // Missing: no session dir at all under the root.
+ void aMissingDatabaseYieldsNullWithoutThrowing(@TempDir Path root) {
+ // No opencode.db 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 anEmptyDatabaseYieldsNullWithoutThrowing(@TempDir Path root) throws Exception {
+ Path db = root.resolve("opencode.db");
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + db);
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, "
+ + "time_updated INTEGER)");
+ }
+
+ assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"));
}
@Test
@@ -77,14 +90,13 @@ class OpenCodeSessionDiscoveryTest {
}
@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);
+ void aCorruptDatabaseFileYieldsNullWithoutThrowing(@TempDir Path root) throws Exception {
+ // A file at opencode.db that is not a SQLite database at all — the open/query must fail
+ // safe, never fatal to a spawn.
+ Path db = root.resolve("opencode.db");
+ Files.writeString(db, "this is not a sqlite database");
- assertEquals("ses_good", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"),
- "an unreadable record is skipped; a later valid one still matches");
+ assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"),
+ "an unreadable database resolves to null, not an exception");
}
}