From 3743789e8d58d2f6b1f33840115a18000b77f8c5 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Mon, 31 Aug 2026 15:52:06 +0700 Subject: [PATCH] fleetd #206: read opencode session ids from opencode.db (SQLite), not the frozen JSON tree opencode migrated its session store to SQLite in January 2026; the JSON tree under storage/session//ses_*.json stopped being written, so OpenCodeSessionDiscovery returned null for every member forever, and fleet_spawn{resumeSessionId} was unreachable. - Add org.xerial:sqlite-jdbc 3.53.4.0, opened read-only (SQLiteConfig.setReadOnly), so it never disturbs a live opencode process writing the WAL-mode database. - Rewrite sessionIdForDirectory to run a parameterized SELECT ... WHERE directory = ? ORDER BY time_updated DESC LIMIT 1 against the session table. Still never throws: a missing database, a locked/corrupt one, or no matching row all return null. - Log the silence that let this go unnoticed: WARN once per instance when opencode.db itself is missing (the layout moved again), DEBUG when it exists but no row matches yet (the normal interim answer right after a spawn). - Replace the JSON-fixture tests with a synthetic-SQLite-db fixture; delete the tests that only proved the old JSON scan worked. --- fleetd/pom.xml | 18 +++ .../member/OpenCodeSessionDiscovery.java | 141 ++++++++---------- .../fleet/member/OpenCodeLauncherTest.java | 3 +- .../member/OpenCodeSessionDiscoveryTest.java | 90 ++++++----- 4 files changed, 134 insertions(+), 118 deletions(-) diff --git a/fleetd/pom.xml b/fleetd/pom.xml index b294c01..8dd8be0 100644 --- a/fleetd/pom.xml +++ b/fleetd/pom.xml @@ -28,6 +28,7 @@ 1.20.4 1.27.1 3.18.0 + 3.53.4.0 + + org.xerial + sqlite-jdbc + ${sqlite-jdbc.version} + + org.slf4j diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeSessionDiscovery.java b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeSessionDiscovery.java index a5ea50f..2813999 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeSessionDiscovery.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeSessionDiscovery.java @@ -1,58 +1,70 @@ package dev.ltms.fleet.member; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sqlite.SQLiteConfig; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.stream.Stream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicBoolean; /** * Resolves the opencode session id for a fleetd 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}. + *

Why this is isolated behind one seam. The layout is version-coupled and not + * a stable contract: opencode persists its session state in a SQLite database at + * {@code /opencode.db} (a {@code session} table, one row per session, keyed by id and + * carrying a {@code directory} column). That schema can move between opencode releases exactly + * like the JSON-file layout it replaced did (opencode migrated off a one-JSON-file-per-session + * tree under {@code /storage/session//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"); } }