From 412930e03e8cbbd58fcce2d887a4ecb1bdfe9342 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 2 Oct 2026 04:10:10 -0400 Subject: [PATCH 1/2] fix(opencode): read the binder's session store on the foreign SQLite reader worker (STA-9122) Before: the OpenCode session binder listed new sessions from opencode.db with node:sqlite on the main thread every 60 s (and on SessionStart kicks), so a large or contended store could stall the app the same way Cursor's did. After: the read is a pure openCodeBinderSessions reader in foreign-sqlite-readers/readers/, run only on the worker. The binder's correlation, pane snapshot and process sweep stay where they were. - The binder round awaits listSessions and re-checks its generation right after, so a stop() during the read discards the round before it touches the unbound map or the watermark. - The client's in-flight dedupe key now includes the cursor, so a stale round from before a restart cannot hand its rows to the restarted round. - Idle teardown is per reader. The binder lane keeps its thread for 120 s, longer than its 60 s poll, so the thread is not respawned every round. - A timeout, crash, malformed reply or unstartable worker resolves to [] (no sessions), the value the old read already returned on failure. - An absent store still reads as [] without a log line, and a permission or corrupt-file failure still logs (kept from #24577, now in the reader: it stats the path and throws anything but ENOENT/ENOTDIR to the client's log). - The binder lane inherits #24572's limits from the shared lane: no respawn until a timed-out worker has exited, 2 consecutive deaths, a queue cap of 8. Its timeout stays 60 s, matching its poll. - dispatch switches on the destructured kind, so a new kind without a case still fails to compile. orcad: the hook server runs there too, so orcad now ships foreign-sqlite-reader-entry.js beside orcad.js (ORCAD_ARTIFACTS, built as an orcad child). build-orcad runs a smoke check that starts the built worker under the build's Node and under the pinned runtime, and does a real binder read on a fixture DB, a Cursor read of a missing file and an OpenCode history list. The OpenCode history scanner uses the same entry and was bundled into orcad without it, so on orcad it always failed closed; it can now run. Tests: reader (cursor, same-ms ids, OpenCode 2 rows, missing then created, corrupt, inaccessible directory), retirement gate for the binder lane, dispatch routing, client lane (rows, failure -> [], dedupe per cursor, own thread, idle teardown default and override), binder loop with an async listSessions (failure -> [], stop during the read), orcad path resolution through orcad's host adapters, artifact list, and the smoke check against good, missing and non-reading entries. --- config/scripts/build-orcad.mjs | 17 ++ .../foreign-sqlite-reader-worker-smoke.mjs | 80 ++++++++ ...oreign-sqlite-reader-worker-smoke.test.mjs | 65 ++++++ config/scripts/orcad-entry-build.mjs | 3 +- .../server-opencode-binder.test.ts | 191 +++++++----------- .../server/server-opencode-binder.ts | 21 +- ...ite-reader-client-worker-lifecycle.test.ts | 81 +++++--- .../foreign-sqlite-reader-client.test.ts | 105 ++++++++++ .../foreign-sqlite-reader-client.ts | 49 ++++- .../foreign-sqlite-reader-dispatch.test.ts | 25 +++ .../foreign-sqlite-reader-dispatch.ts | 14 +- .../foreign-sqlite-reader-entry-path.test.ts | 5 + .../foreign-sqlite-reader-protocol.ts | 11 +- .../foreign-sqlite-reader-spawn.ts | 14 ++ .../opencode-binder-sessions-result.ts | 35 ++++ .../readers/opencode-binder-sessions.test.ts | 105 ++++++++++ .../readers/opencode-binder-sessions.ts | 80 ++++++++ src/main/opencode/opencode-session-binder.ts | 98 +-------- .../orcad/orcad-forked-child-paths.test.ts | 13 ++ src/shared/orcad-artifacts.test.ts | 8 + src/shared/orcad-artifacts.ts | 5 + 21 files changed, 769 insertions(+), 256 deletions(-) create mode 100644 config/scripts/foreign-sqlite-reader-worker-smoke.mjs create mode 100644 config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs create mode 100644 src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts create mode 100644 src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts create mode 100644 src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 2bd9933d33aa..66c8f535a5bd 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -23,10 +23,12 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, + ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_DIR, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -56,6 +58,10 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // orcad restart would SIGKILL every running terminal. const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') +// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner +// start this worker from the module dir, since orcad has no Electron resources tree. +const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader) +const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY) const OUT_FILE = join(OUT_DIR, 'orcad.js') const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { @@ -205,6 +211,7 @@ function buildForkedChild(entryPoint, outfile) { const childResults = await Promise.all([ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), + buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE), ...['writer', 'backup'].map((role) => buildForkedChild( join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), @@ -340,6 +347,16 @@ try { process.exitCode = 1 } +try { + smokeForeignSqliteReaderWorker(OUT_DIR) + if (nodeRuntimePath) { + smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath }) + } +} catch (error) { + console.error('[build-orcad] foreign SQLite reader worker check failed:', error) + process.exitCode = 1 +} + // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on // this string, so two different builds carrying one version would share a directory — and an // already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs new file mode 100644 index 000000000000..b83c9c088afc --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../src/shared/orcad-artifacts.ts' + +// Why a child process: the read must run under the runtime orcad ships, which may not be +// the Node running the build. The verdict is the exit code, never matched output. +const PROBE = ` +const { Worker } = require('node:worker_threads') +const { DatabaseSync } = process.getBuiltinModule('node:sqlite') +const [entry, dbPath, missingPath] = process.argv.slice(2) +const db = new DatabaseSync(dbPath) +db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)') +db.prepare('INSERT INTO session VALUES (?, ?, ?, ?)').run('ses_smoke', '/smoke', 100, null) +db.close() +const steps = [ + { + request: { id: 1, kind: 'openCodeBinderSessions', dbPath, cursor: { ms: 0, id: '' } }, + expected: [{ id: 'ses_smoke', directory: '/smoke', createdAtMs: 100, parentId: null }] + }, + { request: { id: 2, kind: 'cursorProfile', dbPath: missingPath }, expected: { status: 'missing' } }, + // The OpenCode history scanner's kinds share this entry. + { request: { id: 3, kind: 'list', dbPaths: [], limit: null }, expected: { candidates: [], issues: [] } } +] +const worker = new Worker(entry, { execArgv: [] }) +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +setTimeout(() => fail(3, 'foreign SQLite reader worker did not answer'), 20000).unref() +worker.on('error', (error) => fail(4, String(error && error.stack || error))) +worker.on('exit', (code) => fail(5, 'foreign SQLite reader worker exited with ' + code)) +let step = 0 +worker.on('message', (response) => { + const { request, expected } = steps[step] + if (!response || response.id !== request.id || response.ok !== true || + JSON.stringify(response.value) !== JSON.stringify(expected)) { + fail(6, request.kind + ' answered ' + JSON.stringify(response)) + } + step += 1 + if (step === steps.length) { + process.exit(0) + } + worker.postMessage(steps[step].request) +}) +worker.postMessage(steps[0].request) +` + +/** + * Load the built foreign SQLite reader entry and run real reads through it. + * @param outDir - orcad output directory holding the entry. + * @param options.runtimePath - Node to run under; the build's own Node when omitted. + */ +export function smokeForeignSqliteReaderWorker(outDir, { runtimePath, timeoutMs = 30_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-')) + try { + const probe = join(directory, 'probe.cjs') + writeFileSync(probe, PROBE) + const result = runProcessSync({ + program: runtimePath ?? process.execPath, + args: [ + probe, + resolve(outDir, ORCAD_FOREIGN_SQLITE_READER_ENTRY), + join(directory, 'opencode.db'), + join(directory, 'missing.vscdb') + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `Foreign SQLite reader worker smoke failed: ${describeProcessFailure(result)}` + ) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs new file mode 100644 index 000000000000..36121ad88d43 --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -0,0 +1,65 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' +import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' + +const ENTRY = 'foreign-sqlite-reader-entry.js' +const directories = [] +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await build({ + entryPoints: [resolve(ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader)], + outfile: join(builtDirectory, ENTRY), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) +}, 60_000) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('foreign SQLite reader build smoke', () => { + it('passes against the built entry', () => { + expect(() => smokeForeignSqliteReaderWorker(builtDirectory)).not.toThrow() + }) + + it('fails when the entry is missing', () => { + expect(() => smokeForeignSqliteReaderWorker(fixtureDirectory())).toThrow('smoke failed') + }) + + it('fails when the worker answers without reading', () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, ENTRY), + `const { parentPort } = require('node:worker_threads') + parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, value: [] }))` + ) + expect(() => smokeForeignSqliteReaderWorker(directory)).toThrow('smoke failed') + }) + + it('runs in the orcad build under both runtimes', () => { + const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') + expect(source).toContain('smokeForeignSqliteReaderWorker(OUT_DIR)') + expect(source).toContain( + 'smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })' + ) + }) +}) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index e168430fe0a7..8ee6b243b3de 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -8,7 +8,8 @@ export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', - backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' + backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts', + foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts' } export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index ab6ed0ad0ba2..354d542fb17e 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -1,14 +1,13 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' import { makePaneKey } from '../../shared/stable-pane-id' -import SyncDatabase from '../sqlite/sync-database' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../foreign-sqlite-readers/opencode-binder-sessions-result' import { AgentHookServer } from './server' import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder' -import { listOpenCodeDbSessions } from '../opencode/opencode-session-binder' const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -38,33 +37,42 @@ class BinderTestServer extends AgentHookServer { } } -function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void { - const db = new SyncDatabase(dbPath) - try { - db.exec( - `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` - ) - const insert = db.prepare( - `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` - ) - insert.run(id, DIR, Date.now() - 60_000, null) - } finally { - db.close() +const DB_PATH = '/tmp/binder-store/opencode.db' + +/** + * Stands in for the worker read: OpenCode 1 rows past the cursor, oldest first. + * The SQL itself is covered by readers/opencode-binder-sessions.test.ts. + */ +class FakeSessionStore { + rows: BinderSessionRow[] = [] + calls: { dbPath: string; cursor: OpenCodeSessionCursor }[] = [] + + add(id = 'ses_live'): void { + this.rows.push({ id, directory: DIR, createdAtMs: Date.now() - 60_000, parentId: null }) + } + + list = async (dbPath: string, cursor: OpenCodeSessionCursor): Promise => { + this.calls.push({ dbPath, cursor }) + return this.rows + .filter( + (row) => + row.createdAtMs > cursor.ms || (row.createdAtMs === cursor.ms && row.id > cursor.id) + ) + .sort((a, b) => a.createdAtMs - b.createdAtMs || (a.id < b.id ? -1 : 1)) } } describe('opencode binder loop', () => { - let dir = '' - let dbPath = '' + let store: FakeSessionStore let server: BinderTestServer beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-db-')) - dbPath = join(dir, 'opencode.db') + store = new FakeSessionStore() server = new BinderTestServer() server.bindDeps({ now: () => Date.now(), - dbPath: () => dbPath, + dbPath: () => DB_PATH, + listSessions: store.list, listPanes: () => [ { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 } ], @@ -82,24 +90,53 @@ describe('opencode binder loop', () => { afterEach(() => { server.stop() - rmSync(dir, { recursive: true, force: true }) }) it('binds a fresh OpenCode 1 session to its pane', async () => { - writeDb(dbPath, 'session') + store.add() const applied = await server.runBinderRound() expect(applied).toBe(1) expect(server.readRegistry('ses_live')).toBe(PANE_A) + expect(store.calls[0]).toEqual({ dbPath: DB_PATH, cursor: { ms: 0, id: '' } }) }) - it('never binds an OpenCode 2 session', async () => { - writeDb(dbPath, 'session_v2') + it('skips the round when the read answers its failure value', async () => { + // [] is what the worker client resolves to on a timeout, crash or unreadable store. + const sweep = vi.fn(async () => []) + server.bindDeps({ listSessions: async () => [], sweep }) expect(await server.runBinderRound()).toBe(0) + expect(sweep).not.toHaveBeenCalled() + }) + + it('discards a round whose session read was in flight across stop', async () => { + store.add() + let releaseRead!: () => void + const readGate = new Promise((resolve) => { + releaseRead = resolve + }) + const sweep = vi.fn(async () => []) + server.bindDeps({ + sweep, + listSessions: async (dbPath, cursor) => { + await readGate + return store.list(dbPath, cursor) + } + }) + const round = server.runBinderRound() + server.stop() + releaseRead() + expect(await round).toBe(0) + expect(sweep).not.toHaveBeenCalled() expect(server.readRegistry('ses_live')).toBeUndefined() + + // The stale round left the watermark alone: the next round lists from the start. + server.bindDeps({ listSessions: store.list }) + expect(await server.runBinderRound()).toBe(0) + expect(store.calls.at(-1)?.cursor).toEqual({ ms: 0, id: '' }) }) it('an opencode SessionStart kicks a round that binds before the poll', async () => { - writeDb(dbPath, 'session') + store.add() vi.useFakeTimers() try { // Birth arrives stamped with the wrong (server-starter) pane. @@ -117,7 +154,7 @@ describe('opencode binder loop', () => { }) it('an OpenCode 2 SessionStart kicks no round', async () => { - writeDb(dbPath, 'session') + store.add() const sweep = vi.fn(async () => []) server.bindDeps({ sweep }) vi.useFakeTimers() @@ -136,7 +173,7 @@ describe('opencode binder loop', () => { }) it('pane teardown unbinds its sessions', async () => { - writeDb(dbPath, 'session') + store.add() await server.runBinderRound() expect(server.readRegistry('ses_live')).toBe(PANE_A) server.clearPaneState(PANE_A) @@ -144,12 +181,12 @@ describe('opencode binder loop', () => { }) it('stops the loop without hanging the process', () => { - writeDb(dbPath, 'session') + store.add() expect(() => server.stop()).not.toThrow() }) it('runs a round immediately on loop start', async () => { - writeDb(dbPath, 'session') + store.add() server.startBinderLoop() try { await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) @@ -159,7 +196,7 @@ describe('opencode binder loop', () => { }) it('discards a round that was in flight across stop', async () => { - writeDb(dbPath, 'session') + store.add() let releaseSweep!: () => void const sweepGate = new Promise((resolve) => { releaseSweep = resolve @@ -186,7 +223,7 @@ describe('opencode binder loop', () => { }) it('an obsolete round does not clear the new round running flag', async () => { - writeDb(dbPath, 'session') + store.add() let releaseFirst!: () => void let releaseLater!: () => void const firstGate = new Promise((resolve) => { @@ -232,19 +269,17 @@ describe('opencode binder loop', () => { // OpenCode 1 `serve` in pane A stamps every post with pane A; `attach` in pane B drives the session. describe('OpenCode 1 serve + attach', () => { - let dir = '' - let dbPath = '' let server: BinderTestServer beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-attach-')) - dbPath = join(dir, 'opencode.db') - writeDb(dbPath, 'session') + const store = new FakeSessionStore() + store.add() const startedAtMs = Date.now() - 120_000 server = new BinderTestServer() server.bindDeps({ now: () => Date.now(), - dbPath: () => dbPath, + dbPath: () => DB_PATH, + listSessions: store.list, listPanes: () => [ { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }, { paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 } @@ -264,7 +299,6 @@ describe('OpenCode 1 serve + attach', () => { afterEach(() => { server.stop() - rmSync(dir, { recursive: true, force: true }) }) const busy = (extra: Record = {}): Record => ({ @@ -284,80 +318,3 @@ describe('OpenCode 1 serve + attach', () => { expect(server.ingest('opencode', busy({ opencodeMajor: 2 }))).toBe(PANE_A) }) }) - -describe('listOpenCodeDbSessions', () => { - let dir = '' - let dbPath = '' - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-reader-')) - dbPath = join(dir, 'opencode.db') - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('reads OpenCode 1 session rows newer than the watermark', () => { - writeDb(dbPath, 'session') - const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null }) - expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([]) - }) - - it('skips OpenCode 2 rows in a database both versions wrote', () => { - writeDb(dbPath, 'session', 'ses_v1') - writeDb(dbPath, 'session_v2', 'ses_v2') - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }).map((row) => row.id)).toEqual([ - 'ses_v1' - ]) - }) - - it('returns [] for a missing database instead of throwing', () => { - expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) - }) - - it('stays quiet for a missing database and detects it once it appears', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const absent = join(dir, 'later.db') - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) - expect(warn).not.toHaveBeenCalled() - writeDb(absent, 'session') - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toHaveLength(1) - expect(warn).not.toHaveBeenCalled() - } finally { - warn.mockRestore() - } - }) - - it('still warns when the database exists but cannot be read', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(dbPath, 'this is not a sqlite database'.repeat(100)) - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) - expect(warn).toHaveBeenCalledTimes(1) - } finally { - warn.mockRestore() - } - }) - - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'warns when an existing database is behind an inaccessible directory', - () => { - writeDb(dbPath, 'session') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - chmodSync(dir, 0o000) - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) - expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0]?.[1]).toMatchObject({ code: 'EACCES' }) - } finally { - chmodSync(dir, 0o700) - warn.mockRestore() - } - } - ) -}) diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts index 48a75ba54832..a1d2d6d8d380 100644 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -3,13 +3,15 @@ import { applyBinderOwnerships, defaultOpenCodeDbPath, listBinderPaneSnapshots, - listOpenCodeDbSessions, OPENCODE_SESSION_CURSOR_START, runOpenCodeBinderRound, - type BinderPaneSnapshot, - type BinderSessionRow, - type OpenCodeSessionCursor + type BinderPaneSnapshot } from '../../opencode/opencode-session-binder' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../../foreign-sqlite-readers/opencode-binder-sessions-result' +import { readOpenCodeBinderSessions } from '../../foreign-sqlite-readers/foreign-sqlite-reader-spawn' import { sweepProcessIdentities, type ProcessIdentityRow @@ -29,7 +31,8 @@ const OPENCODE_BINDER_UNBOUND_MAX = 500 export type OpenCodeBinderLoopDeps = { now: () => number dbPath: () => string - listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[] + /** Resolves `[]` when the store or its worker cannot answer; never rejects. */ + listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => Promise listPanes: () => BinderPaneSnapshot[] sweep: () => Promise } @@ -54,7 +57,7 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi private openCodeBinderDeps: OpenCodeBinderLoopDeps = { now: () => Date.now(), dbPath: () => defaultOpenCodeDbPath(), - listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs), + listSessions: (dbPath, cursor) => readOpenCodeBinderSessions(dbPath, cursor), listPanes: () => listBinderPaneSnapshots(), sweep: () => sweepProcessIdentities() } @@ -131,7 +134,11 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi try { const deps = this.openCodeBinderDeps const nowMs = deps.now() - const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) + const fresh = await deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) + // Why: stop() may land while the worker reads; its state and watermark were reset. + if (generation !== this.openCodeBinderGeneration) { + return 0 + } const sessions = [...fresh] for (const [id, entry] of this.openCodeBinderUnbound) { if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) { diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts index 874482460177..ec28e6e4c251 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts @@ -78,41 +78,60 @@ describe('ForeignSqliteReaderClient worker lifecycle', () => { expect(workers).toHaveLength(2) }) - it('refuses new native workers until the retired worker positively terminates', async () => { - let spawns = 0 - let finishRetirement: (code: number) => void = () => {} - const retirement = new Promise((resolve) => { - finishRetirement = resolve - }) - const events = new EventEmitter() - const client = makeClient(() => { - spawns++ - return { - on: (...args) => events.on(...args), - off: (...args) => events.off(...args), - removeAllListeners: () => events.removeAllListeners(), - unref: () => undefined, - terminate: () => retirement, - postMessage: (request: { id: number }) => { - if (spawns > 1) { - queueMicrotask(() => - events.emit('message', { id: request.id, ok: true, value: MISSING }) - ) + // Every reader shares the lane, so each must keep the retirement gate. + it.each([ + { + reader: 'cursorProfile', + read: (client: ForeignSqliteReaderClient) => + client.readCursorProfile('/synthetic/state.vscdb'), + failure: FAILURE, + answer: MISSING + }, + { + reader: 'openCodeBinderSessions', + read: (client: ForeignSqliteReaderClient) => + client.readOpenCodeBinderSessions('/synthetic/opencode.db', { ms: 0, id: '' }), + failure: [], + answer: [{ id: 'ses_a', directory: '/w', createdAtMs: 1, parentId: null }] + } + ])( + '$reader refuses new native workers until the retired worker positively terminates', + async ({ read, failure, answer }) => { + let spawns = 0 + let finishRetirement: (code: number) => void = () => {} + const retirement = new Promise((resolve) => { + finishRetirement = resolve + }) + const events = new EventEmitter() + const client = makeClient(() => { + spawns++ + return { + on: (...args) => events.on(...args), + off: (...args) => events.off(...args), + removeAllListeners: () => events.removeAllListeners(), + unref: () => undefined, + terminate: () => retirement, + postMessage: (request: { id: number }) => { + if (spawns > 1) { + queueMicrotask(() => + events.emit('message', { id: request.id, ok: true, value: answer }) + ) + } } } + }, 20) + expect(await read(client)).toEqual(failure) + for (let i = 0; i < 3; i++) { + expect(await read(client)).toEqual(failure) } - }, 20) - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(FAILURE) - for (let i = 0; i < 3; i++) { - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(FAILURE) + expect(spawns).toBe(1) + finishRetirement(1) + await retirement + await afterRetirement() + expect(await read(client)).toEqual(answer) + expect(spawns).toBe(2) } - expect(spawns).toBe(1) - finishRetirement(1) - await retirement - await afterRetirement() - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(MISSING) - expect(spawns).toBe(2) - }) + ) it('settles pending reads when disposed', async () => { const client = makeClient(() => new Worker('setInterval(() => {}, 1000)', { eval: true })) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts index 5f1b614d5112..7832b37b7c87 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts @@ -252,3 +252,108 @@ describe('ForeignSqliteReaderLane', () => { lane.dispose() }) }) + +describe('ForeignSqliteReaderClient OpenCode binder sessions', () => { + const START = { ms: 0, id: '' } + const ROW = { id: 'ses_a', directory: '/w', createdAtMs: 100, parentId: null } + + it('returns the rows the worker posts', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const read = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 5, id: 'x' }) + expect(workers[0]?.posted[0]).toMatchObject({ + kind: 'openCodeBinderSessions', + dbPath: '/o/opencode.db', + cursor: { ms: 5, id: 'x' } + }) + workers[0]?.reply([ROW]) + await expect(read).resolves.toEqual([ROW]) + client.dispose() + }) + + it('returns no sessions on a worker error, a malformed reply, a timeout or a crash', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {}, + timeoutMs: 50 + }) + const errored = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.emit('message', { id: workers[0].posted[0]?.id, ok: false, error: 'busy' }) + await expect(errored).resolves.toEqual([]) + const malformed = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.reply([{ id: 'ses_a' }]) + await expect(malformed).resolves.toEqual([]) + const crashed = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.emit('exit', 1) + await expect(crashed).resolves.toEqual([]) + await expect(client.readOpenCodeBinderSessions('/o/opencode.db', START)).resolves.toEqual([]) + client.dispose() + }) + + it('does not share an in-flight read across cursors', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const stale = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 100, id: 'ses_a' }) + const restarted = client.readOpenCodeBinderSessions('/o/opencode.db', START) + expect(restarted).not.toBe(stale) + expect(client.readOpenCodeBinderSessions('/o/opencode.db', START)).toBe(restarted) + client.dispose() + }) + + it('runs on its own thread, apart from the Cursor reader', () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + void client.readCursorProfile('/a/state.vscdb') + void client.readOpenCodeBinderSessions('/o/opencode.db', START) + expect(workers).toHaveLength(2) + expect(workers[1]?.posted[0]?.kind).toBe('openCodeBinderSessions') + client.dispose() + }) +}) + +describe('ForeignSqliteReaderClient idle teardown', () => { + it('keeps the binder thread alive across its 60 s poll and tears the Cursor one down', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {} + }) + const cursor = client.readCursorProfile('/a/state.vscdb') + const binder = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 0, id: '' }) + workers[0]?.reply(OK) + workers[1]?.reply([]) + await Promise.all([cursor, binder]) + await vi.advanceTimersByTimeAsync(60_000) + expect(workers[0]?.terminated).toBe(true) + expect(workers[1]?.terminated).toBe(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(workers[1]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('takes a per-reader override', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {}, + idleTeardownMs: { openCodeBinderSessions: 1_000 } + }) + const binder = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 0, id: '' }) + workers[0]?.reply([]) + await binder + await vi.advanceTimersByTimeAsync(1_000) + expect(workers[0]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts index b04ed8895efe..854d3a06dcdf 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts @@ -5,6 +5,12 @@ import { parseCursorProfileReadResult, type CursorDesktopProfileReadResult } from './cursor-profile-result' +import { + openCodeBinderSessionsFailure, + parseOpenCodeBinderSessions, + type BinderSessionRow, + type OpenCodeSessionCursor +} from './opencode-binder-sessions-result' import type { ForeignSqliteReaderKind, ForeignSqliteReaderRequest, @@ -18,9 +24,15 @@ import type { // Limits from the dedicated Cursor worker this replaces (#24572). const READ_TIMEOUT_MS: Record = { // Covers the 4-6 s WAL-index rebuild reported in #24360 with margin. - cursorProfile: 10_000 + cursorProfile: 10_000, + openCodeBinderSessions: 60_000 +} +// Why per reader: a thread torn down between a poller's rounds is respawned every round. +const DEFAULT_IDLE_TEARDOWN_MS: Record = { + cursorProfile: 30_000, + // The OpenCode binder polls every 60 s. + openCodeBinderSessions: 120_000 } -const IDLE_TEARDOWN_MS = 30_000 const MAX_CONSECUTIVE_DEATHS = 2 // Reads are deduped per key, so a backlog past this is pile-up, not demand. const MAX_QUEUED_READS = 8 @@ -113,18 +125,19 @@ export class ForeignSqliteReaderLane { export class ForeignSqliteReaderClient { private readonly cursorProfile: ForeignSqliteReaderLane + private readonly openCodeBinderSessions: ForeignSqliteReaderLane constructor(options: { workerFactory: WorkerThreadFactory log?: (message: string) => void timeoutMs?: number - idleTeardownMs?: number + idleTeardownMs?: Partial> }) { const settings = (kind: ForeignSqliteReaderKind): LaneSettings => ({ workerFactory: options.workerFactory, log: options.log ?? ((message: string) => console.warn(message)), timeoutMs: options.timeoutMs ?? READ_TIMEOUT_MS[kind], - idleTeardownMs: options.idleTeardownMs ?? IDLE_TEARDOWN_MS + idleTeardownMs: options.idleTeardownMs?.[kind] ?? DEFAULT_IDLE_TEARDOWN_MS[kind] }) this.cursorProfile = new ForeignSqliteReaderLane( 'cursorProfile', @@ -132,6 +145,12 @@ export class ForeignSqliteReaderClient { cursorProfileReadFailure, settings('cursorProfile') ) + this.openCodeBinderSessions = new ForeignSqliteReaderLane( + 'openCodeBinderSessions', + parseOpenCodeBinderSessions, + openCodeBinderSessionsFailure, + settings('openCodeBinderSessions') + ) } /** @@ -144,8 +163,30 @@ export class ForeignSqliteReaderClient { return this.cursorProfile.read(dbPath, (id) => ({ id, kind: 'cursorProfile', dbPath })) } + /** + * List OpenCode 1 sessions newer than `cursor` off the main thread. + * @param dbPath - The shared server's opencode.db. + * @param cursor - Store position the binder has handled up to. + * @returns Rows oldest first; `[]` when the store or the worker cannot answer. + */ + readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor + ): Promise { + // Why the cursor in the key: a round from before a stop can still be in flight + // with an older cursor, and its rows are not the answer for a restarted round. + const key = JSON.stringify([dbPath, cursor.ms, cursor.id]) + return this.openCodeBinderSessions.read(key, (id) => ({ + id, + kind: 'openCodeBinderSessions', + dbPath, + cursor: { ms: cursor.ms, id: cursor.id } + })) + } + dispose(): void { this.cursorProfile.dispose() + this.openCodeBinderSessions.dispose() } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts index 4aebc6408e4c..0d25bca0a533 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { handleForeignSqliteReaderRequest } from './foreign-sqlite-reader-dispatch' @@ -12,6 +15,28 @@ describe('handleForeignSqliteReaderRequest', () => { ).toEqual({ id: 4, ok: true, value: { status: 'missing' } }) }) + it('routes openCodeBinderSessions to its reader and reports a failed open as an error', () => { + const request = { kind: 'openCodeBinderSessions' as const, cursor: { ms: 0, id: '' } } + expect( + handleForeignSqliteReaderRequest({ + ...request, + id: 5, + dbPath: '/definitely/missing/opencode.db' + }) + ).toEqual({ id: 5, ok: true, value: [] }) + const dir = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-dispatch-')) + try { + const dbPath = join(dir, 'opencode.db') + writeFileSync(dbPath, 'not a sqlite database'.repeat(100)) + expect(handleForeignSqliteReaderRequest({ ...request, id: 6, dbPath })).toMatchObject({ + id: 6, + ok: false + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('rejects a kind no reader owns instead of running one', () => { // Parsed, as a structured clone arrives: untyped, with a kind outside the union. const request = JSON.parse('{"id":9,"kind":"list","dbPaths":[]}') diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts index 69214e5edcd8..c1e5b420d6d6 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts @@ -3,6 +3,7 @@ import type { ForeignSqliteReaderResponse } from './foreign-sqlite-reader-protocol' import { readCursorProfile } from './readers/cursor-profile' +import { readOpenCodeBinderSessions } from './readers/opencode-binder-sessions' /** * Run one foreign-app SQLite read on the worker thread. @@ -12,14 +13,19 @@ import { readCursorProfile } from './readers/cursor-profile' export function handleForeignSqliteReaderRequest( request: ForeignSqliteReaderRequest ): ForeignSqliteReaderResponse { + // Destructured from the parameter so `kind` narrows `request` and ends as `never`. + const { id, kind } = request try { - if (request.kind === 'cursorProfile') { - return { id: request.id, ok: true, value: readCursorProfile(request.dbPath) } + switch (kind) { + case 'cursorProfile': + return { id, ok: true, value: readCursorProfile(request.dbPath) } + case 'openCodeBinderSessions': + return { id, ok: true, value: readOpenCodeBinderSessions(request.dbPath, request.cursor) } } // A structured clone can carry any kind; one without a reader is refused, not guessed at. - return unknownKind(request.id, request.kind) + return unknownKind(id, kind) } catch (err) { - return { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) } + return { id, ok: false, error: err instanceof Error ? err.message : String(err) } } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts index 23280d8b0699..f0cb7715bef4 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts @@ -4,6 +4,7 @@ import { FOREIGN_SQLITE_READER_ENTRY_FILENAME as ENTRY, resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' +import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../shared/orcad-artifacts' const unpackaged = (moduleDir: string) => ({ isPackaged: false, @@ -12,6 +13,10 @@ const unpackaged = (moduleDir: string) => ({ }) describe('resolveForeignSqliteReaderEntryPath', () => { + it('looks for the file name the orcad build emits', () => { + expect(ENTRY).toBe(ORCAD_FOREIGN_SQLITE_READER_ENTRY) + }) + it('resolves the entry beside a dev bundle', () => { const expected = join('out', 'main', ENTRY) expect( diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts index 99293a5b7615..2dc350843568 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts @@ -1,12 +1,21 @@ // Type-only and electron-free: the worker entry and the main-process client both import it. +import type { OpenCodeSessionCursor } from './opencode-binder-sessions-result' + type CursorProfileRequest = { id: number kind: 'cursorProfile' dbPath: string } -export type ForeignSqliteReaderRequest = CursorProfileRequest +type OpenCodeBinderSessionsRequest = { + id: number + kind: 'openCodeBinderSessions' + dbPath: string + cursor: OpenCodeSessionCursor +} + +export type ForeignSqliteReaderRequest = CursorProfileRequest | OpenCodeBinderSessionsRequest export type ForeignSqliteReaderKind = ForeignSqliteReaderRequest['kind'] diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts index 706c32377cbd..de4654d1160c 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts @@ -4,6 +4,7 @@ import { currentWorkerEntryLayout } from '../worker-thread-entry-path' import type { CursorDesktopProfileReadResult } from './cursor-profile-result' import { ForeignSqliteReaderClient } from './foreign-sqlite-reader-client' import { resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' +import type { BinderSessionRow, OpenCodeSessionCursor } from './opencode-binder-sessions-result' // Why: owns the process-wide client and the real worker factory, so the client // class stays testable with a fake factory and callers see only plain functions. @@ -32,3 +33,16 @@ function getSharedClient(): ForeignSqliteReaderClient { export function readCursorDesktopProfile(dbPath: string): Promise { return getSharedClient().readCursorProfile(dbPath) } + +/** + * List OpenCode 1 sessions newer than `cursor` on the foreign SQLite reader worker. + * @param dbPath - The shared server's opencode.db. + * @param cursor - Store position the binder has handled up to. + * @returns Rows oldest first; `[]` when the store or the worker cannot answer. + */ +export function readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor +): Promise { + return getSharedClient().readOpenCodeBinderSessions(dbPath, cursor) +} diff --git a/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts new file mode 100644 index 000000000000..9a27f89f2ea7 --- /dev/null +++ b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +/** One session store row feeding a binder round. */ +export type BinderSessionRow = { + id: string + directory: string + createdAtMs: number + parentId: string | null +} + +/** Position in the session store; composite so same-millisecond rows are never skipped. */ +export type OpenCodeSessionCursor = { + ms: number + id: string +} + +/** No sessions: what the binder read has always answered when the store can't be read. */ +export function openCodeBinderSessionsFailure(): BinderSessionRow[] { + return [] +} + +const rowsSchema = z.array( + z.object({ + id: z.string(), + directory: z.string(), + createdAtMs: z.number(), + parentId: z.string().nullable() + }) +) + +/** A worker reply arrives as a structured clone; null when it is not a list of session rows. */ +export function parseOpenCodeBinderSessions(value: unknown): BinderSessionRow[] | null { + const parsed = rowsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts new file mode 100644 index 000000000000..83396ff02d91 --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts @@ -0,0 +1,105 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../../sqlite/sync-database' +import { readOpenCodeBinderSessions } from './opencode-binder-sessions' + +const DIR = '/tmp/binder-worktree-a' +const START = { ms: 0, id: '' } + +let dir = '' +let dbPath = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-binder-reader-')) + dbPath = join(dir, 'opencode.db') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeSessions( + table: 'session' | 'session_v2', + rows: { id: string; createdAtMs: number; parentId?: string }[] +): void { + const db = new SyncDatabase(dbPath) + try { + db.exec( + `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` + ) + const insert = db.prepare( + `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` + ) + for (const row of rows) { + insert.run(row.id, DIR, row.createdAtMs, row.parentId ?? null) + } + } finally { + db.close() + } +} + +describe('readOpenCodeBinderSessions', () => { + it('reads OpenCode 1 rows past the cursor, oldest first', () => { + writeSessions('session', [ + { id: 'ses_b', createdAtMs: 200, parentId: 'ses_a' }, + { id: 'ses_a', createdAtMs: 100 } + ]) + expect(readOpenCodeBinderSessions(dbPath, START)).toEqual([ + { id: 'ses_a', directory: DIR, createdAtMs: 100, parentId: null }, + { id: 'ses_b', directory: DIR, createdAtMs: 200, parentId: 'ses_a' } + ]) + expect(readOpenCodeBinderSessions(dbPath, { ms: 200, id: 'ses_b' })).toEqual([]) + }) + + it('re-lists rows that share the cursor millisecond with a later id', () => { + writeSessions('session', [ + { id: 'ses_a', createdAtMs: 100 }, + { id: 'ses_b', createdAtMs: 100 } + ]) + expect( + readOpenCodeBinderSessions(dbPath, { ms: 100, id: 'ses_a' }).map((row) => row.id) + ).toEqual(['ses_b']) + }) + + it('skips OpenCode 2 rows in a database both versions wrote', () => { + writeSessions('session', [{ id: 'ses_v1', createdAtMs: 100 }]) + writeSessions('session_v2', [{ id: 'ses_v2', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START).map((row) => row.id)).toEqual(['ses_v1']) + }) + + it('reads a store without the session table as empty', () => { + writeSessions('session_v2', [{ id: 'ses_v2', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START)).toEqual([]) + }) + + // #24577: an absent store is normal, so it answers [] and the client logs nothing. + it('reads a missing database as empty and detects it once it appears', () => { + expect(readOpenCodeBinderSessions(join(dir, 'later.db'), START)).toEqual([]) + expect(readOpenCodeBinderSessions(join(dir, 'missing', 'opencode.db'), START)).toEqual([]) + dbPath = join(dir, 'later.db') + writeSessions('session', [{ id: 'ses_a', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START)).toHaveLength(1) + }) + + it('throws for a corrupt database so the client logs it and answers its failure value', () => { + writeFileSync(dbPath, 'not a sqlite database'.repeat(100)) + expect(() => readOpenCodeBinderSessions(dbPath, START)).toThrow() + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'throws when an existing database is behind an inaccessible directory', + () => { + writeSessions('session', [{ id: 'ses_a', createdAtMs: 100 }]) + try { + chmodSync(dir, 0o000) + expect(() => readOpenCodeBinderSessions(dbPath, START)).toThrow( + expect.objectContaining({ code: 'EACCES' }) + ) + } finally { + chmodSync(dir, 0o700) + } + } + ) +}) diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts new file mode 100644 index 000000000000..817e9469f8e4 --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts @@ -0,0 +1,80 @@ +import { statSync } from 'node:fs' +import { readOpenCodeDatabase } from '../../ai-vault/session-scanner-opencode-sqlite-open' +import { columnExists, tableExists } from '../../opencode-usage/schema-helpers' +import type { BinderSessionRow, OpenCodeSessionCursor } from '../opencode-binder-sessions-result' + +/** + * Sessions newer than `cursor`, oldest first. The composite `(time_created, id)` + * position re-lists rows sharing a millisecond with the cursor, including rows + * the LIMIT cut off last round. Unknown shapes read as empty so an OpenCode + * schema move degrades to unbound sessions. An absent store is empty (#24577); + * any other failed open throws, so the client logs it. + */ +export function readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor +): BinderSessionRow[] { + if (!storeExists(dbPath)) { + return [] + } + return readOpenCodeDatabase({ + dbPath, + read: (db) => { + // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its + // posts name their own pane, so its sessions must never bind. + const table = 'session' + if ( + !tableExists(db, table) || + !columnExists(db, table, 'directory') || + !columnExists(db, table, 'time_created') + ) { + return [] + } + const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' + const rows: unknown[] = db + .prepare( + `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` + ) + .all(cursor.ms, cursor.ms, cursor.id) + const sessions: BinderSessionRow[] = [] + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + continue + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. + const record = row as Record + if ( + typeof record.id !== 'string' || + typeof record.directory !== 'string' || + typeof record.time_created !== 'number' + ) { + continue + } + sessions.push({ + id: record.id, + directory: record.directory, + createdAtMs: record.time_created, + parentId: typeof record.parent_id === 'string' ? record.parent_id : null + }) + } + return sessions + } + }) +} + +// Why stat, not existsSync: a permission failure must surface, not read as an unused store. +function storeExists(dbPath: string): boolean { + try { + statSync(dbPath) + return true + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err.code === 'ENOENT' || err.code === 'ENOTDIR') + ) { + return false + } + throw err + } +} diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 8b62a05fa82f..7f7d95876c82 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -1,4 +1,3 @@ -import { statSync } from 'node:fs' import { resolveOpenCodeDataDirectory } from './opencode-data-directory' import { bindOpenCodeSession, @@ -11,8 +10,10 @@ import { type CorrelatedSession, type SessionOwnership } from '../../shared/agent-hook-listener/opencode-session-correlation' -import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open' -import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../foreign-sqlite-readers/opencode-binder-sessions-result' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { listRegisteredPtys } from '../memory/pty-registry' import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep' @@ -24,9 +25,9 @@ import type { HookListenerState } from '../../shared/agent-hook-listener/listene * * Each round: read new sessions from the shared server's SQLite store, * snapshot panes, sweep for live clients, correlate, bind. Everything the - * round needs is injected so the decision core stays unit-testable; only - * the SQLite reader below touches disk, reusing ai-vault's guarded open - * (read-only + query_only + busy timeout). + * round needs is injected so the decision core stays unit-testable; the + * store read runs on the foreign SQLite reader worker + * (foreign-sqlite-readers/readers/opencode-binder-sessions.ts). */ /** One pane snapshot feeding a binder round. */ @@ -38,14 +39,6 @@ export type BinderPaneSnapshot = { shellPid: number | null } -/** One session store row feeding a binder round. */ -export type BinderSessionRow = { - id: string - directory: string - createdAtMs: number - parentId: string | null -} - /** Everything one binder round needs, injected for tests. */ export type BinderRoundDeps = { nowMs: number @@ -61,12 +54,6 @@ export type BinderRoundResult = { ownerships: SessionOwnership[] } -/** Position in the session store; composite so same-millisecond rows are never skipped. */ -export type OpenCodeSessionCursor = { - ms: number - id: string -} - /** Cursor before anything was ever read. */ export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' } @@ -213,77 +200,6 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult return { ownerships } } -/** - * Sessions newer than `cursor`, oldest first. The composite - * `(time_created, id)` position means rows sharing a millisecond with the - * cursor — including rows the LIMIT cut off last round — are re-listed - * instead of permanently skipped. Unknown shapes read as empty so an opencode - * schema move degrades to unbound sessions, never a crash. Fail-open [] on - * any read error for the same reason. - */ -export function listOpenCodeDbSessions( - dbPath: string, - cursor: OpenCodeSessionCursor -): BinderSessionRow[] { - try { - // Check absence without hiding permission failures as an unused store. - statSync(dbPath) - return readOpenCodeDatabase({ - dbPath, - read: (db) => { - // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its - // posts name their own pane, so its sessions must never bind. - const table = 'session' - if ( - !tableExists(db, table) || - !columnExists(db, table, 'directory') || - !columnExists(db, table, 'time_created') - ) { - return [] - } - const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' - const rows: unknown[] = db - .prepare( - `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` - ) - .all(cursor.ms, cursor.ms, cursor.id) - const sessions: BinderSessionRow[] = [] - for (const row of rows) { - if (typeof row !== 'object' || row === null) { - continue - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. - const record = row as Record - if ( - typeof record.id !== 'string' || - typeof record.directory !== 'string' || - typeof record.time_created !== 'number' - ) { - continue - } - sessions.push({ - id: record.id, - directory: record.directory, - createdAtMs: record.time_created, - parentId: typeof record.parent_id === 'string' ? record.parent_id : null - }) - } - return sessions - } - }) - } catch (err) { - if ( - err instanceof Error && - 'code' in err && - (err.code === 'ENOENT' || err.code === 'ENOTDIR') - ) { - return [] - } - console.warn('[opencode-binder] session store read failed; skipping round', err) - return [] - } -} - /** Default database path for the local shared server. */ export function defaultOpenCodeDbPath(): string { return `${resolveOpenCodeDataDirectory()}/opencode.db` diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts index e66b3c44702a..9f6f63fdc822 100644 --- a/src/main/orcad/orcad-forked-child-paths.test.ts +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -4,6 +4,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getAppEnvironment } from '../../shared/app-environment' import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { + FOREIGN_SQLITE_READER_ENTRY_FILENAME, + resolveForeignSqliteReaderEntryPath +} from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path' +import { currentWorkerEntryLayout } from '../worker-thread-entry-path' import { installOrcadHostAdapters } from './orcad-entry' /** @@ -20,6 +25,7 @@ describe('orcad forked-child paths', () => { // The layout build-orcad.mjs emits: the bundle and its children side by side. writeFileSync(join(deployRoot, 'orcad.js'), '') writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') + writeFileSync(join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME), '') originalArgv = process.argv process.argv = [process.execPath, join(deployRoot, 'orcad.js')] installOrcadHostAdapters() @@ -34,6 +40,13 @@ describe('orcad forked-child paths', () => { expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) }) + it('starts the foreign SQLite reader worker shipped beside orcad.js', () => { + // orcad.js is one bundle, so its __dirname is the deploy root. + expect(resolveForeignSqliteReaderEntryPath(currentWorkerEntryLayout(deployRoot))).toBe( + join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME) + ) + }) + it('ignores a decoy child under the working directory', () => { // A cwd-derived app root finds the real child only when the operator happens to // launch from the install directory, which a supervisor never does — and here it diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index a84ce87be197..33462dcb95a5 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -5,6 +5,7 @@ import { BUNDLED_RIPGREP_PLATFORMS, bundledRipgrepBinaryName } from './bundled-r import { SERVER_TARGETS } from './node-runtime-pin' import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_RIPGREP_ARTIFACTS, @@ -16,6 +17,13 @@ import { } from './orcad-artifacts' describe('standalone runtime artifacts', () => { + it.each(SERVER_TARGETS)( + 'ships the foreign SQLite reader worker the %s runtime starts', + (target) => { + expect(orcadArtifactFilenames(target)).toContain(ORCAD_FOREIGN_SQLITE_READER_ENTRY) + } + ) + it('names a search binary for every SSH host platform', () => { const expected = BUNDLED_RIPGREP_PLATFORMS.map( (platform) => `ripgrep/${platform}/${bundledRipgrepBinaryName(platform)}` diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index c0cbf30bd221..ba9fa05545e6 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -101,6 +101,9 @@ export const ORCAD_EMOJI_SHORTCODE_DATASET = export const ORCAD_VERSION = '0.1.0' +// Equals FOREIGN_SQLITE_READER_ENTRY_FILENAME; that module is not loadable under type stripping. +export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' + // Kept here because build-orcad.mjs imports this manifest directly under Node type stripping. export const ORCAD_RIPGREP_ARTIFACTS = [ 'ripgrep/linux-x64/rg', @@ -153,6 +156,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'daemon-entry.js' }, { filename: 'profile-state-writer-worker-entry.js' }, { filename: 'profile-state-backup-worker-entry.js' }, + // Worker thread that reads other apps' SQLite (the OpenCode binder and history) off the event loop. + { filename: ORCAD_FOREIGN_SQLITE_READER_ENTRY }, // Target-specific even when the JavaScript bundle is shared across packaged slots. { filename: ORCAD_SERVER_TARGET_FILENAME }, // orcad never depends on a host runtime or host-installed native module. From 142ebc45f54114f1b9854069ae9e441d19232011 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 2 Oct 2026 18:30:02 -0400 Subject: [PATCH 2/2] test(opencode): cover the binder read deadline with fake timers and name the failure test accurately (STA-9122) --- .../foreign-sqlite-reader-client.test.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts index 7832b37b7c87..91a25f7124a4 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts @@ -271,12 +271,34 @@ describe('ForeignSqliteReaderClient OpenCode binder sessions', () => { client.dispose() }) - it('returns no sessions on a worker error, a malformed reply, a timeout or a crash', async () => { + it('returns no sessions once the 60 s read deadline elapses', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {} + }) + let result: unknown + void client.readOpenCodeBinderSessions('/o/opencode.db', START).then((value) => { + result = value + }) + await vi.advanceTimersByTimeAsync(59_999) + expect(result).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result).toEqual([]) + expect(workers[0]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('returns no sessions on a worker error, a malformed reply or a crash', async () => { const workers: FakeWorker[] = [] const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), - log() {}, - timeoutMs: 50 + log() {} }) const errored = client.readOpenCodeBinderSessions('/o/opencode.db', START) workers[0]?.emit('message', { id: workers[0].posted[0]?.id, ok: false, error: 'busy' }) @@ -287,7 +309,6 @@ describe('ForeignSqliteReaderClient OpenCode binder sessions', () => { const crashed = client.readOpenCodeBinderSessions('/o/opencode.db', START) workers[0]?.emit('exit', 1) await expect(crashed).resolves.toEqual([]) - await expect(client.readOpenCodeBinderSessions('/o/opencode.db', START)).resolves.toEqual([]) client.dispose() })