Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md

Large diffs are not rendered by default.

1,417 changes: 1,417 additions & 0 deletions docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions server/coding-cli/providers/opencode-listing-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export type OpencodeSessionRow = {
sessionId: string
cwd: string
title: string
createdAt: number
lastActivityAt: number
projectPath: string | null
hasThreeViewsMarker?: number | null
}

export type OpencodeListingResult = {
rows: OpencodeSessionRow[]
schemaMissingParentId: boolean
}

export const THREE_VIEWS_MARKER_SQL_PATTERN = '%<freshell-session-metadata origin=3-views%'

const OPENCODE_DB_BUSY_TIMEOUT_MS = 5000

/**
* OpenCode session listing query. Opens the DB read-only, inspects whether the
* session table exposes parent_id, runs the root-session listing (including the
* hasThreeViewsMarker LIKE subqueries), and returns the raw rows.
*
* The DB work is the heavy, thread-blocking part (~180 ms on a 531 MB DB) — which
* is exactly why this runs inside a worker thread. The function is `async` ONLY
* because it imports `node:sqlite` LAZILY: a static top-level import would be
* eagerly triggered when opencode.ts loads and fire vi.mock('node:sqlite')'s
* hoisted factory before the mock test's inline FakeDatabaseSync class is
* initialized (TDZ ReferenceError). Lazy `await import('node:sqlite')` is the
* same pattern the current production code uses and is intercepted correctly by
* vi.mock. No logging, no fs-async — trivially worker-portable.
*/
export async function runOpencodeListingQuery(
dbPath: string,
markerPattern: string,
): Promise<OpencodeListingResult> {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(dbPath, { readOnly: true })
try {
db.exec(`PRAGMA busy_timeout = ${OPENCODE_DB_BUSY_TIMEOUT_MS}`)
const columns = db.prepare('PRAGMA table_info(session)').all() as Array<{ name?: unknown }>
const hasParentId = columns.some((c) => c.name === 'parent_id')
const rootFilter = hasParentId ? 'AND s.parent_id IS NULL' : ''
// The 3-views marker lives in part.data and/or message.data. Older/partial
// schemas (and the e2e fake-opencode fixture, which has only project+session)
// may lack one or both tables; build the marker check from whichever tables
// are present (the marker can live in either), and degrade to "unmarked" if
// neither exists — instead of throwing "no such table: part".
const tableNames = new Set(
(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name?: unknown }>)
.map((row) => row.name),
)
const markerClauses: string[] = []
const markerParams: string[] = []
if (tableNames.has('part')) {
markerClauses.push('EXISTS (SELECT 1 FROM part pa WHERE pa.session_id = s.id AND pa.data LIKE ?)')
markerParams.push(markerPattern)
}
if (tableNames.has('message')) {
markerClauses.push('EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id AND m.data LIKE ?)')
markerParams.push(markerPattern)
}
const markerExpr = markerClauses.length > 0 ? `(${markerClauses.join(' OR ')})` : '0'
const rows = db.prepare(`
SELECT
s.id AS sessionId,
s.directory AS cwd,
s.title AS title,
s.time_created AS createdAt,
s.time_updated AS lastActivityAt,
p.worktree AS projectPath,
${markerExpr} AS hasThreeViewsMarker
FROM session s
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return an empty result when the schema is not created yet

If opencode.db already exists but OpenCode has not created the session table yet (for example a freshly-created/zero-byte DB, or a reset DB before schema initialization), this query throws no such table: session. Because listSessionsDirect() now rethrows worker/query failures and the indexer preserves cached direct sessions on that path, this scenario is treated as a transient read failure instead of an empty database, leaving stale OpenCode sessions in the sidebar until a later successful scan. Guard for a missing session table and return { rows: [], schemaMissingParentId: false } before preparing this SELECT.

Useful? React with 👍 / 👎.

LEFT JOIN project p ON p.id = s.project_id
WHERE s.time_archived IS NULL
${rootFilter}
ORDER BY s.time_updated DESC
`).all(...markerParams) as OpencodeSessionRow[]
return { rows, schemaMissingParentId: !hasParentId }
} finally {
db.close()
}
}
119 changes: 119 additions & 0 deletions server/coding-cli/providers/opencode-listing-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Worker } from 'node:worker_threads'
import type { OpencodeListingResult, OpencodeSessionRow } from './opencode-listing-query.js'
import { runOpencodeListingQuery } from './opencode-listing-query.js'
// Importing the worker module on the MAIN thread (or a Vitest worker) is safe:
// its auto-run is sentinel-guarded, so this import never spawns/posts anything.
import { OPENCODE_LISTING_WORKER_KIND } from './opencode-listing.worker.js'

export type OpencodeListingQueryInput = { dbPath: string; markerPattern: string }
export type OpencodeListingQueryRunner = (input: OpencodeListingQueryInput) => Promise<OpencodeListingResult>

type WorkerLike = {
on(event: 'message', listener: (value: unknown) => void): unknown
on(event: 'error', listener: (err: Error) => void): unknown
on(event: 'exit', listener: (code: number) => void): unknown
terminate(): Promise<number> | void
}

export type WorkerSpawnOptions = { workerData: unknown; execArgv: string[] }

export type CreateWorkerListingRunnerOptions = {
/** Injectable for unit tests; default spawns a real worker_threads Worker. */
spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike
/** Override the query-module URL (used by the off-thread integration fixture). */
queryModuleUrl?: string
/** Hard timeout for a single listing query. Default 15 s (the real query is ~180 ms). */
timeoutMs?: number
}

const DEFAULT_TIMEOUT_MS = 15_000
// import.meta.url ends with `.ts` in dev/test (tsx / native strip-types) and
// `.js` in prod (compiled dist). Resolve siblings with the matching extension.
const SELF_EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js'
// Append to process.execArgv (do NOT replace) so tsx's `--import .../loader.mjs`
// is inherited in dev; the flag silences node:sqlite's per-spawn ExperimentalWarning.
const WORKER_EXECARGV = [...process.execArgv, '--disable-warning=ExperimentalWarning']

function defaultWorkerUrl(): URL {
return new URL(`./opencode-listing.worker${SELF_EXT}`, import.meta.url)
}
function defaultQueryModuleUrl(): string {
return new URL(`./opencode-listing-query${SELF_EXT}`, import.meta.url).href
}
function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike {
return new Worker(workerUrl, options)
}

type OkMessage = { ok: true; rows: OpencodeSessionRow[]; schemaMissingParentId: boolean }
type ErrMessage = { ok: false; error: { name: string; message: string } }

// Validate the FULL shape, not just the presence of `ok` — a truncated/garbled
// message like `{ ok: true }` must NOT resolve `{ rows: undefined }`.
function isOkMessage(value: unknown): value is OkMessage {
return typeof value === 'object' && value !== null
&& (value as { ok?: unknown }).ok === true
&& Array.isArray((value as { rows?: unknown }).rows)
&& typeof (value as { schemaMissingParentId?: unknown }).schemaMissingParentId === 'boolean'
}
function isErrMessage(value: unknown): value is ErrMessage {
if (typeof value !== 'object' || value === null) return false
if ((value as { ok?: unknown }).ok !== false) return false
const error = (value as { error?: unknown }).error
return typeof error === 'object' && error !== null
&& typeof (error as { message?: unknown }).message === 'string'
}

export function createWorkerListingRunner(
options: CreateWorkerListingRunnerOptions = {},
): OpencodeListingQueryRunner {
const spawn = options.spawn ?? defaultSpawn
const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const workerUrl = defaultWorkerUrl()

return (input: OpencodeListingQueryInput): Promise<OpencodeListingResult> => {
return new Promise<OpencodeListingResult>((resolve, reject) => {
const worker = spawn(workerUrl, { workerData: { ...input, queryModuleUrl, kind: OPENCODE_LISTING_WORKER_KIND }, execArgv: WORKER_EXECARGV })
let settled = false
let timer: NodeJS.Timeout | undefined

const cleanup = () => {
if (timer) clearTimeout(timer)
try { void worker.terminate() } catch { /* ignore */ }
}
const settleResolve = (result: OpencodeListingResult) => {
if (settled) return
settled = true
cleanup()
resolve(result)
}
const settleReject = (err: Error) => {
if (settled) return
settled = true
cleanup()
reject(err)
}

timer = setTimeout(() => settleReject(new Error(`OpenCode listing worker timed out after ${timeoutMs}ms`)), timeoutMs)
if (typeof (timer as NodeJS.Timeout).unref === 'function') (timer as NodeJS.Timeout).unref()

worker.on('message', (value: unknown) => {
if (isOkMessage(value)) {
settleResolve({ rows: value.rows, schemaMissingParentId: value.schemaMissingParentId })
} else if (isErrMessage(value)) {
const err = new Error(value.error.message || 'OpenCode listing worker failed')
err.name = value.error.name ?? 'Error'
settleReject(err)
} else {
settleReject(new Error('OpenCode listing worker sent a malformed message'))
}
})
worker.on('error', (err: Error) => settleReject(err))
worker.on('exit', (code: number) => settleReject(new Error(`OpenCode listing worker exited (code ${code}) before responding`)))
})
}
}

/** Runs the listing query on the caller's thread (no worker). For tests and fallbacks. */
export const inProcessListingRunner: OpencodeListingQueryRunner = (input) =>
runOpencodeListingQuery(input.dbPath, input.markerPattern)
44 changes: 44 additions & 0 deletions server/coding-cli/providers/opencode-listing.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { parentPort, workerData } from 'node:worker_threads'
import type { OpencodeListingResult } from './opencode-listing-query.js'

/**
* Sentinel proving this thread was spawned by OUR runner. REQUIRED because the
* server Vitest config runs test files in worker threads (`pool: 'threads'`), so
* `parentPort` is non-null when a test imports this module. Without the sentinel,
* the auto-run block below would fire on import using Vitest's OWN workerData and
* post a message to Vitest's parent port — corrupting/hanging the test worker.
* The runner injects this exact value in workerData; Vitest's workerData never has it.
*/
export const OPENCODE_LISTING_WORKER_KIND = 'opencode-listing-worker'

export type WorkerListingInput = {
kind: typeof OPENCODE_LISTING_WORKER_KIND
queryModuleUrl: string
dbPath: string
markerPattern: string
}

/**
* Run the listing query by dynamically importing the EXACT resolved query-module
* URL (.ts in dev/test, .js in prod) provided by the spawning code. We pass the
* exact URL rather than a static relative import because NodeNext `.js`→`.ts`
* remapping fails inside a worker thread (validated by spike).
*/
export async function executeListing(
input: { queryModuleUrl: string; dbPath: string; markerPattern: string },
): Promise<OpencodeListingResult> {
const mod = await import(input.queryModuleUrl) as typeof import('./opencode-listing-query.js')
return mod.runOpencodeListingQuery(input.dbPath, input.markerPattern)
}

// Auto-run ONLY when we are a real worker spawned by our runner (parentPort present
// AND our sentinel in workerData). This is import-safe under Vitest's thread pool.
if (parentPort && (workerData as Partial<WorkerListingInput> | undefined)?.kind === OPENCODE_LISTING_WORKER_KIND) {
const port = parentPort
executeListing(workerData as WorkerListingInput)
.then((result) => port.postMessage({ ok: true, rows: result.rows, schemaMissingParentId: result.schemaMissingParentId }))
.catch((err: unknown) => {
const error = err instanceof Error ? { name: err.name, message: err.message } : { name: 'Error', message: String(err) }
port.postMessage({ ok: false, error })
})
}
Loading
Loading