feat(storage): move desktop SQLite ownership into a storage utility process

Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
+72 -1
View File
@@ -6,12 +6,19 @@ import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import { REMOTE_MODEL_GATEWAY_LIMITS } from '../shared/remote-model-gateway-contracts'
import {
AgentModelCallLedger,
AgentModelGateway
AgentModelGateway,
type ModelCallLedger
} from './agent-model-gateway'
const temporary: string[] = []
const secret = 'provider-secret-never-persist'
function gate() {
let resolve!: () => void
const promise = new Promise<void>(done => { resolve = done })
return { promise, resolve }
}
function setup(fetcher: typeof fetch) {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-model-'))
temporary.push(root)
@@ -66,6 +73,70 @@ afterEach(() => {
})
describe('AgentModelGateway', () => {
it('awaits host claim, completion and delivery before their dependent actions', async () => {
const claimGate = gate()
const completeGate = gate()
const deliveryGate = gate()
const ledger: ModelCallLedger = {
claim: vi.fn(() => claimGate.promise), complete: vi.fn(() => completeGate.promise),
delivered: vi.fn(() => deliveryGate.promise), outcomeUnknown: vi.fn(), get: vi.fn(), close: vi.fn()
}
const fetcher = vi.fn<typeof fetch>(async () => new Response('{}'))
const gateway = new AgentModelGateway({ ledger, fetcher })
const settled = vi.fn()
const pending = gateway.dispatch(context, request, new AbortController().signal).then(result => { settled(); return result })
await Promise.resolve()
expect(fetcher).not.toHaveBeenCalled()
claimGate.resolve()
await vi.waitFor(() => expect(ledger.complete).toHaveBeenCalledOnce())
expect(settled).not.toHaveBeenCalled()
completeGate.resolve()
const result = await pending
const acknowledged = vi.fn()
const delivery = result.acknowledgeDelivery().then(acknowledged)
await Promise.resolve()
expect(acknowledged).not.toHaveBeenCalled()
deliveryGate.resolve()
await delivery
expect(acknowledged).toHaveBeenCalledOnce()
})
it.each(['claim', 'complete', 'delivered'] as const)('propagates a rejected host %s without claiming success', async method => {
const failure = new Error('storage unavailable')
const ledger: ModelCallLedger = {
claim: vi.fn(), complete: vi.fn(), delivered: vi.fn(), outcomeUnknown: vi.fn(), get: vi.fn(), close: vi.fn(),
[method]: vi.fn(async () => { throw failure })
}
const fetcher = vi.fn<typeof fetch>(async () => new Response('{}'))
const gateway = new AgentModelGateway({ ledger, fetcher })
const pending = gateway.dispatch(context, request, new AbortController().signal)
if (method === 'delivered') await expect((await pending).acknowledgeDelivery()).rejects.toBe(failure)
else await expect(pending).rejects.toBe(failure)
expect(fetcher).toHaveBeenCalledTimes(method === 'claim' ? 0 : 1)
})
it('settles cancellation during claim only after recording uncertainty, without dispatch', async () => {
const claimGate = gate()
const unknownGate = gate()
const ledger: ModelCallLedger = {
claim: () => claimGate.promise, complete: vi.fn(), delivered: vi.fn(),
outcomeUnknown: vi.fn(() => unknownGate.promise), get: vi.fn(), close: vi.fn()
}
const fetcher = vi.fn<typeof fetch>()
const gateway = new AgentModelGateway({ ledger, fetcher })
const controller = new AbortController()
const settled = vi.fn()
const pending = gateway.dispatch(context, request, controller.signal).catch(error => { settled(); throw error })
const rejected = expect(pending).rejects.toMatchObject({ code: 'cancelled' })
controller.abort()
claimGate.resolve()
await vi.waitFor(() => expect(ledger.outcomeUnknown).toHaveBeenCalledOnce())
expect(fetcher).not.toHaveBeenCalled()
expect(settled).not.toHaveBeenCalled()
unknownGate.resolve()
await rejected
})
it('keeps a slow provider request alive past 60 seconds and still cancels it', async () => {
vi.useFakeTimers()
let providerSignal: AbortSignal | undefined
+18 -8
View File
@@ -309,6 +309,15 @@ export class AgentModelCallLedger {
}
}
/** Remote Agent uses the concrete ledger; desktop awaits its storage host. */
export type ModelCallLedger = {
[K in keyof AgentModelCallLedger]: (
...args: Parameters<AgentModelCallLedger[K]>
) => ReturnType<AgentModelCallLedger[K]> | Promise<ReturnType<AgentModelCallLedger[K]>>
}
export type ModelCallLedgerFactory = (path: string) => Promise<ModelCallLedger>
export type AgentModelGatewayContext = {
bindingId: string
operationId: string
@@ -319,11 +328,11 @@ export type AgentModelGatewayContext = {
}
export class AgentModelGateway {
readonly #ledger: AgentModelCallLedger
readonly #ledger: ModelCallLedger
readonly #fetch: typeof fetch
constructor(options: {
ledger: AgentModelCallLedger
ledger: ModelCallLedger
fetcher?: typeof fetch
}) {
this.#ledger = options.ledger
@@ -348,7 +357,7 @@ export class AgentModelGateway {
)
const requestDigest = sha256(prepared.canonicalRequest)
const callId = createAgentModelCallId(context)
this.#ledger.claim({
await this.#ledger.claim({
callId,
bindingId: context.bindingId,
operationId: context.operationId,
@@ -366,6 +375,7 @@ export class AgentModelGateway {
timer?.unref?.()
let response: Response
try {
signal.throwIfAborted()
response = await this.#fetch(prepared.url, {
method: 'POST',
redirect: 'error',
@@ -380,7 +390,7 @@ export class AgentModelGateway {
: signal.aborted
? 'cancelled'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
await this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider dispatch outcome is unknown'
@@ -403,7 +413,7 @@ export class AgentModelGateway {
: error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
await this.#ledger.outcomeUnknown(callId, code)
throw error instanceof AgentModelGatewayError
? error
: new AgentModelGatewayError(
@@ -418,7 +428,7 @@ export class AgentModelGateway {
clearTimeout(timer)
if (signal.aborted || timeout.signal.aborted) {
const code = timeout.signal.aborted ? 'timeout' : 'cancelled'
this.#ledger.outcomeUnknown(callId, code)
await this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider response arrived after cancellation'
@@ -441,7 +451,7 @@ export class AgentModelGateway {
headers,
bodyBase64: Buffer.from(bytes).toString('base64')
})
this.#ledger.complete(callId)
await this.#ledger.complete(callId)
let finalized = false
return {
response: bridgeResponse,
@@ -453,7 +463,7 @@ export class AgentModelGateway {
)
}
finalized = true
this.#ledger.delivered(callId)
await this.#ledger.delivered(callId)
},
failDelivery: () => {
finalized = true
+82 -10
View File
@@ -1,8 +1,9 @@
import { readFile, rm, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import fs from 'node:fs/promises'
import type { Agent } from '@agentclientprotocol/sdk'
import { afterEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { ContinueHostAdapter, type ContinueHostRunOptions } from '../main/agent/continue-host-adapter'
import { runContinueAcpHelper } from './continue-acp-helper'
@@ -10,6 +11,10 @@ const transport = vi.hoisted(() => ({
agent: undefined as Agent | undefined,
close: () => {}
}))
const proxy = vi.hoisted(() => ({
listen: vi.fn(async () => 'http://127.0.0.1:12345'),
close: vi.fn(async () => {})
}))
vi.mock('@agentclientprotocol/sdk', () => ({
PROTOCOL_VERSION: 1,
ndJsonStream: vi.fn(),
@@ -24,18 +29,27 @@ vi.mock('./model-bridge-helper', () => ({
MODEL_BRIDGE_SDK_AUTH_SENTINEL: 'bridge-test-sentinel',
openCodeModelBridgeModelId: () => 'bridge-model',
ModelBridgeLoopbackProxy: class {
listen = async () => 'http://127.0.0.1:12345'
close = async () => {}
listen = proxy.listen
close = proxy.close
}
}))
vi.mock('./model-bridge-broker', () => ({
createUnixModelBridgeExchange: vi.fn()
}))
afterEach(() => {
let helperRoot: string
let socketPath: string
beforeEach(async () => {
helperRoot = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-helper-'))
socketPath = join(helperRoot, 'bridge.sock')
})
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
transport.agent = undefined
proxy.listen.mockClear()
proxy.close.mockReset()
await rm(helperRoot, { recursive: true, force: true })
})
const server = (name: string) => ({
@@ -43,6 +57,63 @@ const server = (name: string) => ({
headers: [{ name: 'Authorization', value: 'Bearer session-test-token' }]
})
it('closes the proxy and removes signal listeners when directory creation fails', async () => {
const listeners = ['SIGTERM', 'SIGINT'].map(signal => process.listenerCount(signal))
vi.spyOn(fs, 'mkdtemp').mockRejectedValueOnce(new Error('disk full'))
await expect(runContinueAcpHelper({ socketPath, protocol: 'openai-chat-completions',
model: 'test', supportsImageInput: false, entrypoint: 'unused' })).rejects.toThrow('disk full')
expect(proxy.close).toHaveBeenCalledOnce()
expect(['SIGTERM', 'SIGINT'].map(signal => process.listenerCount(signal))).toEqual(listeners)
})
it('does not start ACP after a signal during setup and cleans up even if proxy close fails', async () => {
const create = fs.mkdtemp
let root = ''
const listeners = process.listenerCount('SIGTERM')
vi.spyOn(fs, 'mkdtemp').mockImplementationOnce(async (...args) => {
root = await create(...args)
process.emit('SIGTERM')
return root
})
proxy.close.mockRejectedValueOnce(new Error('proxy close failed'))
await expect(runContinueAcpHelper({ socketPath, protocol: 'openai-chat-completions',
model: 'test', supportsImageInput: false, entrypoint: 'unused' })).rejects.toThrow('proxy close failed')
expect(transport.agent).toBeUndefined()
await expect(fs.stat(root)).rejects.toMatchObject({ code: 'ENOENT' })
expect(process.listenerCount('SIGTERM')).toBe(listeners)
})
it.each(['SIGTERM', 'SIGINT'] as const)('drains active work and removes owned files on %s', async signal => {
let root = ''
let started!: () => void
const active = new Promise<void>(resolve => { started = resolve })
const listeners = process.listenerCount(signal)
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (this: ContinueHostAdapter, _prompt, abort) {
root = (this as unknown as { options: { cacheRoot: string } }).options.cacheRoot
await writeFile(join(root, 'partial'), 'owned')
started()
await new Promise<void>(resolve => abort.addEventListener('abort', () => resolve(), { once: true }))
throw abort.reason
})
const running = runContinueAcpHelper({ socketPath, protocol: 'openai-chat-completions',
model: 'test', supportsImageInput: false, entrypoint: 'unused' })
try {
await vi.waitFor(() => expect(transport.agent).toBeDefined())
const { sessionId } = await transport.agent!.newSession({ cwd: tmpdir(), mcpServers: [] })
const prompt = transport.agent!.prompt({ sessionId, prompt: [{ type: 'text', text: 'test' }] })
await active
process.emit(signal)
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
await running
await expect(fs.stat(root)).rejects.toMatchObject({ code: 'ENOENT' })
expect(process.listenerCount(signal)).toBe(listeners)
await expect(transport.agent!.prompt({ sessionId, prompt: [] })).rejects.toThrow('unavailable')
} finally {
transport.close()
await running
}
})
// Keep the actual helper and adapter config builder together: mocking the
// adapter constructor would miss the model-profile configuration-loss bug.
it('delivers current ACP session MCP capabilities with normal tool authorization', async () => {
@@ -60,14 +131,15 @@ it('delivers current ACP session MCP capabilities with normal tool authorization
expect(await authorize!({ toolName } as never)).toBe('once')
}
const path = await (this as unknown as {
createRunConfig(options: ContinueHostRunOptions): Promise<string>
}).createRunConfig(options!)
createRunConfig(options: ContinueHostRunOptions, root: string): Promise<string>
options: { cacheRoot: string }
}).createRunConfig(options!, (this as unknown as { options: { cacheRoot: string } }).options.cacheRoot)
configs.push(JSON.parse(await readFile(path, 'utf8')))
await rm(path)
return { text: 'OK' }
})
const running = runContinueAcpHelper({
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
socketPath, protocol: 'openai-chat-completions', model: 'test-model',
supportsImageInput: false, sharedSessions: true, entrypoint: 'unused'
})
try {
@@ -104,7 +176,7 @@ it('keeps one flat transcript and preserves user text beginning with Work mode',
return { text: `answer ${prompts.length}` }
})
const running = runContinueAcpHelper({
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
socketPath, protocol: 'openai-chat-completions', model: 'test-model',
supportsImageInput: false, sharedSessions: true, entrypoint: 'unused'
})
try {
@@ -143,8 +215,8 @@ it('keeps local profile MCP scoping and includes Main-bound session servers', as
protocol: 'openai-chat-completions', authentication: 'none', baseUrl: 'http://127.0.0.1:12345' }
})
const create = (options: ContinueHostRunOptions) => (adapter as unknown as {
createRunConfig(options: ContinueHostRunOptions): Promise<string>
}).createRunConfig(options)
createRunConfig(options: ContinueHostRunOptions, root: string): Promise<string>
}).createRunConfig(options, root)
{
const path = await create({})
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
+44 -19
View File
@@ -1,9 +1,8 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import fs, { rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { Readable, Writable } from 'node:stream'
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Agent, type McpServer } from '@agentclientprotocol/sdk'
import { ContinueHostAdapter } from '../main/agent/continue-host-adapter'
@@ -27,8 +26,7 @@ export async function runContinueAcpHelper(options: {
exchange: createUnixModelBridgeExchange({ socketPath: options.socketPath }),
sharedSessions: options.sharedSessions
})
const origin = await proxy.listen()
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-'))
let root: string | undefined
const questions = new Map<string, { sessionId: string; adapter: ContinueHostAdapter }>()
const token = randomBytes(32).toString('base64url')
const replies = createServer((request, response) => { void (async () => {
@@ -42,14 +40,26 @@ export async function runContinueAcpHelper(options: {
questions.delete(value.questionId)
response.writeHead(200).end('{}')
})().catch(() => response.writeHead(400).end()) })
await new Promise<void>((resolve, reject) => { replies.once('error', reject); replies.listen(0, '127.0.0.1', resolve) })
const questionEndpoint = `http://127.0.0.1:${(replies.address() as import('node:net').AddressInfo).port}/${token}`
const sessions = new Map<string, {
cwd: string
mcpServers: McpServer[]
history: NonNullable<AgentExecutionRequest['history']>
active?: { abort: AbortController; adapter: ContinueHostAdapter; finished: Promise<void> }
}>()
let stop!: () => void
let stopping = false
const stopped = new Promise<void>(resolve => { stop = () => { stopping = true; resolve() } })
process.on('SIGTERM', stop)
process.on('SIGINT', stop)
try {
const origin = await proxy.listen()
const launchRoot = join(dirname(options.socketPath), 'goodbuddy-runtime-launch')
await fs.mkdir(launchRoot, { recursive: true, mode: 0o700 })
root = await fs.mkdtemp(join(launchRoot, 'goodbuddy-continue-'))
const cacheRoot = root
await new Promise<void>((resolve, reject) => { replies.once('error', reject); replies.listen(0, '127.0.0.1', resolve) })
const questionEndpoint = `http://127.0.0.1:${(replies.address() as import('node:net').AddressInfo).port}/${token}`
if (stopping) return 128
const modelId = openCodeModelBridgeModelId(options.protocol, options.model)
const configOptions = [{ id: 'model', name: 'Model', type: 'select' as const, currentValue: modelId,
options: [{ value: modelId, name: options.model }] }]
@@ -82,20 +92,21 @@ export async function runContinueAcpHelper(options: {
},
prompt: async ({ sessionId, prompt }) => {
const session = sessions.get(sessionId)
if (!session || session.active) throw new Error('Continue Session is unavailable or busy')
if (stopping || !session || session.active) throw new Error('Continue Session is unavailable or busy')
const route = options.sharedSessions ? await fetch(`${origin}/session?sessionId=${encodeURIComponent(sessionId)}`, {
signal: AbortSignal.timeout(10_000)
}).then(async response => {
if (!response.ok) throw new Error('Continue model route is unavailable')
return await response.json() as { operationId: string }
}) : undefined
if (stopping) throw new Error('Continue helper is stopping')
const sessionMcpServers = session.mcpServers.map(server => {
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
return { name: server.name, type: 'streamable-http' as const, url: server.url,
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
})
const adapter = new ContinueHostAdapter({
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'agent',
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot, mode: 'agent',
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
@@ -159,10 +170,13 @@ export async function runContinueAcpHelper(options: {
if (abort.signal.aborted) return { stopReason: 'cancelled' }
throw error
} finally {
await adapter.dispose()
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
session.active = undefined
finish()
try {
await adapter.dispose()
} finally {
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
session.active = undefined
finish()
}
}
},
cancel: async ({ sessionId }) => {
@@ -179,16 +193,27 @@ export async function runContinueAcpHelper(options: {
const connection = new AgentSideConnection(() => agent, ndJsonStream(
Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)
))
try {
if (options.sharedSessions) await connection.extNotification('goodbuddy/modelBridgeReady', { origin })
await connection.closed
await Promise.race([connection.closed, stopped])
return 0
} finally {
stopping = true
for (const session of sessions.values()) session.active?.abort.abort()
await Promise.all([...sessions.values()].map(session => session.active?.finished))
await proxy.close()
replies.closeAllConnections()
await new Promise<void>(resolve => replies.close(() => resolve()))
await rm(root, { recursive: true, force: true })
try {
await proxy.close()
} finally {
try {
replies.closeAllConnections()
await new Promise<void>(resolve => replies.close(() => resolve()))
} finally {
try {
if (root) await rm(root, { recursive: true, force: true })
} finally {
process.off('SIGTERM', stop)
process.off('SIGINT', stop)
}
}
}
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ describe('remote image MCP to Main service', () => {
return imageOperationSchema.parse(JSON.parse((result.content as { text: string }[])[0]!.text))
}
const first = await call({ intent: 'create', prompt: 'A blue square' })
const uploaded = h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
const uploaded = await h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
const uploadEdit = await call({ intent: 'edit', prompt: 'Make the uploaded square red', sourceArtifactIds: uploaded })
const historyEdit = await call({ intent: 'edit', prompt: 'Make the previous result green', sourceArtifactIds: first.artifactIds })
expect([first, uploadEdit, historyEdit].map(op => op.state)).toEqual(['completed', 'completed', 'completed'])
@@ -139,7 +139,7 @@ describe('remote image MCP to Main service', () => {
expect((await remote.client.listTools()).tools.map(tool => tool.name)).toContain('save_image')
// Larger than one 128 KiB chunk so the Agent must reassemble several replies.
const large = Buffer.concat([Buffer.from(png, 'base64'), randomBytes(300 * 1024)])
const [artifactId] = h.service.persistUploads(h.context, [{ name: 'large.png', mediaType: 'image/png', data: large.toString('base64') }])
const [artifactId] = await h.service.persistUploads(h.context, [{ name: 'large.png', mediaType: 'image/png', data: large.toString('base64') }])
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
const target = join(directory, 'nested', 'out.png')
@@ -159,7 +159,7 @@ describe('remote image MCP to Main service', () => {
it('saves through a mode-free desktop binding', async () => {
const h = await setup()
const [artifactId] = h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
const [artifactId] = await h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
const remote = await h.connect({ save: true })
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
+31 -12
View File
@@ -1,8 +1,7 @@
import { runtimePrivacyEnvironment } from '../shared/runtime-privacy-environment'
import { randomBytes, randomUUID } from 'node:crypto'
import { spawn as nodeSpawn } from 'node:child_process'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import fs, { mkdtemp, rm } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
import {
@@ -13,7 +12,7 @@ import {
type ServerResponse
} from 'node:http'
import type { Socket } from 'node:net'
import { isAbsolute, resolve, join } from 'node:path'
import { dirname, isAbsolute, resolve, join } from 'node:path'
import type {
ModelBridgeModelProtocol
} from '../shared/model-bridge-contracts'
@@ -93,6 +92,7 @@ export type OpenCodeModelBridgeProviderConfig = {
}
export type ModelBridgeHelperChild = {
kill?(signal?: NodeJS.Signals): unknown
once(event: 'error', listener: (error: Error) => void): unknown
once(
event: 'close',
@@ -467,12 +467,23 @@ export async function runOpenCodeModelBridgeHelper(options: {
)
const exchange = createUnixModelBridgeExchange({ socketPath })
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
const origin = await proxy.listen()
let pluginDirectory: string | undefined
let child: ModelBridgeHelperChild | undefined
let stopping = false
const stop = (): void => {
if (stopping) return
stopping = true
child?.kill?.('SIGTERM')
}
process.on('SIGTERM', stop)
process.on('SIGINT', stop)
try {
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
const origin = await proxy.listen()
const launchRoot = join(dirname(socketPath), 'goodbuddy-runtime-launch')
await fs.mkdir(launchRoot, { recursive: true, mode: 0o700 })
pluginDirectory = await mkdtemp(join(launchRoot, 'goodbuddy-opencode-plugin-'))
const pluginPath = join(pluginDirectory, 'subagent.mjs')
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
await fs.writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
const config = createOpenCodeModelBridgeProviderConfig({
protocol: options.protocol,
model: options.model,
@@ -486,12 +497,13 @@ export async function runOpenCodeModelBridgeHelper(options: {
plugin: [pathToFileURL(pluginPath).href]
})
)
if (stopping) return 128
if (options.sharedSessions) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
}) + '\n')
}
const child = (options.spawn ?? defaultHelperSpawn)(
child = (options.spawn ?? defaultHelperSpawn)(
opencodeEntrypoint,
['acp'],
{
@@ -500,9 +512,10 @@ export async function runOpenCodeModelBridgeHelper(options: {
env: environment
}
)
const runningChild = child
return await new Promise<number>((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (code, signal) => {
runningChild.once('error', reject)
runningChild.once('close', (code, signal) => {
if (signal !== null) {
resolveExit(128)
return
@@ -511,9 +524,15 @@ export async function runOpenCodeModelBridgeHelper(options: {
})
})
} finally {
await proxy.close()
if (pluginDirectory) {
await rm(pluginDirectory, { recursive: true, force: true })
try {
await proxy.close()
} finally {
try {
if (pluginDirectory) await rm(pluginDirectory, { recursive: true, force: true })
} finally {
process.off('SIGTERM', stop)
process.off('SIGINT', stop)
}
}
}
}
+63
View File
@@ -15,6 +15,8 @@ import { tmpdir } from 'node:os'
import net from 'node:net'
import { Duplex } from 'node:stream'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import fs from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
@@ -58,6 +60,67 @@ afterEach(() => {
})
describe('model bridge loopback helper', () => {
it('removes a partially created plugin and listeners on write failure', async () => {
let pluginPath = ''
const write = fs.writeFile
vi.spyOn(fs, 'writeFile').mockImplementationOnce(async (...args) => {
pluginPath = String(args[0])
await write(...args)
throw new Error('disk full')
})
const listeners = process.listenerCount('SIGTERM')
await expect(runOpenCodeModelBridgeHelper({ socketPath: join(privateTemporaryDirectory(), 'unused.sock'),
protocol: 'openai-chat-completions', model: 'test', supportsImageInput: false,
opencodeEntrypoint: resolve('unused') })).rejects.toThrow('disk full')
expect(pluginPath).not.toBe('')
expect(existsSync(pluginPath)).toBe(false)
expect(process.listenerCount('SIGTERM')).toBe(listeners)
})
it.each(['SIGTERM', 'SIGINT'] as const)('waits for child exit before plugin cleanup on %s', async signal => {
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
const spawn = vi.fn<ModelBridgeHelperSpawn>(() => child)
const listeners = process.listenerCount(signal)
const running = runOpenCodeModelBridgeHelper({ socketPath: join(privateTemporaryDirectory(), 'unused.sock'),
protocol: 'openai-chat-completions', model: 'test', supportsImageInput: false,
opencodeEntrypoint: resolve('unused'), spawn,
environment: { HOME: tmpdir(), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', PATH: process.env.PATH,
TMPDIR: tmpdir(), XDG_CACHE_HOME: tmpdir(), XDG_CONFIG_HOME: tmpdir(),
XDG_DATA_HOME: tmpdir(), XDG_STATE_HOME: tmpdir() } })
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
const config = JSON.parse(spawn.mock.calls[0]![2].env.OPENCODE_CONFIG_CONTENT!)
const plugin = fileURLToPath(config.plugin[0])
try {
process.emit(signal)
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(existsSync(plugin)).toBe(true)
} finally {
child.emit('close', null, 'SIGTERM')
await expect(running).resolves.toBe(128)
}
expect(existsSync(plugin)).toBe(false)
expect(process.listenerCount(signal)).toBe(listeners)
})
it('does not spawn after a signal during plugin creation', async () => {
const write = fs.writeFile
let plugin = ''
vi.spyOn(fs, 'writeFile').mockImplementationOnce(async (...args) => {
plugin = String(args[0])
await write(...args)
process.emit('SIGTERM')
})
const spawn = vi.fn<ModelBridgeHelperSpawn>()
await expect(runOpenCodeModelBridgeHelper({ socketPath: join(privateTemporaryDirectory(), 'unused.sock'),
protocol: 'openai-chat-completions', model: 'test', supportsImageInput: false,
opencodeEntrypoint: resolve('unused'), spawn,
environment: { HOME: tmpdir(), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', PATH: process.env.PATH,
TMPDIR: tmpdir(), XDG_CACHE_HOME: tmpdir(), XDG_CONFIG_HOME: tmpdir(),
XDG_DATA_HOME: tmpdir(), XDG_STATE_HOME: tmpdir() } })).resolves.toBe(128)
expect(spawn).not.toHaveBeenCalled()
expect(existsSync(plugin)).toBe(false)
})
it(
'preserves launch profile offline resources in the final OpenCode spawn',
async () => {