-
Notifications
You must be signed in to change notification settings - Fork 23
perf(opencode): run session listing off the event loop in a worker #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
308 changes: 308 additions & 0 deletions
308
docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md
Large diffs are not rendered by default.
Oops, something went wrong.
1,417 changes: 1,417 additions & 0 deletions
1,417
docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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() | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }) | ||
| }) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
opencode.dbalready exists but OpenCode has not created thesessiontable yet (for example a freshly-created/zero-byte DB, or a reset DB before schema initialization), this query throwsno such table: session. BecauselistSessionsDirect()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 missingsessiontable and return{ rows: [], schemaMissingParentId: false }before preparing this SELECT.Useful? React with 👍 / 👎.