Files
orca/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts
T
f0b5b8566c Bound OpenCode history reads and repeated worker failures (#25292)
* fix(opencode): keep scan budgets across queue waits and batches

Reuse the scan-owned lifetime proposed in #10708 by @AmethystLiang with the existing shared worker queue.

* test(opencode): check nonempty session fixtures and lint scoped controls

* Derive OpenCode scan deadline message from its budget

---------

Co-authored-by: Neil Parker <nwparker@MacBook-Pro-3.localdomain>
Co-authored-by: OpenCode issue campaign <codex@localhost>
2026-10-04 14:31:22 -07:00

306 lines
10 KiB
TypeScript

import { existsSync } from 'node:fs'
import { Worker } from 'node:worker_threads'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
import type { SessionFileCandidate } from './session-scanner-types'
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import {
mapOpenCodeWslSession,
openCodeWslClient,
openCodeWslPath
} from './session-scanner-opencode-wsl-client'
import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why: resolve the built worker entry + own the process-wide shared client so
// the client class stays free of Electron (require'd lazily here) and the
// scanner call sites depend only on the two routing functions below.
// The shared foreign SQLite reader worker runs OpenCode reads too.
export function resolveOpenCodeSqliteWorkerEntryPath(
runtimeDir = __dirname,
pathExists: (path: string) => boolean = existsSync
): string {
return findForeignSqliteReaderEntry(runtimeDir, pathExists)
}
function defaultWorkerFactory(): Worker {
const workerPath = resolveOpenCodeSqliteWorkerEntryPath()
// Why: a missing built entry must throw synchronously so the client can fail
// closed before it waits on a worker that can never post a result.
if (!existsSync(workerPath)) {
throw new Error(`OpenCode SQLite worker entry not found: ${workerPath}`)
}
return new Worker(workerPath)
}
let sharedClient: OpenCodeSqliteWorkerClient | null = null
function getSharedClient(): OpenCodeSqliteWorkerClient {
sharedClient ??= new OpenCodeSqliteWorkerClient({ workerFactory: defaultWorkerFactory })
return sharedClient
}
/**
* List OpenCode SQLite session candidates through the shared worker client.
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
* @param args.limit - Maximum number of sessions to return per database.
* @param args.issues - Collected scan issues to append errors to.
* @returns Synthetic candidates sorted by effective recency.
*/
export function listOpenCodeSqliteSessionsViaWorker(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
return listForHost(args)
}
/**
* List opencode2 session candidates (v2 channel-scoped DB schema) through the
* shared worker client.
*/
export function listOpenCode2SqliteSessionsViaWorker(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
return listForHost({ ...args, agent: 'opencode2' })
}
export function listZcodeSqliteSessionsViaWorker(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
return listForHost({ ...args, agent: 'zcode' })
}
/**
* Parse one OpenCode SQLite session through the shared worker client.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - Primary key in the `session` table.
* @param args.platform - Platform used for resume-command generation.
* @returns The parsed session, or `null` when it does not exist.
*/
export function parseOpenCodeSqliteSessionViaWorker(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return parseForHost(args)
}
export function parseOpenCode2SqliteSessionViaWorker(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return parseForHost({ ...args, agent: 'opencode2' })
}
export function parseZcodeSqliteSessionViaWorker(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return parseForHost({ ...args, agent: 'zcode' })
}
/**
* Read one OpenCode SQLite session and its whole transcript through the shared
* worker client.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - Primary key in the `session` table.
* @param args.platform - Platform used for resume-command generation.
* @returns The session and every message it holds.
*/
export function captureOpenCodeSqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
return captureForHost(args)
}
export function captureOpenCode2SqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
return captureForHost({ ...args, agent: 'opencode2' })
}
export function captureZcodeSqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
return captureForHost({ ...args, agent: 'zcode' })
}
async function listForHost(
args: Parameters<OpenCodeSqliteWorkerClient['list']>[0]
): Promise<SessionFileCandidate[]> {
throwIfSignalAborted(args.signal)
const native: string[] = []
const groups = new Map<string, { distro: string; paths: Map<string, string> }>()
for (const path of args.dbPaths) {
const wsl = openCodeWslPath(path)
if (!wsl) {
native.push(path)
continue
}
const key = wsl.distro.toLowerCase()
const group = groups.get(key) ?? { distro: wsl.distro, paths: new Map<string, string>() }
group.paths.set(wsl.linuxPath, path)
groups.set(key, group)
}
const candidates = await Promise.all([
native.length ? getSharedClient().list({ ...args, dbPaths: native }) : Promise.resolve([]),
...[...groups.values()].map(async ({ distro, paths }) => {
const first = paths.values().next().value!
const issues: AiVaultScanIssue[] = []
try {
const result = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(distro, first, signal)
return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues })
},
args.agent
)
return result.flatMap((candidate) => {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent)
const original = parsed && paths.get(parsed.dbPath)
return parsed && original
? [
{
...candidate,
file: {
...candidate.file,
path: buildOpenCodeSqliteCandidatePath(original, parsed.sessionId)
}
}
]
: []
})
} catch (error) {
throwIfSignalAborted(args.signal)
issues.push({
agent: args.agent ?? 'opencode',
kind: 'scope',
path: first,
message: error instanceof Error ? error.message : String(error)
})
return []
} finally {
args.issues.push(
...issues.map((issue) => ({ ...issue, path: paths.get(issue.path) ?? issue.path }))
)
}
})
])
throwIfSignalAborted(args.signal)
return candidates.flat().sort((a, b) => b.file.mtimeMs - a.file.mtimeMs)
}
async function parseForHost(
args: Parameters<OpenCodeSqliteWorkerClient['parse']>[0]
): Promise<AiVaultSession | null> {
const wsl = openCodeWslPath(args.dbPath)
if (!wsl) {
return getSharedClient().parse(args)
}
const session = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return mapOpenCodeWslSession(session, args.dbPath)
}
async function captureForHost(
args: Parameters<OpenCodeSqliteWorkerClient['capture']>[0]
): Promise<OpenCodeSqliteCaptureValue> {
const wsl = openCodeWslPath(args.dbPath)
if (!wsl) {
return getSharedClient().capture(args)
}
const capture = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
export async function readOpenCodeTranscriptPageViaWorker(
args: {
dbPath: string
sessionId: string
limit: number
beforeMessageRowId?: number
},
signal?: AbortSignal
) {
const wsl = openCodeWslPath(args.dbPath)
const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient()
const value = await client.readNativeChat(
{
...args,
limit: openCodeTranscriptPageLimit(args.limit),
dbPath: wsl?.linuxPath ?? args.dbPath,
kind: 'native-page'
},
signal
)
if (value !== null && !('items' in value)) {
throw new Error('Invalid OpenCode transcript page')
}
return value
}
export async function readOpenCodeTranscriptSignalViaWorker(
args: {
dbPath: string
sessionId: string
},
signal?: AbortSignal
) {
const wsl = openCodeWslPath(args.dbPath)
const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient()
const value = await client.readNativeChat(
{ ...args, dbPath: wsl?.linuxPath ?? args.dbPath, kind: 'native-signal' },
signal
)
if (value !== null && !('messageCount' in value)) {
throw new Error('Invalid OpenCode transcript signal')
}
return value
}