mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
import type { Worker } from 'node:worker_threads'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
IDLE_TEARDOWN_MS,
|
|
LIST_TIMEOUT_MS,
|
|
MAX_CONSECUTIVE_DEATHS,
|
|
OpenCodeSqliteWorkerClient,
|
|
PARSE_TIMEOUT_MS
|
|
} from './session-scanner-opencode-sqlite-worker-client'
|
|
import type {
|
|
OpenCodeSqliteWorkerRequest,
|
|
OpenCodeSqliteWorkerResponse
|
|
} from './session-scanner-opencode-sqlite-worker-protocol'
|
|
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
|
|
|
// A worker_threads stand-in the tests drive directly: it records posted requests
|
|
// and lets a test emit message/error/exit without a built worker bundle.
|
|
class FakeWorker {
|
|
postedRequests: OpenCodeSqliteWorkerRequest[] = []
|
|
terminated = false
|
|
unrefed = false
|
|
private listeners = new Map<string, Set<(arg?: unknown) => void>>()
|
|
|
|
on(event: string, listener: (arg?: unknown) => void): this {
|
|
const set = this.listeners.get(event) ?? new Set()
|
|
set.add(listener)
|
|
this.listeners.set(event, set)
|
|
return this
|
|
}
|
|
|
|
off(event: string, listener: (arg?: unknown) => void): this {
|
|
this.listeners.get(event)?.delete(listener)
|
|
return this
|
|
}
|
|
|
|
removeAllListeners(): void {
|
|
this.listeners.clear()
|
|
}
|
|
|
|
unref(): void {
|
|
this.unrefed = true
|
|
}
|
|
|
|
async terminate(): Promise<number> {
|
|
this.terminated = true
|
|
return 1
|
|
}
|
|
|
|
postMessage(request: OpenCodeSqliteWorkerRequest): void {
|
|
this.postedRequests.push(request)
|
|
}
|
|
|
|
emit(event: string, arg?: unknown): void {
|
|
// Copy first: the client removes its listeners synchronously during a fault.
|
|
for (const listener of Array.from(this.listeners.get(event) ?? [])) {
|
|
listener(arg)
|
|
}
|
|
}
|
|
|
|
lastId(): number {
|
|
const last = this.postedRequests.at(-1)
|
|
if (!last) {
|
|
throw new Error('no request posted to fake worker')
|
|
}
|
|
return last.id
|
|
}
|
|
}
|
|
|
|
function makeFactory(workers: FakeWorker[]): () => Worker {
|
|
return () => {
|
|
const worker = new FakeWorker()
|
|
workers.push(worker)
|
|
return worker as unknown as Worker
|
|
}
|
|
}
|
|
|
|
describe('OpenCodeSqliteWorkerClient', () => {
|
|
it('correlates responses by id and ignores stale ids', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
const parsePromise = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
const worker = workers[0]
|
|
expect(worker).toBeDefined()
|
|
expect(worker!.unrefed).toBe(true)
|
|
|
|
// A response for a different id must not settle the active call.
|
|
worker!.emit('message', {
|
|
id: 999,
|
|
ok: true,
|
|
value: null
|
|
} satisfies OpenCodeSqliteWorkerResponse)
|
|
worker!.emit('message', {
|
|
id: worker!.lastId(),
|
|
ok: true,
|
|
value: { sessionId: 'a' }
|
|
} satisfies OpenCodeSqliteWorkerResponse)
|
|
|
|
await expect(parsePromise).resolves.toEqual({ sessionId: 'a' })
|
|
})
|
|
|
|
it('dispatches one request at a time in FIFO order', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
const first = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
const second = client.parse({ dbPath: '/db#b', sessionId: 'b', platform: 'darwin' })
|
|
const worker = workers[0]!
|
|
|
|
// Only the first is dispatched; the second waits for the active slot.
|
|
expect(worker.postedRequests).toHaveLength(1)
|
|
expect(worker.postedRequests[0]).toMatchObject({ kind: 'parse', sessionId: 'a' })
|
|
|
|
worker.emit('message', { id: worker.postedRequests[0]!.id, ok: true, value: 'A' })
|
|
await first
|
|
|
|
expect(worker.postedRequests).toHaveLength(2)
|
|
expect(worker.postedRequests[1]).toMatchObject({ kind: 'parse', sessionId: 'b' })
|
|
worker.emit('message', { id: worker.postedRequests[1]!.id, ok: true, value: 'B' })
|
|
await expect(second).resolves.toBe('B')
|
|
// The worker is reused across serial calls (one persistent worker).
|
|
expect(workers).toHaveLength(1)
|
|
})
|
|
|
|
it('times out only the active call, then respawns and drains the queue', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({
|
|
workerFactory: makeFactory(workers),
|
|
log() {}
|
|
})
|
|
|
|
const active = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
const queued = client.parse({ dbPath: '/db#b', sessionId: 'b', platform: 'darwin' })
|
|
const activeAssertion = expect(active).rejects.toThrow(/timed out/)
|
|
|
|
// The queued call's timer must not have started while it waited, so only
|
|
// the active call fires at the parse timeout.
|
|
await vi.advanceTimersByTimeAsync(PARSE_TIMEOUT_MS)
|
|
await activeAssertion
|
|
|
|
// Fault respawns a fresh worker and dispatches the still-queued call.
|
|
expect(workers).toHaveLength(2)
|
|
const respawned = workers[1]!
|
|
expect(respawned.postedRequests).toHaveLength(1)
|
|
expect(respawned.postedRequests[0]).toMatchObject({ sessionId: 'b' })
|
|
respawned.emit('message', { id: respawned.lastId(), ok: true, value: 'B' })
|
|
await expect(queued).resolves.toBe('B')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('rejects only the active call on a worker crash and respawns for the queue', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
const active = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
const queued = client.parse({ dbPath: '/db#b', sessionId: 'b', platform: 'darwin' })
|
|
const activeAssertion = expect(active).rejects.toThrow(/exited with code/)
|
|
|
|
workers[0]!.emit('exit', 1)
|
|
await activeAssertion
|
|
expect(workers[0]!.terminated).toBe(true)
|
|
|
|
// Exactly one respawn; the queued call drains on the new worker.
|
|
expect(workers).toHaveLength(2)
|
|
const respawned = workers[1]!
|
|
respawned.emit('message', { id: respawned.lastId(), ok: true, value: 'B' })
|
|
await expect(queued).resolves.toBe('B')
|
|
})
|
|
|
|
it('fails closed instead of running SQLite on the main thread when spawn fails', async () => {
|
|
const client = new OpenCodeSqliteWorkerClient({
|
|
workerFactory() {
|
|
throw new Error('no worker bundle')
|
|
},
|
|
log() {}
|
|
})
|
|
|
|
const listIssues: AiVaultScanIssue[] = []
|
|
await expect(
|
|
client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues })
|
|
).resolves.toEqual([])
|
|
expect(
|
|
listIssues.some((issue) => /background scanner could not start/.test(issue.message))
|
|
).toBe(true)
|
|
await expect(
|
|
client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' })
|
|
).rejects.toThrow(/background scanner could not start/)
|
|
})
|
|
|
|
it('stops respawning after the consecutive-death cap and fails the rest to issues', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
const pending = Array.from({ length: MAX_CONSECUTIVE_DEATHS + 2 }, (_, i) =>
|
|
client.parse({ dbPath: `/db#${i}`, sessionId: `s${i}`, platform: 'darwin' })
|
|
)
|
|
const settled = pending.map((promise) => expect(promise).rejects.toThrow())
|
|
|
|
// Crash every worker as it is spawned; the client respawns up to the cap.
|
|
for (let i = 0; i < MAX_CONSECUTIVE_DEATHS; i++) {
|
|
expect(workers[i]).toBeDefined()
|
|
workers[i]!.emit('error', new Error(`crash ${i}`))
|
|
}
|
|
|
|
await Promise.all(settled)
|
|
// No respawn past the cap: only MAX_CONSECUTIVE_DEATHS workers were created,
|
|
// and the queued remainder failed to scan issues rather than looping.
|
|
expect(workers).toHaveLength(MAX_CONSECUTIVE_DEATHS)
|
|
})
|
|
|
|
it('surfaces a list-leg timeout as a scan issue and returns no candidates', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({
|
|
workerFactory: makeFactory(workers),
|
|
log() {}
|
|
})
|
|
const issues: AiVaultScanIssue[] = []
|
|
const listPromise = client.list({
|
|
dbPaths: ['/tmp/opencode.db'],
|
|
limit: 10,
|
|
issues
|
|
})
|
|
// The list request is dispatched but never answered → it must time out into
|
|
// a scan issue (not an unbounded stall) and contribute no sessions.
|
|
await vi.advanceTimersByTimeAsync(LIST_TIMEOUT_MS)
|
|
await expect(listPromise).resolves.toEqual([])
|
|
expect(issues).toHaveLength(1)
|
|
expect(issues[0]!.agent).toBe('opencode')
|
|
expect(issues[0]!.message).toMatch(/did not complete/)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('self-heals after repeated spawn failures instead of latching unavailable', async () => {
|
|
const workers: FakeWorker[] = []
|
|
let failSpawns = true
|
|
const client = new OpenCodeSqliteWorkerClient({
|
|
workerFactory() {
|
|
if (failSpawns) {
|
|
throw new Error('spawn down')
|
|
}
|
|
const worker = new FakeWorker()
|
|
workers.push(worker)
|
|
return worker as unknown as Worker
|
|
},
|
|
log() {}
|
|
})
|
|
// Scan 1: both calls fail closed, keeping synchronous SQLite off the main
|
|
// thread. The failure is not latched, so a later scan can still recover.
|
|
const firstIssues: AiVaultScanIssue[] = []
|
|
const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues })
|
|
expect(first).toEqual([])
|
|
expect(
|
|
firstIssues.some((issue) => /background scanner could not start/.test(issue.message))
|
|
).toBe(true)
|
|
await expect(
|
|
client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' })
|
|
).rejects.toThrow(/background scanner could not start/)
|
|
expect(workers).toHaveLength(0)
|
|
|
|
// Spawns recover; the next scan must re-probe and use the worker.
|
|
failSpawns = false
|
|
const secondIssues: AiVaultScanIssue[] = []
|
|
const secondPromise = client.list({ dbPaths: ['/db'], limit: 10, issues: secondIssues })
|
|
const worker = workers[0]
|
|
expect(worker).toBeDefined()
|
|
worker!.emit('message', {
|
|
id: worker!.lastId(),
|
|
ok: true,
|
|
value: { candidates: [], issues: [] }
|
|
} satisfies OpenCodeSqliteWorkerResponse)
|
|
await expect(secondPromise).resolves.toEqual([])
|
|
expect(secondIssues).toHaveLength(0)
|
|
})
|
|
|
|
it('drops a cleanly exited idle worker and respawns on the next request', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
const first = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
workers[0]!.emit('message', { id: workers[0]!.lastId(), ok: true, value: 'A' })
|
|
await expect(first).resolves.toBe('A')
|
|
workers[0]!.emit('exit', 0)
|
|
|
|
const second = client.parse({ dbPath: '/db#b', sessionId: 'b', platform: 'darwin' })
|
|
expect(workers).toHaveLength(2)
|
|
workers[1]!.emit('message', { id: workers[1]!.lastId(), ok: true, value: 'B' })
|
|
await expect(second).resolves.toBe('B')
|
|
})
|
|
|
|
it('terminates an idle worker and respawns on later work', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({
|
|
workerFactory: makeFactory(workers),
|
|
log() {}
|
|
})
|
|
|
|
const first = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
workers[0]!.emit('message', { id: workers[0]!.lastId(), ok: true, value: 'A' })
|
|
await expect(first).resolves.toBe('A')
|
|
await vi.advanceTimersByTimeAsync(IDLE_TEARDOWN_MS)
|
|
expect(workers[0]!.terminated).toBe(true)
|
|
|
|
const second = client.parse({ dbPath: '/db#b', sessionId: 'b', platform: 'darwin' })
|
|
expect(workers).toHaveLength(2)
|
|
workers[1]!.emit('message', { id: workers[1]!.lastId(), ok: true, value: 'B' })
|
|
await expect(second).resolves.toBe('B')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('does not carry a worker death from a prior burst into the next scan cap', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
// Burst 1: one parse whose worker dies, then the burst ends (queue empties)
|
|
// with no success to reset the death counter.
|
|
const first = client.parse({ dbPath: '/db#a', sessionId: 'a', platform: 'darwin' })
|
|
const firstAssertion = expect(first).rejects.toThrow(/exited with code/)
|
|
workers[0]!.emit('exit', 1)
|
|
await firstAssertion
|
|
|
|
// Burst 2 from idle: the fresh scan resets the cap, so MAX_CONSECUTIVE_DEATHS
|
|
// brand-new workers must be spawned before the remainder drains — the carried
|
|
// death from burst 1 does not count against burst 2.
|
|
const pending = Array.from({ length: MAX_CONSECUTIVE_DEATHS }, (_, i) =>
|
|
client.parse({ dbPath: `/db#b${i}`, sessionId: `b${i}`, platform: 'darwin' })
|
|
)
|
|
const settled = pending.map((promise) => expect(promise).rejects.toThrow())
|
|
for (let i = 0; i < MAX_CONSECUTIVE_DEATHS; i++) {
|
|
workers.at(-1)!.emit('error', new Error(`b-crash ${i}`))
|
|
}
|
|
await Promise.all(settled)
|
|
// One worker from burst 1 plus a fresh worker per allowed death in burst 2.
|
|
expect(workers).toHaveLength(1 + MAX_CONSECUTIVE_DEATHS)
|
|
})
|
|
|
|
it('reuses the warm worker across a burst without respawning', async () => {
|
|
const workers: FakeWorker[] = []
|
|
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const promise = client.parse({ dbPath: `/db#${i}`, sessionId: `s${i}`, platform: 'darwin' })
|
|
const worker = workers[0]!
|
|
worker.emit('message', { id: worker.lastId(), ok: true, value: `v${i}` })
|
|
await expect(promise).resolves.toBe(`v${i}`)
|
|
}
|
|
expect(workers).toHaveLength(1)
|
|
})
|
|
})
|