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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user