feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,29 +2,56 @@ export type BoundedResponseTextOptions = {
|
||||
maxBytes: number
|
||||
missingBodyMessage?: string
|
||||
tooLargeMessage: string
|
||||
truncatedMessage?: string
|
||||
}
|
||||
|
||||
export async function readBoundedResponseText(
|
||||
export class BoundedResponseTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BoundedResponseTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedResponseTruncatedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BoundedResponseTruncatedError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBoundedResponseBytes(
|
||||
response: Response,
|
||||
options: BoundedResponseTextOptions
|
||||
): Promise<string> {
|
||||
): Promise<Uint8Array> {
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
let parsedLength: number | undefined
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength)
|
||||
parsedLength = Number(declaredLength)
|
||||
if (
|
||||
!Number.isSafeInteger(parsedLength) ||
|
||||
parsedLength < 0 ||
|
||||
parsedLength > options.maxBytes
|
||||
) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error(options.tooLargeMessage)
|
||||
throw new BoundedResponseTooLargeError(
|
||||
options.tooLargeMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!response.body) {
|
||||
if (options.missingBodyMessage) {
|
||||
throw new Error(options.missingBodyMessage)
|
||||
}
|
||||
return ''
|
||||
if (
|
||||
options.truncatedMessage !== undefined &&
|
||||
parsedLength !== undefined &&
|
||||
parsedLength !== 0
|
||||
) {
|
||||
throw new BoundedResponseTruncatedError(
|
||||
options.truncatedMessage
|
||||
)
|
||||
}
|
||||
return new Uint8Array()
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
@@ -40,7 +67,9 @@ export async function readBoundedResponseText(
|
||||
}
|
||||
total += value.byteLength
|
||||
if (total > options.maxBytes) {
|
||||
throw new Error(options.tooLargeMessage)
|
||||
throw new BoundedResponseTooLargeError(
|
||||
options.tooLargeMessage
|
||||
)
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
@@ -50,5 +79,24 @@ export async function readBoundedResponseText(
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8')
|
||||
if (
|
||||
options.truncatedMessage !== undefined &&
|
||||
parsedLength !== undefined &&
|
||||
response.headers.get('content-encoding') === null &&
|
||||
total !== parsedLength
|
||||
) {
|
||||
throw new BoundedResponseTruncatedError(
|
||||
options.truncatedMessage
|
||||
)
|
||||
}
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
export async function readBoundedResponseText(
|
||||
response: Response,
|
||||
options: BoundedResponseTextOptions
|
||||
): Promise<string> {
|
||||
return Buffer.from(
|
||||
await readBoundedResponseBytes(response, options)
|
||||
).toString('utf8')
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createModelProfileRuntime
|
||||
} from './create-runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
|
||||
function createBrowserService(): BrowserToolService & {
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
@@ -421,4 +422,32 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('rejects remote process runtimes before invoking any local launcher', () => {
|
||||
const launch = vi.fn()
|
||||
const workspaceAccess = {
|
||||
dispose: vi.fn(async () => undefined)
|
||||
} as unknown as WorkspaceAccess
|
||||
|
||||
expect(() =>
|
||||
createAgentRuntime(
|
||||
'C:\\SafeLocalDefault',
|
||||
settings({
|
||||
provider: 'deepseek-harness'
|
||||
}),
|
||||
{
|
||||
deepseekHarnessLauncher: launch,
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: 'host-one',
|
||||
remoteRootPath: '/srv/project',
|
||||
cacheIdentity: 'remote-one',
|
||||
routeIdentity: 'remote-one',
|
||||
workspaceAccess
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('远程执行空间尚不可用')
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,14 @@ import type { ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelToolProvider } from './model-tool-provider'
|
||||
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||
import {
|
||||
LocalWorkspaceAccess,
|
||||
type WorkspaceAccess
|
||||
} from '../workspace'
|
||||
import {
|
||||
REMOTE_EXECUTION_SPACE_UNAVAILABLE,
|
||||
type ExecutionSpaceDescriptor
|
||||
} from '../execution-space'
|
||||
|
||||
const noSubagentTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -55,6 +63,8 @@ export type AgentCapabilityContext = {
|
||||
browserService?: BrowserToolService
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
webSearchEnabled?: boolean
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
workspaceAccess?: WorkspaceAccess
|
||||
}
|
||||
|
||||
function resolveContextCompression(
|
||||
@@ -145,15 +155,34 @@ export function createAgentRuntime(
|
||||
settings?: ResolvedRuntimeSettings,
|
||||
capabilities: AgentCapabilityContext = {}
|
||||
): AgentRuntime {
|
||||
const executionSpace = capabilities.executionSpace
|
||||
const remote = executionSpace?.kind === 'ssh'
|
||||
const baseUrl = (
|
||||
settings?.opencodeBaseUrl ||
|
||||
process.env.GOODBUDDY_OPENCODE_URL ||
|
||||
''
|
||||
).trim()
|
||||
const embedded = !baseUrl
|
||||
const workspace = settings?.workspacePath || defaultWorkspace
|
||||
const workspace =
|
||||
executionSpace?.kind === 'local'
|
||||
? executionSpace.rootPath
|
||||
: settings?.workspacePath || defaultWorkspace
|
||||
const getWorkspaceAccess = (): WorkspaceAccess =>
|
||||
capabilities.workspaceAccess ??
|
||||
executionSpace?.workspaceAccess ??
|
||||
new LocalWorkspaceAccess(workspace)
|
||||
const provider = settings?.provider ?? defaultRuntimeSettings.provider
|
||||
|
||||
if (
|
||||
remote &&
|
||||
(provider === 'continue' ||
|
||||
provider === 'opencode' ||
|
||||
provider === 'deepseek-harness' ||
|
||||
provider === 'auto')
|
||||
) {
|
||||
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
if (provider === 'deepseek-harness') {
|
||||
const profile = settings?.deepseekHarnessModelProfile
|
||||
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
||||
@@ -179,7 +208,7 @@ export function createAgentRuntime(
|
||||
skillPackages: capabilities.skillPackages,
|
||||
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||
toolProvider: new ModelToolProvider(
|
||||
workspace,
|
||||
getWorkspaceAccess(),
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
@@ -311,7 +340,8 @@ export function createAgentRuntime(
|
||||
webSearchEnabled: capabilities.webSearchEnabled,
|
||||
contextCompression: settings
|
||||
? resolveContextCompression(settings, defaultModelProfile)
|
||||
: undefined
|
||||
: undefined,
|
||||
workspaceAccess: getWorkspaceAccess()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const MAX_NATIVE_TOOLS = 200
|
||||
const NATIVE_SNAPSHOT_TIMEOUT_MS = 2_000
|
||||
const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch'])
|
||||
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
||||
'GoodBuddy controlled execution rules:',
|
||||
'GoodBuddy work mode rules:',
|
||||
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
||||
'- Inspect only what is needed, then create or update the requested workspace files promptly.',
|
||||
'- Work in small verifiable steps and use tool results as the source of truth.',
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ModelCallIdentity,
|
||||
PrepareModelCall
|
||||
} from '../../shared/model-call-operation-contracts'
|
||||
import {
|
||||
ModelCallCapacityError,
|
||||
ModelCallConflictError,
|
||||
ModelCallOperationStore,
|
||||
ModelCallStateError
|
||||
} from './model-call-operation-store'
|
||||
|
||||
const directories: string[] = []
|
||||
const digestA = `sha256:${'a'.repeat(64)}`
|
||||
const digestB = `sha256:${'b'.repeat(64)}`
|
||||
const profileDigest = `sha256:${'c'.repeat(64)}`
|
||||
const policy = {
|
||||
modelProfileDigest: profileDigest
|
||||
}
|
||||
const identity: ModelCallIdentity = {
|
||||
callOperationId: 'call-1',
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
provider: 'openai',
|
||||
profile: 'work',
|
||||
model: 'gpt-model',
|
||||
protocol: 'responses-v1'
|
||||
}
|
||||
const prepared: PrepareModelCall = {
|
||||
identity,
|
||||
requestDigest: digestA,
|
||||
...policy
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createPath(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-'))
|
||||
directories.push(directory)
|
||||
return join(directory, 'model-calls.sqlite')
|
||||
}
|
||||
|
||||
function operation(callOperationId: string, roundIndex: number): PrepareModelCall {
|
||||
return {
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
promptOperationId: `prompt-${callOperationId}`,
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: roundIndex % 2 === 0 ? digestA : digestB,
|
||||
...policy
|
||||
}
|
||||
}
|
||||
|
||||
function completeCall(
|
||||
store: ModelCallOperationStore,
|
||||
callOperationId: string
|
||||
): void {
|
||||
store.beginDispatch(callOperationId)
|
||||
store.complete(callOperationId, { status: 'completed' })
|
||||
store.markResponseDelivered(callOperationId)
|
||||
}
|
||||
|
||||
describe('ModelCallOperationStore', () => {
|
||||
it('durably preserves prepared and dispatched crash states in WAL mode', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.prepare({
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-2',
|
||||
promptOperationId: 'prompt-2',
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: digestB,
|
||||
...policy
|
||||
})
|
||||
expect(store.beginDispatch('call-2').permitted).toBe(true)
|
||||
store.close()
|
||||
|
||||
const check = new DatabaseSync(path)
|
||||
expect(
|
||||
(check.prepare('PRAGMA journal_mode').get() as { journal_mode: string })
|
||||
.journal_mode
|
||||
).toBe('wal')
|
||||
check.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(reopened.get('call-1')?.status).toBe('prepared')
|
||||
expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined()
|
||||
expect(reopened.get('call-2')?.status).toBe('dispatched')
|
||||
expect(reopened.beginDispatch('call-2').permitted).toBe(false)
|
||||
expect(reopened.listUnresolved().map((record) => record.status)).toEqual([
|
||||
'prepared',
|
||||
'dispatched'
|
||||
])
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
expect(store.prepare(prepared).created).toBe(true)
|
||||
expect(store.prepare(prepared).created).toBe(false)
|
||||
expect(() =>
|
||||
store.prepare({ ...prepared, requestDigest: digestB })
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: { ...identity, model: 'different-model' }
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('grants only one dispatch permit across concurrent store instances', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
const second = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
|
||||
const claims = await Promise.all([
|
||||
Promise.resolve().then(() => first.beginDispatch('call-1')),
|
||||
Promise.resolve().then(() => second.beginDispatch('call-1'))
|
||||
])
|
||||
expect(claims.filter((claim) => claim.permitted)).toHaveLength(1)
|
||||
expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe(
|
||||
true
|
||||
)
|
||||
first.close()
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('blocks replay after an outcome becomes unknown, including after reopen', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1', {
|
||||
providerIdempotencyKey: 'provider-key-1'
|
||||
})
|
||||
store.markOutcomeUnknown('call-1', {
|
||||
status: 'outcome-unknown',
|
||||
providerRequestId: 'provider-request-1',
|
||||
reason: {
|
||||
code: 'connection-lost',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(store.beginDispatch('call-1').permitted).toBe(false)
|
||||
store.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(reopened.beginDispatch('call-1').permitted).toBe(false)
|
||||
expect(reopened.listUnresolved()).toHaveLength(1)
|
||||
expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('makes identical completion idempotent and rejects changed evidence', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => 42
|
||||
})
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1')
|
||||
const evidence = {
|
||||
status: 'completed' as const,
|
||||
providerRequestId: 'request-provider-1',
|
||||
providerResponseId: 'response-provider-1',
|
||||
result: { finishReason: 'stop', outputDigest: digestB },
|
||||
usage: { inputTokens: 10, outputTokens: 5 }
|
||||
}
|
||||
const completed = store.complete('call-1', evidence)
|
||||
expect(store.complete('call-1', evidence)).toEqual(completed)
|
||||
expect(() =>
|
||||
store.complete('call-1', {
|
||||
...evidence,
|
||||
providerResponseId: 'different-response'
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(store.listUnresolved()).toEqual([])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('strictly rejects invalid transitions and different terminal outcomes', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
expect(() =>
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
).toThrow(ModelCallStateError)
|
||||
store.beginDispatch('call-1')
|
||||
store.failDefinitive('call-1', {
|
||||
status: 'failed-definitive',
|
||||
error: {
|
||||
code: 'authentication',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
).toThrow(ModelCallStateError)
|
||||
expect(() =>
|
||||
store.markOutcomeUnknown('call-1', {
|
||||
status: 'outcome-unknown',
|
||||
reason: {
|
||||
code: 'uncertain',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallStateError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('guards closed stores and makes close and dispose idempotent', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
store.close()
|
||||
expect(() => store.get('call-1')).toThrow(ModelCallStateError)
|
||||
expect(() => store.prepare(prepared)).toThrow(ModelCallStateError)
|
||||
expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError)
|
||||
expect(() => store.listUnresolved()).toThrow(ModelCallStateError)
|
||||
expect(() => store.close()).not.toThrow()
|
||||
expect(() => store.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails safely when persisted JSON is corrupt', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.close()
|
||||
|
||||
const corrupt = new DatabaseSync(path)
|
||||
corrupt.exec(`
|
||||
PRAGMA ignore_check_constraints = ON;
|
||||
UPDATE model_call_operations SET identity_json = '{broken';
|
||||
`)
|
||||
corrupt.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(() => reopened.get('call-1')).toThrow(ModelCallStateError)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('enforces the hard record cap without dispatching prepared calls', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 2
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
store.prepare(operation('call-2', 1))
|
||||
|
||||
expect(() => store.prepare(operation('call-3', 2))).toThrow(
|
||||
ModelCallCapacityError
|
||||
)
|
||||
expect(store.get('call-1')?.status).toBe('prepared')
|
||||
expect(store.get('call-2')?.status).toBe('prepared')
|
||||
expect(store.get('call-3')).toBeUndefined()
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('automatically prunes only definitive terminal rows for capacity', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 3
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
completeCall(store, 'call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
store.beginDispatch('call-2')
|
||||
store.failDefinitive('call-2', {
|
||||
status: 'failed-definitive',
|
||||
error: { code: 'provider-rejected', retryable: false }
|
||||
})
|
||||
store.prepare(operation('call-3', 2))
|
||||
store.beginDispatch('call-3')
|
||||
|
||||
store.prepare(operation('call-4', 3))
|
||||
expect(store.get('call-1')).toBeUndefined()
|
||||
expect(store.get('call-2')?.status).toBe('failed-definitive')
|
||||
expect(store.get('call-3')?.status).toBe('dispatched')
|
||||
expect(store.get('call-4')?.status).toBe('prepared')
|
||||
|
||||
store.prepare(operation('call-5', 4))
|
||||
expect(store.get('call-2')).toBeUndefined()
|
||||
expect(store.get('call-3')?.status).toBe('dispatched')
|
||||
expect(store.get('call-4')?.status).toBe('prepared')
|
||||
expect(store.get('call-5')?.status).toBe('prepared')
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('never prunes dispatched or outcome-unknown rows for capacity', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 2
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
store.beginDispatch('call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
store.beginDispatch('call-2')
|
||||
store.markOutcomeUnknown('call-2', {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: 'transport-uncertain', retryable: false }
|
||||
})
|
||||
|
||||
expect(() => store.prepare(operation('call-3', 2))).toThrow(
|
||||
ModelCallCapacityError
|
||||
)
|
||||
expect(store.get('call-1')?.status).toBe('dispatched')
|
||||
expect(store.get('call-2')?.status).toBe('outcome-unknown')
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('reapplies capacity and terminal pruning after reopen', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path, { maximumRecords: 3 })
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
first.prepare(operation(`call-${index}`, index))
|
||||
}
|
||||
completeCall(first, 'call-1')
|
||||
completeCall(first, 'call-2')
|
||||
first.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 })
|
||||
reopened.prepare(operation('call-4', 4))
|
||||
expect(reopened.get('call-1')).toBeUndefined()
|
||||
expect(reopened.get('call-2')).toBeUndefined()
|
||||
expect(reopened.get('call-3')?.status).toBe('prepared')
|
||||
expect(reopened.get('call-4')?.status).toBe('prepared')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('prunes terminal records deterministically with bounded options', async () => {
|
||||
let now = 0
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => ++now
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
completeCall(store, 'call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
completeCall(store, 'call-2')
|
||||
store.prepare(operation('call-3', 2))
|
||||
store.beginDispatch('call-3')
|
||||
store.markOutcomeUnknown('call-3', {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: 'transport-uncertain', retryable: false }
|
||||
})
|
||||
|
||||
expect(store.pruneTerminal(1, 6)).toBe(1)
|
||||
expect(store.get('call-1')).toBeUndefined()
|
||||
expect(store.get('call-2')?.status).toBe('completed')
|
||||
expect(store.get('call-3')?.status).toBe('outcome-unknown')
|
||||
expect(() => store.pruneTerminal(1_001)).toThrow()
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('atomically grants one strictly monotonic next round across stores', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
const second = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
completeCall(first, 'call-1')
|
||||
const next = {
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-round-1-a',
|
||||
roundIndex: 1
|
||||
},
|
||||
requestDigest: digestB,
|
||||
...policy
|
||||
}
|
||||
const competing = {
|
||||
...next,
|
||||
identity: {
|
||||
...next.identity,
|
||||
callOperationId: 'call-round-1-b'
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => first.prepare(next)),
|
||||
Promise.resolve().then(() => second.prepare(competing))
|
||||
])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
|
||||
first.close()
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('retains replay authority after delivered records are pruned and reopened', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
completeCall(first, 'call-1')
|
||||
first.finalizePrompt({
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0
|
||||
})
|
||||
expect(first.pruneTerminal(1)).toBe(1)
|
||||
expect(first.get('call-1')).toBeUndefined()
|
||||
first.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError)
|
||||
expect(
|
||||
reopened.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...prepared.identity,
|
||||
callOperationId: 'call-next-prompt',
|
||||
requestId: 'request-2',
|
||||
promptOperationId: 'prompt-2',
|
||||
promptSequence: 1,
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: digestB
|
||||
}).created
|
||||
).toBe(true)
|
||||
expect(() =>
|
||||
reopened.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...prepared.identity,
|
||||
callOperationId: 'call-skipped-prompt',
|
||||
requestId: 'request-3',
|
||||
promptOperationId: 'prompt-3',
|
||||
promptSequence: 3,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('compacts finalized prompt authorities below the hard authority cap', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumAuthorities: 1
|
||||
})
|
||||
for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) {
|
||||
const callOperationId = `call-prompt-${promptSequence}`
|
||||
const promptOperationId = `prompt-${promptSequence}`
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
requestId: `request-${promptSequence}`,
|
||||
promptOperationId,
|
||||
promptSequence,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
completeCall(store, callOperationId)
|
||||
store.finalizePrompt({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId,
|
||||
promptSequence
|
||||
})
|
||||
}
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'replayed-finalized-prompt',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 1,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('rejects skipped, lower, and profile-swapped rounds', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
completeCall(store, 'call-1')
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-skipped',
|
||||
roundIndex: 2
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallStateError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-profile-swap',
|
||||
roundIndex: 1,
|
||||
profile: 'other'
|
||||
},
|
||||
modelProfileDigest: digestB
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-lower',
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('permits sequential rounds without a call-count or cumulative-token quota', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
|
||||
const callOperationId = `call-unbounded-${roundIndex}`
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
requestId: `request-${roundIndex}`,
|
||||
roundIndex
|
||||
}
|
||||
})
|
||||
completeCall(store, callOperationId)
|
||||
}
|
||||
expect(store.get('call-unbounded-11')).toMatchObject({
|
||||
status: 'completed',
|
||||
responseDeliveredAt: expect.any(Number)
|
||||
})
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('requires explicit response delivery before the next round', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => 42
|
||||
})
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1')
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
const next = {
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-2',
|
||||
roundIndex: 1
|
||||
},
|
||||
requestDigest: digestB
|
||||
}
|
||||
expect(() => store.prepare(next)).toThrow(ModelCallStateError)
|
||||
expect(store.pruneTerminal(1)).toBe(0)
|
||||
const pending = store.listStartupRecords().records
|
||||
expect(pending).toMatchObject([{ status: 'completed' }])
|
||||
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
|
||||
const delivered = store.markResponseDelivered('call-1')
|
||||
expect(delivered.responseDeliveredAt).toBe(42)
|
||||
expect(store.markResponseDelivered('call-1')).toEqual(delivered)
|
||||
expect(store.prepare(next).created).toBe(true)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('paginates startup and status-filtered records without starvation', async () => {
|
||||
let now = 0
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => ++now
|
||||
})
|
||||
for (const id of ['call-a', 'call-b', 'call-c']) {
|
||||
store.prepare(operation(id, 0))
|
||||
store.beginDispatch(id)
|
||||
}
|
||||
store.complete('call-b', { status: 'completed' })
|
||||
completeCall(store, 'call-c')
|
||||
|
||||
const first = store.listStartupRecords({ limit: 1 })
|
||||
expect(first.records).toHaveLength(1)
|
||||
expect(first.nextCursor).toBeDefined()
|
||||
const second = store.listStartupRecords({
|
||||
limit: 1,
|
||||
cursor: first.nextCursor
|
||||
})
|
||||
expect([
|
||||
...first.records,
|
||||
...second.records
|
||||
].map((record) => record.identity.callOperationId)).toEqual([
|
||||
'call-a',
|
||||
'call-b'
|
||||
])
|
||||
expect(
|
||||
store.list({
|
||||
statuses: ['completed'],
|
||||
delivery: 'pending-response',
|
||||
limit: 10
|
||||
}).records.map((record) => record.identity.callOperationId)
|
||||
).toEqual(['call-b'])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('includes prepared records in startup reconciliation', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(operation('call-prepared', 0))
|
||||
|
||||
expect(store.listStartupRecords().records).toMatchObject([
|
||||
{
|
||||
identity: { callOperationId: 'call-prepared' },
|
||||
status: 'prepared'
|
||||
}
|
||||
])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('uses the verified private SQLite path opener before initialization', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.close()
|
||||
const databaseStats = await stat(path)
|
||||
expect(databaseStats.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(databaseStats.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
expect(() => new ModelCallOperationStore(':memory:')).toThrow(
|
||||
'SQLite database must be a filesystem path'
|
||||
)
|
||||
|
||||
const invalidPath = join(
|
||||
join(path, '..'),
|
||||
'invalid-capacity-must-not-exist.sqlite'
|
||||
)
|
||||
expect(
|
||||
() =>
|
||||
new ModelCallOperationStore(invalidPath, {
|
||||
maximumRecords: 0
|
||||
})
|
||||
).toThrow()
|
||||
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -621,7 +621,7 @@ describe('ModelAgentRuntime', () => {
|
||||
const summaryBody = JSON.parse(
|
||||
fetcher.mock.calls[0]![1]!.body as string
|
||||
) as { max_tokens: number; system: string; messages: unknown[] }
|
||||
expect(summaryBody.max_tokens).toBe(8_192)
|
||||
expect(summaryBody.max_tokens).toBe(32_000)
|
||||
expect(summaryBody.system).toContain('Summarize earlier history.')
|
||||
expect(JSON.stringify(summaryBody.messages)).toContain('old-user-')
|
||||
expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-')
|
||||
@@ -1847,13 +1847,15 @@ describe('ModelAgentRuntime', () => {
|
||||
})
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
max_output_tokens: 4096,
|
||||
stream: true,
|
||||
instructions: expect.stringContaining('GoodBuddy'),
|
||||
input: [
|
||||
expect.objectContaining({ role: 'user', content: '你好' })
|
||||
]
|
||||
})
|
||||
expect(JSON.parse(init?.body as string)).not.toHaveProperty(
|
||||
'max_output_tokens'
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
@@ -4418,8 +4420,9 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('terminates repeated identical tool rounds without exhausting hard limits', async () => {
|
||||
it('allows repeated tool rounds until the run is cancelled', async () => {
|
||||
let callId = 0
|
||||
const controller = new AbortController()
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
callId += 1
|
||||
return Response.json({
|
||||
@@ -4443,7 +4446,16 @@ describe('ModelAgentRuntime', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
const toolProvider = createToolProvider()
|
||||
let completedCalls = 0
|
||||
const toolProvider = createToolProvider({
|
||||
callTool: vi.fn(async () => {
|
||||
completedCalls += 1
|
||||
if (completedCalls === 5) {
|
||||
controller.abort()
|
||||
}
|
||||
return createTextToolResult('same result')
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
@@ -4460,16 +4472,149 @@ describe('ModelAgentRuntime', () => {
|
||||
prompt: 'repeat',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
controller.signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
}
|
||||
|
||||
await expect(consume()).rejects.toThrow('没有取得进展')
|
||||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||||
expect(toolProvider.callTool).toHaveBeenCalledTimes(2)
|
||||
await expect(consume()).rejects.toThrow()
|
||||
expect(fetcher).toHaveBeenCalledTimes(5)
|
||||
expect(toolProvider.callTool).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('allows more than the legacy 40 tool calls in one model round', async () => {
|
||||
const toolCalls = Array.from({ length: 45 }, (_, index) => ({
|
||||
id: `call-bulk-${index}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: JSON.stringify({ path: `file-${index}.txt` })
|
||||
}
|
||||
}))
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'all tools completed'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const toolProvider = createToolProvider()
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider
|
||||
})
|
||||
const events = []
|
||||
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation-bulk-tools',
|
||||
prompt: 'run all tools',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(toolProvider.callTool).toHaveBeenCalledTimes(45)
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('continues beyond the legacy 24 model tool rounds', async () => {
|
||||
let modelRound = 0
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
modelRound += 1
|
||||
if (modelRound <= 25) {
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: `call-round-${modelRound}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: JSON.stringify({
|
||||
path: `round-${modelRound}.txt`
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'completed after 25 rounds'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const toolProvider = createToolProvider()
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider
|
||||
})
|
||||
const events = []
|
||||
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation-many-rounds',
|
||||
prompt: 'continue until done',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(toolProvider.callTool).toHaveBeenCalledTimes(25)
|
||||
expect(fetcher).toHaveBeenCalledTimes(26)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('releases provider state for only the requested conversation', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
import {
|
||||
scopedReadToolNames,
|
||||
type KnowledgeMcpGateway
|
||||
@@ -173,13 +174,7 @@ const maxChatResponseBytes = 2 * 1024 * 1024
|
||||
const maxStreamBlockBytes = 1024 * 1024
|
||||
const maxToolArgumentBytes = 128 * 1024
|
||||
const maxToolContextBytes = 1024 * 1024
|
||||
const maxToolCallsPerRun = 40
|
||||
const maxToolRounds = 24
|
||||
const maxRepeatedIdenticalCalls = 3
|
||||
const maxIdenticalRoundsWithoutProgress = 2
|
||||
const defaultModelRequestTimeoutMs = 10 * 60_000
|
||||
const defaultModelOutputTokens = 4_096
|
||||
const summaryModelOutputTokens = contextSummaryTokenBudget
|
||||
|
||||
const noModelTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -220,6 +215,7 @@ export type ModelRuntimeOptions = {
|
||||
imageGenerationQuality?: ImageGenerationQuality
|
||||
skillInstructions?: string
|
||||
defaultWorkspace?: string
|
||||
workspaceAccess?: WorkspaceAccess
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
browserService?: BrowserToolService
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
@@ -227,7 +223,6 @@ export type ModelRuntimeOptions = {
|
||||
toolProvider?: ModelToolProviderLike
|
||||
fetcher?: typeof fetch
|
||||
requestTimeoutMs?: number
|
||||
maxOutputTokens?: number
|
||||
contextCompression?: {
|
||||
settings: ContextCompressionSettings
|
||||
contextWindowTokens?: number
|
||||
@@ -665,26 +660,6 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function canonicalizeToolArguments(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalizeToolArguments)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeToolArguments(item)])
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getToolCallFingerprint(call: ModelToolCall): string {
|
||||
return `${call.name}:${JSON.stringify(
|
||||
canonicalizeToolArguments(call.arguments)
|
||||
)}`
|
||||
}
|
||||
|
||||
function validateToolResult(result: ModelToolResult): number {
|
||||
if (
|
||||
!Array.isArray(result.parts) ||
|
||||
@@ -1344,7 +1319,6 @@ async function* readAnthropicToolStream(
|
||||
let answer = ''
|
||||
let reasoning = ''
|
||||
let stopReason: unknown
|
||||
let toolCallCount = 0
|
||||
|
||||
for await (const block of readBoundedSseBlocks(response)) {
|
||||
const parsed = parseSseData(block)
|
||||
@@ -1421,10 +1395,6 @@ async function* readAnthropicToolStream(
|
||||
}
|
||||
}
|
||||
} else if (content.type === 'tool_use') {
|
||||
toolCallCount += 1
|
||||
if (toolCallCount > maxToolCallsPerRun) {
|
||||
throw new Error('模型单轮工具调用超过安全限制')
|
||||
}
|
||||
const identity = parseToolCallIdentity(content.id, content.name)
|
||||
next.kind = 'tool_use'
|
||||
next.initialInput = content.input
|
||||
@@ -1555,31 +1525,23 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
private readonly fetcher: typeof fetch
|
||||
private readonly toolProvider: ModelToolProviderLike
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly maxOutputTokens: number
|
||||
|
||||
constructor(private readonly options: ModelRuntimeOptions) {
|
||||
this.fetcher = options.fetcher ?? fetch
|
||||
this.requestTimeoutMs =
|
||||
options.requestTimeoutMs ?? defaultModelRequestTimeoutMs
|
||||
this.maxOutputTokens =
|
||||
options.maxOutputTokens ?? defaultModelOutputTokens
|
||||
if (
|
||||
!Number.isSafeInteger(this.requestTimeoutMs) ||
|
||||
this.requestTimeoutMs < 1
|
||||
) {
|
||||
throw new Error('模型接口请求超时设置无效')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(this.maxOutputTokens) ||
|
||||
this.maxOutputTokens < 1 ||
|
||||
this.maxOutputTokens > summaryModelOutputTokens
|
||||
) {
|
||||
throw new Error('模型最大输出设置无效')
|
||||
}
|
||||
this.toolProvider =
|
||||
options.toolProvider ??
|
||||
new ModelToolProvider(
|
||||
options.defaultWorkspace ?? process.cwd(),
|
||||
options.workspaceAccess ??
|
||||
options.defaultWorkspace ??
|
||||
process.cwd(),
|
||||
options.mcpServers,
|
||||
options.browserService,
|
||||
options.knowledgeGateway,
|
||||
@@ -1831,6 +1793,14 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private get anthropicProtocolMaximumTokens(): number {
|
||||
return Math.max(
|
||||
this.options.contextCompression?.contextWindowTokens ??
|
||||
minimumModelContextWindowTokens,
|
||||
minimumModelContextWindowTokens
|
||||
)
|
||||
}
|
||||
|
||||
private getHardSafetyTriggerTokens(): number | undefined {
|
||||
const contextWindowTokens =
|
||||
this.options.contextCompression?.contextWindowTokens
|
||||
@@ -1842,8 +1812,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
contextWindowTokens,
|
||||
minimumModelContextWindowTokens
|
||||
) -
|
||||
this.maxOutputTokens -
|
||||
2_048
|
||||
12_000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1915,8 +1884,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
supportsImageInput: false,
|
||||
toolProvider: noModelTools,
|
||||
fetcher: this.fetcher,
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
maxOutputTokens: summaryModelOutputTokens
|
||||
requestTimeoutMs: this.requestTimeoutMs
|
||||
})
|
||||
const summaryRequest: AgentExecutionRequest = {
|
||||
requestId: request.requestId,
|
||||
@@ -2543,7 +2511,6 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_output_tokens: this.maxOutputTokens,
|
||||
stream: true,
|
||||
instructions: system,
|
||||
input: messages,
|
||||
@@ -2554,7 +2521,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
: anthropic
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.maxOutputTokens,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages,
|
||||
@@ -2564,7 +2531,6 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: this.maxOutputTokens,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
@@ -2690,8 +2656,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
const index = toolDelta?.index
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
(index as number) < 0 ||
|
||||
(index as number) >= maxToolCallsPerRun
|
||||
(index as number) < 0
|
||||
) {
|
||||
throw new Error(
|
||||
'OpenAI 模型接口返回了无效流式工具调用序号'
|
||||
@@ -3011,14 +2976,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
rounds: [],
|
||||
compressionCount: 0
|
||||
}
|
||||
const seenCallIds = new Set<string>()
|
||||
let totalToolCalls = 0
|
||||
let answer = ''
|
||||
const identicalCallCounts = new Map<string, number>()
|
||||
let previousRoundSignature: string | undefined
|
||||
let identicalRoundsWithoutProgress = 0
|
||||
|
||||
for (let round = 0; round < maxToolRounds; round += 1) {
|
||||
for (let round = 0; ; round += 1) {
|
||||
signal.throwIfAborted()
|
||||
if (round > 0) {
|
||||
toolSnapshot = await loadToolSnapshot()
|
||||
@@ -3156,25 +3116,6 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
return
|
||||
}
|
||||
const roundSignature = response.toolCalls
|
||||
.map(getToolCallFingerprint)
|
||||
.join('\n')
|
||||
if (roundSignature === previousRoundSignature) {
|
||||
identicalRoundsWithoutProgress += 1
|
||||
if (
|
||||
identicalRoundsWithoutProgress >=
|
||||
maxIdenticalRoundsWithoutProgress
|
||||
) {
|
||||
throw new Error('直连模型重复了相同工具调用且没有取得进展')
|
||||
}
|
||||
} else {
|
||||
previousRoundSignature = roundSignature
|
||||
identicalRoundsWithoutProgress = 0
|
||||
}
|
||||
totalToolCalls += response.toolCalls.length
|
||||
if (totalToolCalls > maxToolCallsPerRun) {
|
||||
throw new Error('直连模型单次运行的工具调用超过 40 个')
|
||||
}
|
||||
if (responses) {
|
||||
if (!response.responsesOutput) {
|
||||
throw new Error('OpenAI Responses 工具调用缺少 output')
|
||||
@@ -3205,19 +3146,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
const responsesResults: Array<Record<string, unknown>> = []
|
||||
const chatImageCarrierContent: Array<Record<string, unknown>> = []
|
||||
let roundContextBytes = 0
|
||||
const roundCallIds = new Set<string>()
|
||||
for (const call of response.toolCalls) {
|
||||
signal.throwIfAborted()
|
||||
const callFingerprint = getToolCallFingerprint(call)
|
||||
const identicalCallCount =
|
||||
(identicalCallCounts.get(callFingerprint) ?? 0) + 1
|
||||
identicalCallCounts.set(callFingerprint, identicalCallCount)
|
||||
if (identicalCallCount > maxRepeatedIdenticalCalls) {
|
||||
throw new Error('直连模型重复请求了完全相同的工具调用')
|
||||
}
|
||||
if (seenCallIds.has(call.id)) {
|
||||
if (roundCallIds.has(call.id)) {
|
||||
throw new Error('模型重复使用了工具调用 ID')
|
||||
}
|
||||
seenCallIds.add(call.id)
|
||||
roundCallIds.add(call.id)
|
||||
const tool = toolSnapshot.toolsByName.get(call.name)
|
||||
const displayName = tool?.displayName ?? call.name.slice(0, 128)
|
||||
const input = boundedToolDetail(call.arguments, 4_000)
|
||||
@@ -3421,7 +3356,6 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
throw new Error('直连模型工具调用轮次超过 24 轮')
|
||||
}
|
||||
|
||||
async *run(
|
||||
@@ -3537,7 +3471,6 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_output_tokens: this.maxOutputTokens,
|
||||
stream: true,
|
||||
instructions: system,
|
||||
input: messages
|
||||
@@ -3545,14 +3478,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
: anthropic
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.maxOutputTokens,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: this.maxOutputTokens,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
@@ -201,6 +202,216 @@ describe('ModelToolProvider', () => {
|
||||
).resolves.toBe('saved')
|
||||
})
|
||||
|
||||
it('delegates workspace tools and disposal to WorkspaceAccess', async () => {
|
||||
const readText = vi.fn(async () => ({
|
||||
path: 'remote.txt',
|
||||
name: 'remote.txt',
|
||||
content: 'remote content',
|
||||
size: 14
|
||||
}))
|
||||
const listDirectory = vi.fn(async () => ({
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'remote.txt', path: 'remote.txt', type: 'file' as const }
|
||||
],
|
||||
truncated: false
|
||||
}))
|
||||
const writeTextAtomic = vi.fn(async () => ({
|
||||
path: 'output.txt',
|
||||
bytesWritten: 5
|
||||
}))
|
||||
const dispose = vi.fn(async () => undefined)
|
||||
const access = {
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory,
|
||||
stat: vi.fn(),
|
||||
readText,
|
||||
writeTextAtomic,
|
||||
search: vi.fn(),
|
||||
getChanges: vi.fn(),
|
||||
dispose
|
||||
} as unknown as WorkspaceAccess
|
||||
const provider = new ModelToolProvider(access)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(
|
||||
provider.callTool(
|
||||
'workspace_read_text',
|
||||
{ path: 'remote.txt' },
|
||||
signal,
|
||||
toolContext
|
||||
)
|
||||
).resolves.toEqual({
|
||||
parts: [{ type: 'text', text: 'remote content' }],
|
||||
contextBytes: 14
|
||||
})
|
||||
await provider.callTool(
|
||||
'workspace_list_directory',
|
||||
{},
|
||||
signal,
|
||||
toolContext
|
||||
)
|
||||
await provider.callTool(
|
||||
'workspace_write_text',
|
||||
{ path: 'output.txt', content: 'saved' },
|
||||
signal,
|
||||
toolContext
|
||||
)
|
||||
expect(readText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: 'remote.txt',
|
||||
maximumBytes: 256 * 1024,
|
||||
signal
|
||||
})
|
||||
)
|
||||
expect(listDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '.',
|
||||
maximumEntries: 200,
|
||||
includeOther: true,
|
||||
signal
|
||||
})
|
||||
)
|
||||
expect(writeTextAtomic).toHaveBeenCalledWith({
|
||||
path: 'output.txt',
|
||||
content: 'saved',
|
||||
maximumBytes: 512 * 1024,
|
||||
signal
|
||||
})
|
||||
await provider.dispose()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('denies spoofed mutating tool calls in Ask before reaching sinks', async () => {
|
||||
const readText = vi.fn(async () => ({
|
||||
path: 'remote.txt',
|
||||
name: 'remote.txt',
|
||||
content: 'remote content',
|
||||
size: 14
|
||||
}))
|
||||
const listDirectory = vi.fn(async () => ({
|
||||
path: '',
|
||||
entries: [],
|
||||
truncated: false
|
||||
}))
|
||||
const writeTextAtomic = vi.fn()
|
||||
const access = {
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory,
|
||||
stat: vi.fn(),
|
||||
readText,
|
||||
writeTextAtomic,
|
||||
search: vi.fn(),
|
||||
getChanges: vi.fn(),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
} as unknown as WorkspaceAccess
|
||||
const browserService = createBrowserService()
|
||||
const createMagicNote = vi.fn()
|
||||
const callGoodBuddyConfigTool = vi.fn()
|
||||
const gateway = {
|
||||
createMagicNote,
|
||||
callGoodBuddyConfigTool,
|
||||
getAvailableToolNames: vi.fn(() => [
|
||||
'note_create',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const provider = new ModelToolProvider(
|
||||
access,
|
||||
[createMcpServer()],
|
||||
browserService,
|
||||
gateway
|
||||
)
|
||||
const signal = new AbortController().signal
|
||||
const askContext = {
|
||||
conversationId: 'spoofed-ask-tools',
|
||||
workMode: 'ask',
|
||||
knowledgeCapabilityToken: 'main-only-token'
|
||||
} satisfies ModelToolCallContext
|
||||
const denial = 'Ask 模式仅允许调用已声明的只读工具'
|
||||
|
||||
for (const [name, argumentsValue] of [
|
||||
[
|
||||
'workspace_write_text',
|
||||
{ path: 'output.txt', content: 'blocked' }
|
||||
],
|
||||
['browser_click', { ref: 'b_target' }],
|
||||
['note_create', { title: 'blocked' }],
|
||||
[
|
||||
'goodbuddy_config_apply',
|
||||
{ planId: '00000000-0000-4000-8000-000000000702' }
|
||||
],
|
||||
['mcp_spoofed_mutation', {}]
|
||||
] as const) {
|
||||
await expect(
|
||||
provider.callTool(
|
||||
name,
|
||||
argumentsValue,
|
||||
signal,
|
||||
askContext
|
||||
)
|
||||
).rejects.toThrow(denial)
|
||||
}
|
||||
|
||||
expect(writeTextAtomic).not.toHaveBeenCalled()
|
||||
expect(browserService.click).not.toHaveBeenCalled()
|
||||
expect(createMagicNote).not.toHaveBeenCalled()
|
||||
expect(callGoodBuddyConfigTool).not.toHaveBeenCalled()
|
||||
expect(mocks.Client).not.toHaveBeenCalled()
|
||||
|
||||
const writeTool = {
|
||||
name: 'workspace_write_text',
|
||||
displayName: '写入工作区文本',
|
||||
description: 'write',
|
||||
inputSchema: {},
|
||||
source: 'builtin'
|
||||
} as const
|
||||
expect(() =>
|
||||
provider.getApproval(
|
||||
writeTool,
|
||||
{ path: 'output.txt', content: 'blocked' },
|
||||
'output.txt',
|
||||
askContext
|
||||
)
|
||||
).toThrow(denial)
|
||||
expect(() =>
|
||||
provider.getApproval(
|
||||
{
|
||||
...writeTool,
|
||||
name: 'browser_click',
|
||||
displayName: '点击浏览器元素'
|
||||
},
|
||||
{ ref: 'b_target' },
|
||||
'b_target',
|
||||
askContext
|
||||
)
|
||||
).toThrow(denial)
|
||||
expect(browserService.getOrigin).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
provider.callTool(
|
||||
'workspace_read_text',
|
||||
{ path: 'remote.txt' },
|
||||
signal,
|
||||
askContext
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
parts: [{ type: 'text', text: 'remote content' }]
|
||||
})
|
||||
await expect(
|
||||
provider.callTool(
|
||||
'workspace_list_directory',
|
||||
{},
|
||||
signal,
|
||||
askContext
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
parts: [{ type: 'text', text: expect.any(String) }]
|
||||
})
|
||||
expect(readText).toHaveBeenCalledOnce()
|
||||
expect(listDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const search = vi.fn(async () => [])
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
rename,
|
||||
realpath,
|
||||
rm,
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import {
|
||||
dirname,
|
||||
isAbsolute,
|
||||
resolve
|
||||
} from 'node:path'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { isIP } from 'node:net'
|
||||
import { z } from 'zod'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
@@ -26,11 +13,9 @@ import {
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import { createMcpTransport } from '../capabilities/mcp-client-transport'
|
||||
import {
|
||||
getCanonicalWorkspace,
|
||||
isPathInside,
|
||||
listBoundedDirectoryEntries,
|
||||
readBoundedUtf8File
|
||||
} from '../workspace-file-access'
|
||||
LocalWorkspaceAccess,
|
||||
type WorkspaceAccess
|
||||
} from '../workspace'
|
||||
import type { RuntimeApprovalRequest } from './runtime'
|
||||
import {
|
||||
BrowserModelTools,
|
||||
@@ -88,6 +73,11 @@ const goodbuddyConfigWriteToolNameSet = new Set<string>(
|
||||
goodbuddyConfigWriteToolNames
|
||||
)
|
||||
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
||||
const builtinModelToolAccessByName = new Map<string, 'read' | 'write'>(
|
||||
builtinModelTools.map((tool) => [tool.name, tool.access])
|
||||
)
|
||||
const ASK_TOOL_DENIAL_MESSAGE =
|
||||
'Ask 模式仅允许调用已声明的只读工具'
|
||||
const scopedToolJsonSchemas = new Map(
|
||||
[...scopedDataToolByName].map(([name, definition]) => {
|
||||
const schema = z.toJSONSchema(
|
||||
@@ -309,6 +299,23 @@ function createTextToolResult(text: string): ModelToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
function assertToolAuthorizedForWorkMode(
|
||||
name: string,
|
||||
context: ModelToolCallContext
|
||||
): void {
|
||||
if (context.workMode !== 'ask') {
|
||||
return
|
||||
}
|
||||
const access =
|
||||
builtinModelToolAccessByName.get(name) ??
|
||||
scopedDataToolByName.get(
|
||||
name as Parameters<typeof scopedDataToolByName.get>[0]
|
||||
)?.access
|
||||
if (access !== 'read') {
|
||||
throw new Error(ASK_TOOL_DENIAL_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpImage(
|
||||
content: Record<string, unknown>
|
||||
): Extract<ModelToolResultPart, { type: 'image' }> {
|
||||
@@ -472,7 +479,6 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
|
||||
}
|
||||
|
||||
export class ModelToolProvider implements ModelToolProviderLike {
|
||||
private canonicalWorkspace?: Promise<string>
|
||||
private mcpConnections?: Promise<ConnectedMcp[]>
|
||||
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
|
||||
private readonly clients = new Set<Client>()
|
||||
@@ -480,12 +486,19 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
private readonly webSearchClients = new Set<Client>()
|
||||
|
||||
constructor(
|
||||
private readonly workspace: string,
|
||||
workspace: string | WorkspaceAccess,
|
||||
private readonly mcpServers: ResolvedMcpServer[] = [],
|
||||
private readonly browserService?: BrowserToolService,
|
||||
private readonly knowledgeGateway?: KnowledgeMcpGateway,
|
||||
private readonly webSearchEnabled = false
|
||||
) {}
|
||||
) {
|
||||
this.workspaceAccess =
|
||||
typeof workspace === 'string'
|
||||
? new LocalWorkspaceAccess(workspace)
|
||||
: workspace
|
||||
}
|
||||
|
||||
private readonly workspaceAccess: WorkspaceAccess
|
||||
|
||||
private getScopedTools(
|
||||
context: ModelToolCallContext
|
||||
@@ -607,71 +620,6 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
]
|
||||
}
|
||||
|
||||
private async getWorkspace(): Promise<string> {
|
||||
this.canonicalWorkspace ??= getCanonicalWorkspace(
|
||||
this.workspace,
|
||||
'直连模型工作区不是目录'
|
||||
)
|
||||
return this.canonicalWorkspace
|
||||
}
|
||||
|
||||
private async resolveExistingPath(
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<string> {
|
||||
const root = await this.getWorkspace()
|
||||
const relativePath = workspacePathSchema.parse(inputPath)
|
||||
const candidate = resolve(root, relativePath)
|
||||
if (!isPathInside(root, candidate)) {
|
||||
throw new Error('工具路径不能超出工作区')
|
||||
}
|
||||
const canonical = await realpath(candidate)
|
||||
if (!isPathInside(root, canonical)) {
|
||||
throw new Error('工具路径不能通过符号链接超出工作区')
|
||||
}
|
||||
const metadata = await stat(canonical)
|
||||
if (
|
||||
(expected === 'file' && !metadata.isFile()) ||
|
||||
(expected === 'directory' && !metadata.isDirectory())
|
||||
) {
|
||||
throw new Error(
|
||||
expected === 'file' ? '工具路径不是普通文件' : '工具路径不是目录'
|
||||
)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
private async resolveWritablePath(inputPath: string): Promise<string> {
|
||||
const root = await this.getWorkspace()
|
||||
const relativePath = workspacePathSchema.parse(inputPath)
|
||||
const candidate = resolve(root, relativePath)
|
||||
if (!isPathInside(root, candidate) || candidate === root) {
|
||||
throw new Error('工具路径不能超出工作区')
|
||||
}
|
||||
const canonicalParent = await realpath(dirname(candidate))
|
||||
if (!isPathInside(root, canonicalParent)) {
|
||||
throw new Error('工具路径不能通过符号链接超出工作区')
|
||||
}
|
||||
const existing = await lstat(candidate).catch((error: unknown) => {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
})
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error('工作区写入工具拒绝符号链接')
|
||||
}
|
||||
if (existing && !existing.isFile()) {
|
||||
throw new Error('工作区写入目标不是普通文件')
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private getBuiltinTools(): ModelToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
@@ -1005,6 +953,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
argumentSummary: string,
|
||||
context: ModelToolCallContext
|
||||
): RuntimeApprovalRequest {
|
||||
assertToolAuthorizedForWorkMode(tool.name, context)
|
||||
const browserTools = this.getBrowserTools(context)
|
||||
if (browserTools?.ownsTool(tool.name)) {
|
||||
return browserTools.getApproval(
|
||||
@@ -1082,6 +1031,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
context: ModelToolCallContext
|
||||
): Promise<ModelToolResult> {
|
||||
signal.throwIfAborted()
|
||||
assertToolAuthorizedForWorkMode(name, context)
|
||||
if (name === 'knowledge_list') {
|
||||
if (
|
||||
!this.knowledgeGateway ||
|
||||
@@ -1369,28 +1319,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
}
|
||||
if (name === 'workspace_read_text') {
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
const filePath = await this.resolveExistingPath(input.path, 'file')
|
||||
return createTextToolResult(
|
||||
(
|
||||
await readBoundedUtf8File(
|
||||
filePath,
|
||||
MAX_READ_BYTES,
|
||||
'工作区文本文件超过 256KB 安全限制',
|
||||
'工作区读取目标不是有效 UTF-8 文本'
|
||||
)
|
||||
await this.workspaceAccess.readText({
|
||||
path: input.path,
|
||||
maximumBytes: MAX_READ_BYTES,
|
||||
tooLargeMessage: '工作区文本文件超过 256KB 安全限制',
|
||||
invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本',
|
||||
signal
|
||||
})
|
||||
).content
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_list_directory') {
|
||||
const input = listInputSchema.parse(argumentsValue)
|
||||
const directoryPath = await this.resolveExistingPath(
|
||||
input.path,
|
||||
'directory'
|
||||
)
|
||||
const listing = await listBoundedDirectoryEntries(
|
||||
directoryPath,
|
||||
200
|
||||
)
|
||||
const listing = await this.workspaceAccess.listDirectory({
|
||||
path: input.path,
|
||||
maximumEntries: 200,
|
||||
includeGit: true,
|
||||
includeOther: true,
|
||||
signal
|
||||
})
|
||||
return createTextToolResult(
|
||||
boundedJson(
|
||||
{
|
||||
@@ -1398,11 +1347,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? 'directory'
|
||||
: entry.isFile()
|
||||
? 'file'
|
||||
: 'other'
|
||||
type: entry.type
|
||||
})),
|
||||
truncated: listing.truncated
|
||||
},
|
||||
@@ -1415,26 +1360,15 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
if (Buffer.byteLength(input.content) > MAX_WRITE_BYTES) {
|
||||
throw new Error('写入内容超过 512KB 安全限制')
|
||||
}
|
||||
const filePath = await this.resolveWritablePath(input.path)
|
||||
const temporaryPath = `${filePath}.${randomUUID()}.tmp`
|
||||
const handle = await open(temporaryPath, 'wx', 0o600)
|
||||
try {
|
||||
try {
|
||||
await handle.writeFile(input.content, 'utf8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(temporaryPath, filePath)
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
||||
throw new Error('无法安全写入工作区文件', { cause: error })
|
||||
}
|
||||
const written = await this.workspaceAccess.writeTextAtomic({
|
||||
path: input.path,
|
||||
content: input.content,
|
||||
maximumBytes: MAX_WRITE_BYTES,
|
||||
signal
|
||||
})
|
||||
return createTextToolResult(
|
||||
boundedJson(
|
||||
{
|
||||
path: input.path,
|
||||
bytesWritten: Buffer.byteLength(input.content)
|
||||
},
|
||||
written,
|
||||
'工作区写入结果无法序列化'
|
||||
)
|
||||
)
|
||||
@@ -1499,7 +1433,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
this.webSearchClients.clear()
|
||||
this.mcpConnections = undefined
|
||||
this.webSearchBindings = undefined
|
||||
await Promise.allSettled(clients.map((client) => client.close()))
|
||||
await Promise.allSettled([
|
||||
...clients.map((client) => client.close()),
|
||||
this.workspaceAccess.dispose()
|
||||
])
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
openPrivateSqliteDatabase,
|
||||
PreparedPrivateSqliteDatabaseFile
|
||||
} from './private-sqlite-database'
|
||||
|
||||
describe('private SQLite database opening', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function privateDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-private-sqlite-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
it('opens a verified regular filesystem database', async () => {
|
||||
const path = join(await privateDirectory(), 'verified.sqlite')
|
||||
const database = openPrivateSqliteDatabase(path)
|
||||
database.exec('CREATE TABLE verified (value TEXT) STRICT')
|
||||
database.close()
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects a symbolic-link database path',
|
||||
async () => {
|
||||
const directory = await privateDirectory()
|
||||
const target = join(directory, 'target.sqlite')
|
||||
const link = join(directory, 'link.sqlite')
|
||||
await writeFile(target, '')
|
||||
await symlink(target, link)
|
||||
|
||||
expect(() => openPrivateSqliteDatabase(link)).toThrow(
|
||||
'non-symbolic-link'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects a symbolic-link parent path',
|
||||
async () => {
|
||||
const directory = await privateDirectory()
|
||||
const realParent = join(directory, 'real-parent')
|
||||
const linkedParent = join(directory, 'linked-parent')
|
||||
await mkdir(realParent, { mode: 0o700 })
|
||||
await symlink(realParent, linkedParent, 'dir')
|
||||
|
||||
expect(() =>
|
||||
openPrivateSqliteDatabase(join(linkedParent, 'database.sqlite'))
|
||||
).toThrow('parent must be a real directory')
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects a parent outside the private same-UID boundary',
|
||||
async () => {
|
||||
const directory = await privateDirectory()
|
||||
await chmod(directory, 0o755)
|
||||
|
||||
expect(() =>
|
||||
openPrivateSqliteDatabase(join(directory, 'database.sqlite'))
|
||||
).toThrow('parent must be private')
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects path replacement while the verified descriptor remains open',
|
||||
async () => {
|
||||
const directory = await privateDirectory()
|
||||
const path = join(directory, 'database.sqlite')
|
||||
const moved = join(directory, 'moved.sqlite')
|
||||
const prepared = new PreparedPrivateSqliteDatabaseFile(path)
|
||||
try {
|
||||
await rename(path, moved)
|
||||
await writeFile(path, '')
|
||||
await expect(() => prepared.openDatabase()).toThrow(
|
||||
'path identity changed'
|
||||
)
|
||||
} finally {
|
||||
prepared.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PreparedPrivateSqliteDatabaseFile,
|
||||
openPrivateSqliteDatabase
|
||||
} from '../../shared/node/private-sqlite-database'
|
||||
@@ -0,0 +1,683 @@
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { ModelCallOperationStore } from './model-call-operation-store'
|
||||
import {
|
||||
createModelCallOperationId,
|
||||
createResolvedModelProfileDigest,
|
||||
RemoteModelGateway,
|
||||
type RemoteModelGatewayDispatchContext
|
||||
} from './remote-model-gateway'
|
||||
|
||||
const stores: ModelCallOperationStore[] = []
|
||||
const storePaths = new Map<ModelCallOperationStore, string>()
|
||||
|
||||
const anthropicProfile: ResolvedModelProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Anthropic',
|
||||
baseUrl: 'https://provider.example',
|
||||
modelName: 'claude-test',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
apiKey: 'main-only-secret'
|
||||
}
|
||||
|
||||
const context: RemoteModelGatewayDispatchContext = {
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile),
|
||||
modelProfile: anthropicProfile
|
||||
}
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'claude-test', messages: [] })
|
||||
).toString('base64')
|
||||
} as const
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const store of stores.splice(0)) {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function createStore(): Promise<ModelCallOperationStore> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-'))
|
||||
const path = join(directory, 'operations.sqlite')
|
||||
const store = new ModelCallOperationStore(path)
|
||||
stores.push(store)
|
||||
storePaths.set(store, path)
|
||||
return store
|
||||
}
|
||||
|
||||
describe('RemoteModelGateway', () => {
|
||||
it('prepares and claims before one authenticated provider fetch', async () => {
|
||||
const store = await createStore()
|
||||
const order: string[] = []
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
prepare.mockImplementation(function (input) {
|
||||
order.push('prepare')
|
||||
return ModelCallOperationStore.prototype.prepare.call(store, input)
|
||||
})
|
||||
const begin = vi.spyOn(store, 'beginDispatch')
|
||||
begin.mockImplementation(function (id, metadata) {
|
||||
order.push('begin')
|
||||
return ModelCallOperationStore.prototype.beginDispatch.call(
|
||||
store,
|
||||
id,
|
||||
metadata
|
||||
)
|
||||
})
|
||||
const complete = vi.spyOn(store, 'complete')
|
||||
let sentHeaders: Headers | undefined
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
const fetcher = vi.fn(async (url: URL, init?: RequestInit) => {
|
||||
order.push('fetch')
|
||||
expect(url.toString()).toBe(
|
||||
'https://provider.example/v1/messages'
|
||||
)
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(init?.redirect).toBe('error')
|
||||
sentHeaders = new Headers(init?.headers)
|
||||
sentBody = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'message-1',
|
||||
stop_reason: 'end_turn',
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
cache_read_input_tokens: 3
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'request-provider-1',
|
||||
authorization: 'must-not-return'
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
context,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(order).toEqual(['prepare', 'begin', 'fetch'])
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret')
|
||||
expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01')
|
||||
expect(sentBody).toEqual({
|
||||
model: 'claude-test',
|
||||
messages: []
|
||||
})
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.headers).toEqual({
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'request-provider-1'
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('main-only-secret')
|
||||
const completed = complete.mock.results[0]?.value
|
||||
expect(completed?.status).toBe('completed')
|
||||
expect(completed?.terminalEvidence).toMatchObject({
|
||||
status: 'completed',
|
||||
providerRequestId: 'request-provider-1',
|
||||
providerResponseId: 'message-1',
|
||||
result: {
|
||||
finishReason: 'end_turn',
|
||||
outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
|
||||
},
|
||||
usage: {
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
cachedInputTokens: 3
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(completed)).not.toContain('main-only-secret')
|
||||
expect(JSON.stringify(completed)).not.toContain('"messages"')
|
||||
})
|
||||
|
||||
it('does not clamp Runtime-selected provider output parameters', async () => {
|
||||
const store = await createStore()
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) => {
|
||||
sentBody = JSON.parse(String(init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
return new Response('{}')
|
||||
}
|
||||
})
|
||||
const requestWithProviderMaximum = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
max_tokens: 1_000_000
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
await gateway.dispatch(
|
||||
context,
|
||||
requestWithProviderMaximum,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(sentBody).toMatchObject({ max_tokens: 1_000_000 })
|
||||
})
|
||||
|
||||
it('does not persist endpoints, credentials, or provider bodies', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ private_response_field: 'secret' }))
|
||||
})
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
store.close()
|
||||
|
||||
const databaseBytes = await readFile(storePaths.get(store)!)
|
||||
const persisted = databaseBytes.toString('utf8')
|
||||
expect(persisted).not.toContain('https://provider.example')
|
||||
expect(persisted).not.toContain('main-only-secret')
|
||||
expect(persisted).not.toContain('"messages"')
|
||||
expect(persisted).not.toContain('private_response_field')
|
||||
})
|
||||
|
||||
it('completes the ledger for a full HTTP 4xx response', async () => {
|
||||
const store = await createStore()
|
||||
const complete = vi.spyOn(store, 'complete')
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ error: { type: 'invalid_request' } }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
context,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result.status).toBe(400)
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(complete.mock.results[0]?.value.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('permits only one fetch for duplicate canonical dispatches', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: 'already-dispatched'
|
||||
})
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses profile-independent round identity and rejects a profile swap', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
const swappedProfile = {
|
||||
...anthropicProfile,
|
||||
id: '00000000-0000-4000-8000-000000000002',
|
||||
modelName: 'claude-other'
|
||||
}
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: swappedProfile,
|
||||
modelProfileDigest:
|
||||
createResolvedModelProfileDigest(swappedProfile)
|
||||
},
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'claude-other', messages: [] })
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('different identity or evidence')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('validates the Main profile snapshot digest before ledger preparation', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{ ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` },
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed on a different request for the same bound call', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'different' }]
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('different identity or evidence')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
for (const invalidRequest of [
|
||||
{ ...request, path: 'https://attacker.example/v1/messages' },
|
||||
{
|
||||
...request,
|
||||
headers: { authorization: 'Bearer remote-secret' }
|
||||
},
|
||||
{ ...request, path: '/v1/responses' },
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'untrusted-model' })
|
||||
).toString('base64')
|
||||
},
|
||||
{ ...request, path: '/v1/responses' }
|
||||
]) {
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
invalidRequest as typeof request,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow()
|
||||
}
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
expect(store.listUnresolved()).toEqual([])
|
||||
})
|
||||
|
||||
it('enforces the trusted profile image-input policy before prepare', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
const imageRequest = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'AA=='
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
imageRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: {
|
||||
...anthropicProfile,
|
||||
supportsImageInput: true
|
||||
},
|
||||
modelProfileDigest: createResolvedModelProfileDigest({
|
||||
...anthropicProfile,
|
||||
supportsImageInput: true
|
||||
})
|
||||
},
|
||||
imageRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 200 })
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ background: true },
|
||||
{ store: true },
|
||||
{ service_tier: 'priority' },
|
||||
{ web_search_options: {} },
|
||||
{
|
||||
tools: [
|
||||
{
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search'
|
||||
}
|
||||
]
|
||||
}
|
||||
])(
|
||||
'rejects independently billed provider capabilities before prepare',
|
||||
async (providerCapability) => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
...providerCapability
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('allows ordinary client-defined tools within the token policy', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
input_schema: { type: 'object' }
|
||||
}
|
||||
]
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 200 })
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'network failure',
|
||||
expectedCode: 'network-error',
|
||||
run: async () => {
|
||||
throw new Error('socket exposed sensitive upstream details')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
expectedCode: 'timeout',
|
||||
run: async (_url: URL, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
}),
|
||||
timeoutMs: 5
|
||||
}
|
||||
])(
|
||||
'marks $name after dispatch as outcome unknown',
|
||||
async ({ expectedCode, run, timeoutMs }) => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: run,
|
||||
...(timeoutMs ? { timeoutMs } : {})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: expectedCode,
|
||||
message: expect.not.stringContaining('sensitive')
|
||||
})
|
||||
expect(store.listUnresolved()).toHaveLength(1)
|
||||
expect(store.listUnresolved()[0]).toMatchObject({
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: expectedCode, retryable: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('marks caller cancellation after dispatch as outcome unknown', async () => {
|
||||
const store = await createStore()
|
||||
const controller = new AbortController()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
controller.abort()
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
if (init?.signal?.aborted) {
|
||||
reject(init.signal.reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, controller.signal)
|
||||
).rejects.toMatchObject({ code: 'cancelled' })
|
||||
expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown')
|
||||
})
|
||||
|
||||
it('marks an oversized response as outcome unknown', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(Buffer.alloc(768 * 1024 + 1), {
|
||||
headers: {
|
||||
'content-length': String(768 * 1024 + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({ code: 'response-too-large' })
|
||||
expect(store.listUnresolved()[0]).toMatchObject({
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
reason: {
|
||||
code: 'response-too-large',
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps completion pending until delivery ACK and then permits the next round', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
const nextContext = { ...context, roundIndex: 1 }
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(nextContext, request, new AbortController().signal)
|
||||
).rejects.toThrow('not been completed and delivered')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
const pending = store.listStartupRecords().records
|
||||
expect(pending).toMatchObject([{ status: 'completed' }])
|
||||
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
|
||||
|
||||
gateway.markResponseDelivered(context)
|
||||
await gateway.dispatch(
|
||||
nextContext,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('permits sequential responses without a cumulative output allowance', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () => new Response('{}')
|
||||
})
|
||||
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
|
||||
const round = { ...context, roundIndex }
|
||||
await gateway.dispatch(
|
||||
round,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
gateway.markResponseDelivered(round)
|
||||
}
|
||||
expect(store.get(
|
||||
createModelCallOperationId({ ...context, roundIndex: 11 })
|
||||
)).toMatchObject({
|
||||
status: 'completed',
|
||||
responseDeliveredAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('marks schema-invalid bridge responses unknown before terminal completion', async () => {
|
||||
const store = await createStore()
|
||||
const response = new Response('{}')
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
({
|
||||
status: 999,
|
||||
headers: response.headers,
|
||||
body: response.body
|
||||
}) as Response
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({ code: 'invalid-bridge-response' })
|
||||
expect(store.listStartupRecords().records).toMatchObject([
|
||||
{
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
reason: { code: 'invalid-bridge-response' }
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('injects OpenAI bearer auth without returning or persisting it', async () => {
|
||||
const store = await createStore()
|
||||
const profile: ResolvedModelProfile = {
|
||||
...anthropicProfile,
|
||||
baseUrl: 'https://openai.example/v1',
|
||||
protocol: 'openai-responses'
|
||||
}
|
||||
let authorization: string | null = null
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) => {
|
||||
authorization = new Headers(init?.headers).get('authorization')
|
||||
return new Response('{}')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: profile,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(profile)
|
||||
},
|
||||
{ ...request, path: '/v1/responses' },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(authorization).toBe('Bearer main-only-secret')
|
||||
expect(JSON.stringify(result)).not.toContain('main-only-secret')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,714 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
modelCallIdentitySchema,
|
||||
modelCallResultMetadataSchema,
|
||||
modelCallUsageMetadataSchema,
|
||||
type ModelCallResultMetadata,
|
||||
type ModelCallUsageMetadata
|
||||
} from '../../shared/model-call-operation-contracts'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
remoteModelGatewayResponseSchema,
|
||||
type RemoteModelGatewayRequest,
|
||||
type RemoteModelGatewayResponse
|
||||
} from '../../shared/remote-model-gateway-contracts'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
|
||||
import { ModelCallOperationStore } from './model-call-operation-store'
|
||||
import {
|
||||
createOpenAIChatCompletionsUrl,
|
||||
createOpenAIResponsesUrl
|
||||
} from './openai-endpoint'
|
||||
import {
|
||||
BoundedResponseTooLargeError,
|
||||
readBoundedResponseBytes
|
||||
} from './bounded-response'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const RESPONSE_HEADER_NAMES = [
|
||||
'content-type',
|
||||
'openai-request-id',
|
||||
'request-id',
|
||||
'x-request-id'
|
||||
] as const
|
||||
|
||||
export type RemoteModelGatewayDispatchContext = {
|
||||
requestId: string
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
promptSequence: number
|
||||
roundIndex: number
|
||||
modelProfileDigest: string
|
||||
modelProfile: ResolvedModelProfile
|
||||
}
|
||||
|
||||
export type RemoteModelGatewayOptions = {
|
||||
store: ModelCallOperationStore
|
||||
fetcher?: (
|
||||
input: URL,
|
||||
init: RequestInit
|
||||
) => Promise<Response>
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class RemoteModelGatewayError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'RemoteModelGatewayError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process-only, one-shot provider forwarder. Remote runtimes choose only
|
||||
* a protocol-specific relative path and a bounded payload. Provider location,
|
||||
* credentials, stable operation identity, and dispatch ownership stay in Main.
|
||||
*/
|
||||
export class RemoteModelGateway {
|
||||
readonly #store: ModelCallOperationStore
|
||||
readonly #fetcher: NonNullable<RemoteModelGatewayOptions['fetcher']>
|
||||
readonly #timeoutMs: number
|
||||
|
||||
constructor(options: RemoteModelGatewayOptions) {
|
||||
this.#store = options.store
|
||||
this.#fetcher = options.fetcher ?? fetch
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
if (
|
||||
!Number.isSafeInteger(timeoutMs) ||
|
||||
timeoutMs < 1 ||
|
||||
timeoutMs > 300_000
|
||||
) {
|
||||
throw new RangeError('Remote model gateway timeout is invalid')
|
||||
}
|
||||
this.#timeoutMs = timeoutMs
|
||||
}
|
||||
|
||||
async dispatch(
|
||||
context: RemoteModelGatewayDispatchContext,
|
||||
request: RemoteModelGatewayRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteModelGatewayResponse> {
|
||||
signal.throwIfAborted()
|
||||
const prepared = prepareDispatch(context, request)
|
||||
|
||||
this.#store.prepare({
|
||||
identity: prepared.identity,
|
||||
requestDigest: prepared.requestDigest,
|
||||
modelProfileDigest: prepared.modelProfileDigest
|
||||
})
|
||||
const claim = this.#store.beginDispatch(
|
||||
prepared.identity.callOperationId
|
||||
)
|
||||
if (!claim.permitted) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'already-dispatched',
|
||||
'Remote model request was already dispatched'
|
||||
)
|
||||
}
|
||||
|
||||
const timeoutController = new AbortController()
|
||||
const timeoutError = new RemoteModelGatewayError(
|
||||
'timeout',
|
||||
'Remote model request timed out'
|
||||
)
|
||||
const timer = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
this.#timeoutMs
|
||||
)
|
||||
const requestSignal = AbortSignal.any([
|
||||
signal,
|
||||
timeoutController.signal
|
||||
])
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.#fetcher(prepared.url, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
signal: requestSignal
|
||||
})
|
||||
} catch {
|
||||
clearTimeout(timer)
|
||||
const code =
|
||||
timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: signal.aborted
|
||||
? 'cancelled'
|
||||
: 'network-error'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
}
|
||||
|
||||
let responseBody: Uint8Array
|
||||
try {
|
||||
responseBody = await readBoundedResponseBytes(response, {
|
||||
maxBytes:
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
|
||||
tooLargeMessage: 'Remote model response is too large',
|
||||
truncatedMessage: 'Remote model response was truncated'
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
const code =
|
||||
timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: signal.aborted
|
||||
? 'cancelled'
|
||||
: error instanceof BoundedResponseTooLargeError
|
||||
? 'response-too-large'
|
||||
: 'response-read-failed'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (timeoutController.signal.aborted || signal.aborted) {
|
||||
const code = timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: 'cancelled'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
}
|
||||
|
||||
const responseHeaders = selectResponseHeaders(response.headers)
|
||||
const providerRequestId =
|
||||
safeProviderIdentifier(
|
||||
response.headers.get('x-request-id')
|
||||
) ??
|
||||
safeProviderIdentifier(
|
||||
response.headers.get('openai-request-id')
|
||||
) ??
|
||||
safeProviderIdentifier(response.headers.get('request-id'))
|
||||
let bridgeResponse: RemoteModelGatewayResponse
|
||||
try {
|
||||
bridgeResponse = remoteModelGatewayResponseSchema.parse({
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
bodyBase64: Buffer.from(responseBody).toString('base64')
|
||||
})
|
||||
} catch {
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
'invalid-bridge-response'
|
||||
)
|
||||
}
|
||||
const parsedMetadata = parseResponseMetadata(responseBody)
|
||||
this.#store.complete(prepared.identity.callOperationId, {
|
||||
status: 'completed',
|
||||
...(providerRequestId ? { providerRequestId } : {}),
|
||||
...(parsedMetadata.providerResponseId
|
||||
? { providerResponseId: parsedMetadata.providerResponseId }
|
||||
: {}),
|
||||
result: {
|
||||
outputDigest: sha256(responseBody),
|
||||
...(parsedMetadata.result ?? {})
|
||||
},
|
||||
...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {})
|
||||
})
|
||||
|
||||
return bridgeResponse
|
||||
}
|
||||
|
||||
markResponseDelivered(
|
||||
context: Pick<
|
||||
RemoteModelGatewayDispatchContext,
|
||||
'bindingId' | 'promptOperationId' | 'roundIndex'
|
||||
>
|
||||
): void {
|
||||
this.#store.markResponseDelivered(
|
||||
createModelCallOperationId(context)
|
||||
)
|
||||
}
|
||||
|
||||
finalizePrompt(context: {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
promptSequence: number
|
||||
}): void {
|
||||
this.#store.finalizePrompt(context)
|
||||
}
|
||||
|
||||
#markUnknownAndThrow(
|
||||
callOperationId: string,
|
||||
code: string
|
||||
): never {
|
||||
this.#store.markOutcomeUnknown(callOperationId, {
|
||||
status: 'outcome-unknown',
|
||||
reason: {
|
||||
code,
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
throw new RemoteModelGatewayError(
|
||||
code,
|
||||
remoteModelGatewayErrorMessage(code)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDispatch(
|
||||
context: RemoteModelGatewayDispatchContext,
|
||||
request: RemoteModelGatewayRequest
|
||||
): {
|
||||
identity: ReturnType<typeof modelCallIdentitySchema.parse>
|
||||
requestDigest: string
|
||||
modelProfileDigest: string
|
||||
url: URL
|
||||
headers: Record<string, string>
|
||||
body: Uint8Array
|
||||
} {
|
||||
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
|
||||
const profile = context.modelProfile
|
||||
const modelProfileDigest = createResolvedModelProfileDigest(profile)
|
||||
if (modelProfileDigest !== context.modelProfileDigest) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'model-profile-digest-mismatch',
|
||||
'Trusted model profile snapshot does not match its digest'
|
||||
)
|
||||
}
|
||||
const url = providerUrl(profile, parsedRequest.path)
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'invalid-provider-endpoint',
|
||||
'Configured model provider endpoint is invalid'
|
||||
)
|
||||
}
|
||||
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'missing-provider-credential',
|
||||
'Configured model provider credential is unavailable'
|
||||
)
|
||||
}
|
||||
const body = normalizeProviderRequestBody(
|
||||
profile,
|
||||
Buffer.from(parsedRequest.bodyBase64, 'base64')
|
||||
)
|
||||
const normalizedBodyBase64 = body.toString('base64')
|
||||
const canonicalRequest = JSON.stringify({
|
||||
profileId: profile.id,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
model: profile.modelName,
|
||||
url: url.toString(),
|
||||
request: {
|
||||
method: parsedRequest.method,
|
||||
path: parsedRequest.path,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(parsedRequest.headers).sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)
|
||||
),
|
||||
bodyBase64: normalizedBodyBase64
|
||||
}
|
||||
})
|
||||
const requestDigest = sha256(canonicalRequest)
|
||||
const callOperationId = createModelCallOperationId(context)
|
||||
const identity = modelCallIdentitySchema.parse({
|
||||
callOperationId,
|
||||
requestId: context.requestId,
|
||||
bindingId: context.bindingId,
|
||||
promptOperationId: context.promptOperationId,
|
||||
promptSequence: context.promptSequence,
|
||||
roundIndex: context.roundIndex,
|
||||
provider:
|
||||
profile.protocol === 'anthropic-messages'
|
||||
? 'anthropic'
|
||||
: 'openai',
|
||||
profile: profile.id,
|
||||
model: profile.modelName,
|
||||
protocol: profile.protocol
|
||||
})
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...parsedRequest.headers
|
||||
}
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
if (profile.authentication === 'api-key') {
|
||||
headers['x-api-key'] = profile.apiKey!
|
||||
}
|
||||
} else if (profile.authentication === 'api-key') {
|
||||
headers.authorization = `Bearer ${profile.apiKey!}`
|
||||
}
|
||||
|
||||
return {
|
||||
identity,
|
||||
requestDigest,
|
||||
modelProfileDigest,
|
||||
url,
|
||||
headers,
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelCallOperationId(
|
||||
context: Pick<
|
||||
RemoteModelGatewayDispatchContext,
|
||||
'bindingId' | 'promptOperationId' | 'roundIndex'
|
||||
>
|
||||
): string {
|
||||
return `model-call-${createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
bindingId: context.bindingId,
|
||||
promptOperationId: context.promptOperationId,
|
||||
roundIndex: context.roundIndex
|
||||
})
|
||||
)
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
export function createResolvedModelProfileDigest(
|
||||
profile: ResolvedModelProfile
|
||||
): string {
|
||||
return sha256(
|
||||
JSON.stringify({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
contextWindowTokens: profile.contextWindowTokens ?? null,
|
||||
imageGenerationQuality: profile.imageGenerationQuality ?? null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeProviderRequestBody(
|
||||
profile: ResolvedModelProfile,
|
||||
body: Buffer
|
||||
): Buffer {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(body)
|
||||
) as unknown
|
||||
} catch {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (!isRecord(value) || value.model !== profile.modelName) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (
|
||||
profile.supportsImageInput !== true &&
|
||||
containsImageInput(value)
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = { ...value }
|
||||
assertProviderBodyPolicy(profile, request)
|
||||
if (
|
||||
profile.protocol !== 'anthropic-messages' &&
|
||||
profile.protocol !== 'openai-chat-completions' &&
|
||||
profile.protocol !== 'openai-responses'
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
return Buffer.from(JSON.stringify(request), 'utf8')
|
||||
}
|
||||
|
||||
function requestPolicyError(): RemoteModelGatewayError {
|
||||
return new RemoteModelGatewayError(
|
||||
'request-policy-mismatch',
|
||||
'Remote model request does not match the trusted model policy'
|
||||
)
|
||||
}
|
||||
|
||||
function assertProviderBodyPolicy(
|
||||
profile: ResolvedModelProfile,
|
||||
request: Record<string, unknown>
|
||||
): void {
|
||||
if (
|
||||
request.background === true ||
|
||||
request.store === true ||
|
||||
request.web_search_options !== undefined ||
|
||||
request.mcp_servers !== undefined ||
|
||||
request.conversation !== undefined ||
|
||||
request.previous_response_id !== undefined ||
|
||||
(request.service_tier !== undefined &&
|
||||
request.service_tier !== 'auto' &&
|
||||
request.service_tier !== 'default') ||
|
||||
(Array.isArray(request.modalities) &&
|
||||
request.modalities.some((value) => value !== 'text'))
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (
|
||||
Array.isArray(request.include) &&
|
||||
request.include.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
/(?:web_search|file_search|computer|code_interpreter|mcp|image_generation)/iu.test(
|
||||
value
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (request.tools === undefined) {
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(request.tools)) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const tool of request.tools) {
|
||||
if (!isRecord(tool)) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const type = tool.type
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (type !== undefined && type !== 'custom') {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
} else if (type !== 'function') {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function providerUrl(
|
||||
profile: ResolvedModelProfile,
|
||||
path: string
|
||||
): URL {
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (path !== '/v1/messages') {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createAnthropicMessagesUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-chat-completions') {
|
||||
if (
|
||||
path !== '/chat/completions' &&
|
||||
path !== '/v1/chat/completions'
|
||||
) {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createOpenAIChatCompletionsUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-responses') {
|
||||
if (path !== '/responses' && path !== '/v1/responses') {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createOpenAIResponsesUrl(profile.baseUrl)
|
||||
}
|
||||
throw protocolPathError()
|
||||
}
|
||||
|
||||
function protocolPathError(): RemoteModelGatewayError {
|
||||
return new RemoteModelGatewayError(
|
||||
'protocol-path-mismatch',
|
||||
'Remote model request path does not match the configured protocol'
|
||||
)
|
||||
}
|
||||
|
||||
function selectResponseHeaders(
|
||||
headers: Headers
|
||||
): Record<string, string> {
|
||||
const selected: Record<string, string> = {}
|
||||
for (const name of RESPONSE_HEADER_NAMES) {
|
||||
const value = headers.get(name)
|
||||
const encodedBytes =
|
||||
value === null ? 0 : new TextEncoder().encode(value).byteLength
|
||||
if (
|
||||
value !== null &&
|
||||
encodedBytes > 0 &&
|
||||
!/[\r\n]/u.test(value) &&
|
||||
encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes
|
||||
) {
|
||||
selected[name] = value
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
function parseResponseMetadata(body: Uint8Array): {
|
||||
providerResponseId?: string
|
||||
result?: ModelCallResultMetadata
|
||||
usage?: ModelCallUsageMetadata
|
||||
} {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return {}
|
||||
}
|
||||
const providerResponseId = safeProviderIdentifier(parsed.id)
|
||||
const finishReason = safeFinishReason(
|
||||
isRecordArray(parsed.choices) &&
|
||||
isRecord(parsed.choices[0])
|
||||
? parsed.choices[0].finish_reason
|
||||
: parsed.stop_reason
|
||||
)
|
||||
const resultCandidate = {
|
||||
...(finishReason ? { finishReason } : {})
|
||||
}
|
||||
const result = modelCallResultMetadataSchema.safeParse(resultCandidate)
|
||||
const usage = parseUsage(parsed.usage)
|
||||
return {
|
||||
...(providerResponseId ? { providerResponseId } : {}),
|
||||
...(result.success && Object.keys(result.data).length > 0
|
||||
? { result: result.data }
|
||||
: {}),
|
||||
...(usage ? { usage } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsage(value: unknown): ModelCallUsageMetadata | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const promptDetails = isRecord(value.prompt_tokens_details)
|
||||
? value.prompt_tokens_details
|
||||
: undefined
|
||||
const inputDetails = isRecord(value.input_tokens_details)
|
||||
? value.input_tokens_details
|
||||
: undefined
|
||||
const completionDetails = isRecord(value.completion_tokens_details)
|
||||
? value.completion_tokens_details
|
||||
: undefined
|
||||
const outputDetails = isRecord(value.output_tokens_details)
|
||||
? value.output_tokens_details
|
||||
: undefined
|
||||
const candidate = {
|
||||
inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens),
|
||||
outputTokens: safeTokenCount(
|
||||
value.output_tokens ?? value.completion_tokens
|
||||
),
|
||||
cachedInputTokens: safeTokenCount(
|
||||
value.cache_read_input_tokens ??
|
||||
inputDetails?.cached_tokens ??
|
||||
promptDetails?.cached_tokens
|
||||
),
|
||||
reasoningTokens: safeTokenCount(
|
||||
outputDetails?.reasoning_tokens ??
|
||||
completionDetails?.reasoning_tokens
|
||||
)
|
||||
}
|
||||
const compact = Object.fromEntries(
|
||||
Object.entries(candidate).filter((entry) => entry[1] !== undefined)
|
||||
)
|
||||
const result = modelCallUsageMetadataSchema.safeParse(compact)
|
||||
return result.success && Object.keys(result.data).length > 0
|
||||
? result.data
|
||||
: undefined
|
||||
}
|
||||
|
||||
function safeTokenCount(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
function safeProviderIdentifier(value: unknown): string | undefined {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.length > 256 ||
|
||||
value.trim() !== value ||
|
||||
new TextEncoder().encode(value).byteLength > 256
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function safeFinishReason(value: unknown): string | undefined {
|
||||
const result =
|
||||
modelCallResultMetadataSchema.shape.finishReason.safeParse(value)
|
||||
return result.success ? result.data : undefined
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
)
|
||||
}
|
||||
|
||||
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
|
||||
return Array.isArray(value) && value.every(isRecord)
|
||||
}
|
||||
|
||||
function containsImageInput(value: unknown): boolean {
|
||||
const maximumNodes = 100_000
|
||||
const pending: unknown[] = [value]
|
||||
let visited = 0
|
||||
while (pending.length > 0) {
|
||||
visited += 1
|
||||
if (visited > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const current = pending.pop()
|
||||
if (Array.isArray(current)) {
|
||||
if (visited + pending.length + current.length > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const item of current) {
|
||||
pending.push(item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isRecord(current)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
current.type === 'image' ||
|
||||
current.type === 'image_url' ||
|
||||
current.type === 'input_image'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const values = Object.values(current)
|
||||
if (visited + pending.length + values.length > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const item of values) {
|
||||
pending.push(item)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function sha256(value: string | Uint8Array): string {
|
||||
return `sha256:${createHash('sha256').update(value).digest('hex')}`
|
||||
}
|
||||
|
||||
function remoteModelGatewayErrorMessage(code: string): string {
|
||||
switch (code) {
|
||||
case 'timeout':
|
||||
return 'Remote model request timed out after dispatch'
|
||||
case 'cancelled':
|
||||
return 'Remote model request was cancelled after dispatch'
|
||||
case 'response-too-large':
|
||||
return 'Remote model response exceeded the allowed size'
|
||||
case 'response-read-failed':
|
||||
return 'Remote model response could not be read'
|
||||
case 'invalid-bridge-response':
|
||||
return 'Remote model response could not be safely delivered'
|
||||
default:
|
||||
return 'Remote model request outcome is unknown'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
AcpBindingCursors,
|
||||
AcpCompletePromptRequest,
|
||||
AcpCompletePromptResult,
|
||||
AcpEscalateCancellationRequest,
|
||||
AcpReconcilePromptRequest,
|
||||
AcpReconcilePromptResult
|
||||
} from '../../shared/agent-protocol'
|
||||
import type {
|
||||
RemotePromptOperationAcceptance,
|
||||
RemotePromptOperationPreparation,
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type {
|
||||
ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
|
||||
export type RemoteRuntimeChannelCapabilities = {
|
||||
cancellationEscalation: boolean
|
||||
promptOperationReconciliation: boolean
|
||||
modelBridge?: boolean
|
||||
}
|
||||
|
||||
export type RuntimeSessionBindingCursors = AcpBindingCursors
|
||||
|
||||
export type RemoteCancellationEscalation = Omit<
|
||||
AcpEscalateCancellationRequest,
|
||||
'reason'
|
||||
> & {
|
||||
reason: unknown
|
||||
}
|
||||
|
||||
export type RemotePromptOperationReconciliation =
|
||||
AcpReconcilePromptRequest
|
||||
|
||||
export type RemotePromptOperationReconciliationResult =
|
||||
AcpReconcilePromptResult
|
||||
export type RemotePromptOperationCompletion = AcpCompletePromptRequest
|
||||
export type RemotePromptOperationCompletionResult =
|
||||
AcpCompletePromptResult
|
||||
|
||||
export type RemoteModelBridgeOpenRequest = {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
requestId: string
|
||||
policy: ModelBridgePolicy
|
||||
}
|
||||
|
||||
export type RemoteModelBridgeCloseResult = {
|
||||
clean: boolean
|
||||
poisoned: boolean
|
||||
}
|
||||
|
||||
export interface RemoteModelBridgeSession {
|
||||
readonly version: 'goodbuddy-model-bridge-v1'
|
||||
readonly channelId: string
|
||||
readonly channelEpoch: string
|
||||
readonly policy: ModelBridgePolicy
|
||||
readonly closed: Promise<RemoteModelBridgeCloseResult>
|
||||
close(reason?: string): Promise<RemoteModelBridgeCloseResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* A process-neutral, already authenticated ACP virtual channel.
|
||||
*
|
||||
* Implementations own framing, journaling and transport generation checks.
|
||||
* The runtime intentionally receives no SSH client, socket or command API.
|
||||
*/
|
||||
export interface RemoteRuntimeChannel {
|
||||
readonly input: ReadableStream<Uint8Array>
|
||||
readonly output: WritableStream<Uint8Array>
|
||||
readonly generation: number
|
||||
readonly channelEpoch: string
|
||||
/**
|
||||
* Signed Runtime capability identity advertised by the Agent before the
|
||||
* untrusted Runtime process starts.
|
||||
*/
|
||||
readonly advertisedAcpCapabilitiesDigest: string
|
||||
readonly capabilities: RemoteRuntimeChannelCapabilities
|
||||
readonly closed: Promise<void>
|
||||
|
||||
isCurrentGeneration(): boolean
|
||||
getBindingCursors(
|
||||
bindingId: string
|
||||
): Promise<RuntimeSessionBindingCursors>
|
||||
/** Accepts the prompt and starts its Ask or Execute Runtime process. */
|
||||
preparePrompt(
|
||||
preparation: RemotePromptOperationPreparation
|
||||
): Promise<RemotePromptOperationAcceptance>
|
||||
/**
|
||||
* Bounds same-daemon stream recovery to the signed prompt lifetime and
|
||||
* caller cancellation. Implementations that cannot recover may omit it.
|
||||
*/
|
||||
setRecoveryBoundary?(
|
||||
deadlineAt: string,
|
||||
signal: AbortSignal
|
||||
): void
|
||||
clearRecoveryBoundary?(): void
|
||||
/**
|
||||
* Allocates and registers a fresh Main-owned model channel for one prompt.
|
||||
* Legacy channels may omit this when the runtime is configured without a
|
||||
* model bridge.
|
||||
*/
|
||||
openModelBridge?(
|
||||
request: RemoteModelBridgeOpenRequest
|
||||
): Promise<RemoteModelBridgeSession>
|
||||
completePromptOperation(
|
||||
operation: RemotePromptOperationCompletion
|
||||
): Promise<RemotePromptOperationCompletionResult>
|
||||
/**
|
||||
* Stops or resumes inbound ACP delivery. Pausing must take effect before
|
||||
* the returned promise resolves and must not advance the Main ACK cursor.
|
||||
*/
|
||||
setInboundPaused(paused: boolean): Promise<void>
|
||||
escalateCancellation(
|
||||
escalation: RemoteCancellationEscalation
|
||||
): Promise<void>
|
||||
reconcilePromptOperation(
|
||||
operation: RemotePromptOperationReconciliation
|
||||
): Promise<RemotePromptOperationReconciliationResult>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export class StaleRemoteRuntimeGenerationError extends Error {
|
||||
constructor() {
|
||||
super('远端 Runtime 连接代际已失效')
|
||||
this.name = 'StaleRemoteRuntimeGenerationError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isDefinitiveRemoteRuntimeRequestRejection(
|
||||
error: unknown,
|
||||
method: string
|
||||
): boolean {
|
||||
if (
|
||||
error === null ||
|
||||
typeof error !== 'object' ||
|
||||
!('remoteRequestOutcome' in error) ||
|
||||
error.remoteRequestOutcome !== 'rejected' ||
|
||||
!('remoteMethod' in error) ||
|
||||
error.remoteMethod !== method
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function assertCurrentRemoteRuntimeGeneration(
|
||||
channel: RemoteRuntimeChannel
|
||||
): void {
|
||||
if (!channel.isCurrentGeneration()) {
|
||||
throw new StaleRemoteRuntimeGenerationError()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import type {
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntime, RuntimeAuthorizer } from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
import {
|
||||
AgentRuntimeController,
|
||||
MAX_DRAINING_RUNTIME_GENERATIONS,
|
||||
RuntimeReplacementCapacityError
|
||||
} from './runtime-controller'
|
||||
|
||||
class TestRuntime implements AgentRuntime {
|
||||
readonly dispose = vi.fn(async () => {})
|
||||
@@ -65,6 +69,33 @@ class TestRuntime implements AgentRuntime {
|
||||
finish(): void {
|
||||
this.release?.()
|
||||
}
|
||||
|
||||
releaseConversation(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
class DrainableTestRuntime extends TestRuntime {
|
||||
readonly beginDrain = vi.fn(async () => {})
|
||||
readonly forceShutdown = vi.fn(async () => {})
|
||||
private readonly drained: Promise<void>
|
||||
private resolveDrained!: () => void
|
||||
|
||||
constructor() {
|
||||
super(true)
|
||||
this.drained = new Promise((resolve) => {
|
||||
this.resolveDrained = resolve
|
||||
})
|
||||
}
|
||||
|
||||
waitForDrain(): Promise<void> {
|
||||
return this.drained
|
||||
}
|
||||
|
||||
releaseConversation(): Promise<void> {
|
||||
this.resolveDrained()
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentRuntimeController', () => {
|
||||
@@ -135,6 +166,212 @@ describe('AgentRuntimeController', () => {
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('drains remote sessions without interrupting their active prompt', async () => {
|
||||
const previous = new DrainableTestRuntime()
|
||||
const next = new TestRuntime()
|
||||
const controller = new AgentRuntimeController(previous)
|
||||
const stream = controller.run(
|
||||
{
|
||||
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b13',
|
||||
conversationId: 'conversation-drain',
|
||||
prompt: 'test',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
const pending = stream.next()
|
||||
await previous.started
|
||||
|
||||
await controller.replace(next)
|
||||
expect(previous.beginDrain).toHaveBeenCalledOnce()
|
||||
expect(previous.dispose).not.toHaveBeenCalled()
|
||||
|
||||
previous.finish()
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
value: { type: 'text' }
|
||||
})
|
||||
await stream.return()
|
||||
expect(previous.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await controller.releaseConversation('conversation-drain')
|
||||
await vi.waitFor(() =>
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
)
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('clears conversation ownership when a Runtime release fails', async () => {
|
||||
const runtime = new TestRuntime()
|
||||
runtime.releaseConversation = vi.fn(async () => {
|
||||
throw new Error('release failed')
|
||||
})
|
||||
const controller = new AgentRuntimeController(runtime)
|
||||
const stream = controller.run(
|
||||
{
|
||||
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b16',
|
||||
conversationId: 'conversation-release-failure',
|
||||
prompt: 'test',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
await stream.next()
|
||||
await stream.return()
|
||||
|
||||
expect(controller.ownedConversationCount).toBe(1)
|
||||
await expect(
|
||||
controller.releaseConversation(
|
||||
'conversation-release-failure'
|
||||
)
|
||||
).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(controller.ownedConversationCount).toBe(0)
|
||||
expect(controller.canRetire).toBe(true)
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('counts a draining generation until disposal actually settles', async () => {
|
||||
const previous = new DrainableTestRuntime()
|
||||
let finishDisposal!: () => void
|
||||
previous.dispose.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
finishDisposal = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AgentRuntimeController(previous)
|
||||
|
||||
await controller.replace(new TestRuntime())
|
||||
await controller.releaseConversation('conversation-disposal')
|
||||
await vi.waitFor(() =>
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(controller.replacementStatus.drainingGenerations).toBe(1)
|
||||
|
||||
finishDisposal()
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
controller.replacementStatus.drainingGenerations
|
||||
).toBe(0)
|
||||
)
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('bounds retained draining generations and rejects further replacements', async () => {
|
||||
const first = new DrainableTestRuntime()
|
||||
const second = new DrainableTestRuntime()
|
||||
const third = new DrainableTestRuntime()
|
||||
const rejected = new TestRuntime()
|
||||
const controller = new AgentRuntimeController(first)
|
||||
|
||||
await controller.replace(second)
|
||||
await controller.replace(third)
|
||||
|
||||
expect(controller.replacementStatus).toEqual({
|
||||
drainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
|
||||
failedDrainingGenerations: 0,
|
||||
pendingReplacements: 0,
|
||||
maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
|
||||
saturated: true
|
||||
})
|
||||
const replacement = controller.replace(rejected)
|
||||
await expect(replacement).rejects.toBeInstanceOf(
|
||||
RuntimeReplacementCapacityError
|
||||
)
|
||||
await expect(replacement).rejects.toMatchObject({
|
||||
code: 'runtime-drain-capacity',
|
||||
occupiedGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
|
||||
maximumGenerations: MAX_DRAINING_RUNTIME_GENERATIONS
|
||||
})
|
||||
expect(rejected.dispose).toHaveBeenCalledOnce()
|
||||
expect(first.forceShutdown).not.toHaveBeenCalled()
|
||||
expect(second.forceShutdown).not.toHaveBeenCalled()
|
||||
|
||||
await controller.releaseConversation('conversation-drain')
|
||||
await vi.waitFor(() => {
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
expect(second.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('retains a recoverable generation when drain observation fails', async () => {
|
||||
const previous = new DrainableTestRuntime()
|
||||
previous.waitForDrain = vi.fn(async () => {
|
||||
throw new Error('temporary drain status failure')
|
||||
})
|
||||
const controller = new AgentRuntimeController(previous, 1)
|
||||
|
||||
await controller.replace(new TestRuntime())
|
||||
await vi.waitFor(() =>
|
||||
expect(controller.replacementStatus).toMatchObject({
|
||||
drainingGenerations: 1,
|
||||
failedDrainingGenerations: 1
|
||||
})
|
||||
)
|
||||
expect(previous.dispose).not.toHaveBeenCalled()
|
||||
expect(previous.forceShutdown).not.toHaveBeenCalled()
|
||||
|
||||
await controller.dispose()
|
||||
expect(previous.forceShutdown).toHaveBeenCalledOnce()
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the configured shutdown grace while replacing a busy runtime', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const previous = new TestRuntime(true)
|
||||
const controller = new AgentRuntimeController(previous, 25)
|
||||
const stream = controller.run(
|
||||
{
|
||||
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b14',
|
||||
conversationId: 'conversation-replacement-grace',
|
||||
prompt: 'test',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
const pending = stream.next()
|
||||
await previous.started
|
||||
|
||||
const replacement = controller.replace(new TestRuntime())
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
let replaced = false
|
||||
void replacement.then(() => {
|
||||
replaced = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(replaced).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await replacement
|
||||
expect(replaced).toBe(true)
|
||||
|
||||
previous.finish()
|
||||
await expect(pending).rejects.toThrow('Runtime 已切换')
|
||||
await stream.return()
|
||||
await controller.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears bounded-wait timers after fast lifecycle operations', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const controller = new AgentRuntimeController(
|
||||
new TestRuntime(),
|
||||
25
|
||||
)
|
||||
await controller.replace(new TestRuntime())
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await controller.dispose()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('forces runtime disposal when active work does not stop during shutdown', async () => {
|
||||
const runtime = new TestRuntime(true)
|
||||
const controller = new AgentRuntimeController(runtime, 1)
|
||||
|
||||
@@ -16,14 +16,67 @@ type RuntimeSlot = {
|
||||
runtime: AgentRuntime
|
||||
activeRequests: number
|
||||
retiring: boolean
|
||||
drainable: boolean
|
||||
drainStarted?: Promise<void>
|
||||
drainError?: Error
|
||||
disposal?: Promise<void>
|
||||
disposeStarted?: Promise<void>
|
||||
resolveDisposal?: () => void
|
||||
}
|
||||
|
||||
export const MAX_DRAINING_RUNTIME_GENERATIONS = 2
|
||||
|
||||
export type RuntimeReplacementStatus = {
|
||||
drainingGenerations: number
|
||||
failedDrainingGenerations: number
|
||||
pendingReplacements: number
|
||||
maximumDrainingGenerations: number
|
||||
saturated: boolean
|
||||
}
|
||||
|
||||
export class RuntimeReplacementCapacityError extends Error {
|
||||
readonly code = 'runtime-drain-capacity'
|
||||
|
||||
constructor(
|
||||
readonly occupiedGenerations: number,
|
||||
readonly maximumGenerations: number
|
||||
) {
|
||||
super(
|
||||
`Runtime replacement is unavailable while previous generations are draining (${occupiedGenerations}/${maximumGenerations})`
|
||||
)
|
||||
this.name = 'RuntimeReplacementCapacityError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface DrainableAgentRuntime extends AgentRuntime {
|
||||
/** Stop accepting new sessions while existing session ownership remains. */
|
||||
beginDrain(): Promise<void>
|
||||
/** Resolve only after all owned sessions are released or interrupted. */
|
||||
waitForDrain(): Promise<void>
|
||||
/** Explicit bounded-shutdown escalation; never used by replacement. */
|
||||
forceShutdown(): Promise<void>
|
||||
}
|
||||
|
||||
function isDrainableRuntime(
|
||||
runtime: AgentRuntime
|
||||
): runtime is DrainableAgentRuntime {
|
||||
const candidate = runtime as Partial<DrainableAgentRuntime>
|
||||
return (
|
||||
typeof candidate.beginDrain === 'function' &&
|
||||
typeof candidate.waitForDrain === 'function' &&
|
||||
typeof candidate.forceShutdown === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export class AgentRuntimeController implements AgentRuntime {
|
||||
private current: RuntimeSlot
|
||||
private readonly retired = new Set<RuntimeSlot>()
|
||||
private replacementQueue: Promise<void> = Promise.resolve()
|
||||
private pendingReplacements = 0
|
||||
private closing = false
|
||||
private retirement?: Promise<void>
|
||||
private disposal?: Promise<void>
|
||||
private readonly ownedConversationIds = new Set<string>()
|
||||
|
||||
constructor(
|
||||
runtime: AgentRuntime,
|
||||
@@ -32,7 +85,8 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
this.current = {
|
||||
runtime,
|
||||
activeRequests: 0,
|
||||
retiring: false
|
||||
retiring: false,
|
||||
drainable: isDrainableRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,15 +110,69 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return this.current.runtime.capability
|
||||
}
|
||||
|
||||
get replacementStatus(): RuntimeReplacementStatus {
|
||||
const drainingGenerations = [...this.retired].filter(
|
||||
(slot) => slot.drainable
|
||||
).length
|
||||
const failedDrainingGenerations = [...this.retired].filter(
|
||||
(slot) =>
|
||||
slot.drainable &&
|
||||
slot.drainError !== undefined
|
||||
).length
|
||||
const occupiedGenerations =
|
||||
drainingGenerations + this.pendingReplacements
|
||||
return {
|
||||
drainingGenerations,
|
||||
failedDrainingGenerations,
|
||||
pendingReplacements: this.pendingReplacements,
|
||||
maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
|
||||
saturated:
|
||||
occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS
|
||||
}
|
||||
}
|
||||
|
||||
get activeRequestCount(): number {
|
||||
return [...new Set([this.current, ...this.retired])].reduce(
|
||||
(total, slot) => total + slot.activeRequests,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
get ownedConversationCount(): number {
|
||||
return this.ownedConversationIds.size
|
||||
}
|
||||
|
||||
get canRetire(): boolean {
|
||||
return (
|
||||
!this.closing &&
|
||||
this.activeRequestCount === 0 &&
|
||||
this.ownedConversationIds.size === 0
|
||||
)
|
||||
}
|
||||
|
||||
replace(next: AgentRuntime): Promise<void> {
|
||||
if (this.closing) {
|
||||
return next.dispose().then(() => {
|
||||
return this.disposeRejectedRuntime(next).then(() => {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
})
|
||||
}
|
||||
const operation = this.replacementQueue.then(() =>
|
||||
this.performReplace(next)
|
||||
)
|
||||
const status = this.replacementStatus
|
||||
const occupiedGenerations =
|
||||
status.drainingGenerations + status.pendingReplacements
|
||||
if (occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS) {
|
||||
return this.disposeRejectedRuntime(next).then(() => {
|
||||
throw new RuntimeReplacementCapacityError(
|
||||
occupiedGenerations,
|
||||
MAX_DRAINING_RUNTIME_GENERATIONS
|
||||
)
|
||||
})
|
||||
}
|
||||
this.pendingReplacements += 1
|
||||
const operation = this.replacementQueue
|
||||
.then(() => this.performReplace(next))
|
||||
.finally(() => {
|
||||
this.pendingReplacements -= 1
|
||||
})
|
||||
this.replacementQueue = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
@@ -74,13 +182,22 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
this.current = {
|
||||
runtime: next,
|
||||
activeRequests: 0,
|
||||
retiring: false
|
||||
retiring: false,
|
||||
drainable: isDrainableRuntime(next)
|
||||
}
|
||||
const disposal = this.retire(previous)
|
||||
await Promise.race([
|
||||
disposal,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 2_000))
|
||||
])
|
||||
const disposal = this.retireSlot(previous)
|
||||
if (previous.drainable) {
|
||||
await previous.drainStarted
|
||||
return
|
||||
}
|
||||
await waitWithin(disposal, this.shutdownGraceMs)
|
||||
}
|
||||
|
||||
private async disposeRejectedRuntime(runtime: AgentRuntime): Promise<void> {
|
||||
await waitWithin(
|
||||
runtime.dispose().catch(() => undefined),
|
||||
this.shutdownGraceMs
|
||||
)
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
@@ -107,6 +224,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
request: RuntimeConversationCompactInput,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome> {
|
||||
this.ownedConversationIds.add(request.conversationId)
|
||||
return this.invoke((runtime) => {
|
||||
if (!runtime.compactConversation) {
|
||||
throw new Error('当前 Runtime 不支持手动压缩')
|
||||
@@ -141,7 +259,11 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return status
|
||||
} finally {
|
||||
slot.activeRequests -= 1
|
||||
if (slot.retiring && slot.activeRequests === 0) {
|
||||
if (
|
||||
slot.retiring &&
|
||||
!slot.drainable &&
|
||||
slot.activeRequests === 0
|
||||
) {
|
||||
await this.disposeSlot(slot)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +278,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const slot = this.current
|
||||
this.ownedConversationIds.add(request.conversationId)
|
||||
const toolsAllowed = request.workMode === 'execute'
|
||||
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
|
||||
? authorize
|
||||
@@ -186,24 +309,43 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
signal,
|
||||
effectiveAuthorize
|
||||
)) {
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
yield event
|
||||
}
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
} finally {
|
||||
slot.activeRequests -= 1
|
||||
if (slot.retiring && slot.activeRequests === 0) {
|
||||
if (
|
||||
slot.retiring &&
|
||||
!slot.drainable &&
|
||||
slot.activeRequests === 0
|
||||
) {
|
||||
await this.disposeSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await this.current.runtime.releaseConversation?.(conversationId)
|
||||
const results = await Promise.allSettled([
|
||||
this.current.runtime.releaseConversation?.(conversationId),
|
||||
...[...this.retired].map((slot) =>
|
||||
slot.runtime.releaseConversation?.(conversationId)
|
||||
)
|
||||
])
|
||||
this.ownedConversationIds.delete(conversationId)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
'One or more Agent Runtimes failed to release the conversation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async respondToQuestion(
|
||||
@@ -220,52 +362,143 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
private retire(slot: RuntimeSlot): Promise<void> {
|
||||
private retireSlot(slot: RuntimeSlot): Promise<void> {
|
||||
slot.retiring = true
|
||||
this.retired.add(slot)
|
||||
if (!slot.disposal) {
|
||||
slot.disposal = new Promise((resolve) => {
|
||||
slot.resolveDisposal = resolve
|
||||
})
|
||||
}
|
||||
if (slot.activeRequests === 0) {
|
||||
if (slot.drainable && !slot.drainStarted) {
|
||||
const runtime = slot.runtime as DrainableAgentRuntime
|
||||
slot.drainStarted = runtime.beginDrain()
|
||||
void slot.drainStarted
|
||||
.then(() => runtime.waitForDrain())
|
||||
.then(() => this.disposeSlot(slot))
|
||||
.catch((error: unknown) => {
|
||||
slot.drainError =
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
})
|
||||
}
|
||||
if (!slot.drainable && slot.activeRequests === 0) {
|
||||
void this.disposeSlot(slot)
|
||||
}
|
||||
return slot.disposal
|
||||
}
|
||||
|
||||
private async disposeSlot(slot: RuntimeSlot): Promise<void> {
|
||||
private async disposeSlot(
|
||||
slot: RuntimeSlot,
|
||||
timeoutMs?: number,
|
||||
abandonOnTimeout = false
|
||||
): Promise<void> {
|
||||
if (!slot.resolveDisposal) {
|
||||
return
|
||||
}
|
||||
const resolve = slot.resolveDisposal
|
||||
slot.resolveDisposal = undefined
|
||||
slot.disposeStarted ??= Promise.resolve().then(() =>
|
||||
slot.runtime.dispose()
|
||||
)
|
||||
let completed = true
|
||||
try {
|
||||
await slot.runtime.dispose()
|
||||
if (timeoutMs === undefined) {
|
||||
await slot.disposeStarted
|
||||
} else {
|
||||
completed = await waitWithin(
|
||||
slot.disposeStarted,
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
resolve()
|
||||
completed = true
|
||||
}
|
||||
if (!completed && !abandonOnTimeout) {
|
||||
return
|
||||
}
|
||||
const resolve = slot.resolveDisposal
|
||||
if (resolve === undefined) {
|
||||
return
|
||||
}
|
||||
slot.resolveDisposal = undefined
|
||||
this.retired.delete(slot)
|
||||
resolve()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
retire(): Promise<void> {
|
||||
if (this.retirement !== undefined) {
|
||||
return this.retirement
|
||||
}
|
||||
this.closing = true
|
||||
const operation = this.replacementQueue.then(async () => {
|
||||
this.retireSlot(this.current)
|
||||
await Promise.all(
|
||||
[...this.retired].map(
|
||||
(slot) => slot.disposal ?? Promise.resolve()
|
||||
)
|
||||
)
|
||||
})
|
||||
this.retirement = operation
|
||||
return operation
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
if (this.disposal !== undefined) {
|
||||
return this.disposal
|
||||
}
|
||||
this.closing = true
|
||||
const operation = this.replacementQueue.then(async () => {
|
||||
const slot = this.current
|
||||
const disposal = this.retire(slot)
|
||||
if (slot.activeRequests === 0) {
|
||||
return disposal
|
||||
}
|
||||
await Promise.race([
|
||||
disposal,
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, this.shutdownGraceMs)
|
||||
)
|
||||
])
|
||||
await this.disposeSlot(slot)
|
||||
return disposal
|
||||
this.retireSlot(slot)
|
||||
const slots = [...this.retired]
|
||||
const disposals = slots.map(
|
||||
(candidate) => candidate.disposal ?? Promise.resolve()
|
||||
)
|
||||
await waitWithin(
|
||||
Promise.all(disposals).then(() => undefined),
|
||||
this.shutdownGraceMs
|
||||
)
|
||||
await Promise.all(
|
||||
slots.map(async (candidate) => {
|
||||
if (!candidate.resolveDisposal) {
|
||||
return
|
||||
}
|
||||
if (candidate.drainable) {
|
||||
await waitWithin(
|
||||
(
|
||||
candidate.runtime as DrainableAgentRuntime
|
||||
).forceShutdown().catch(() => undefined),
|
||||
this.shutdownGraceMs
|
||||
)
|
||||
}
|
||||
await this.disposeSlot(
|
||||
candidate,
|
||||
this.shutdownGraceMs,
|
||||
true
|
||||
)
|
||||
})
|
||||
)
|
||||
await Promise.all(disposals)
|
||||
})
|
||||
this.replacementQueue = operation.catch(() => undefined)
|
||||
await operation
|
||||
this.disposal = operation
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
async function waitWithin(
|
||||
promise: Promise<void>,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,8 +331,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
maxOutputTokens: 128
|
||||
authentication: 'api-key'
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -382,7 +381,6 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: true,
|
||||
maxOutputTokens: 128,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
@@ -593,7 +591,6 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
maxOutputTokens: 128,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { RuntimeSessionBinding } from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
MemoryRuntimeSessionBindingStore,
|
||||
RuntimeSessionBindingCorruptionError,
|
||||
RuntimeSessionBindingConflictError,
|
||||
RuntimeSessionClaimConflictError,
|
||||
SqliteRuntimeSessionBindingStore
|
||||
} from './runtime-session-binding-store'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
|
||||
function binding(
|
||||
bindingId: string,
|
||||
conversationId: string
|
||||
): RuntimeSessionBinding {
|
||||
return {
|
||||
bindingId,
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 1,
|
||||
conversationId,
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeBundleDigest: digest,
|
||||
runtimeAdapterDigest: digest,
|
||||
acpSessionId: `opening-${bindingId}`,
|
||||
acpCapabilitiesDigest: digest,
|
||||
state: 'opening',
|
||||
promptSequence: 0,
|
||||
channelEpoch: '1',
|
||||
lastOutboundJournaledSequence: '0',
|
||||
lastOutboundDeliveredSequence: '0',
|
||||
lastInboundJournaledSequence: '0',
|
||||
lastMainAckSequence: '0'
|
||||
}
|
||||
}
|
||||
|
||||
describe('MemoryRuntimeSessionBindingStore', () => {
|
||||
it('atomically rejects concurrent claims for one live ACP session', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
await store.put(binding('binding-1', 'conversation-1'))
|
||||
await store.put(binding('binding-2', 'conversation-2'))
|
||||
|
||||
const claims = await Promise.allSettled([
|
||||
store.claimAcpSession('binding-1', 'shared-session'),
|
||||
store.claimAcpSession('binding-2', 'shared-session')
|
||||
])
|
||||
|
||||
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = claims.find(
|
||||
(claim): claim is PromiseRejectedResult =>
|
||||
claim.status === 'rejected'
|
||||
)
|
||||
expect(rejected?.reason).toBeInstanceOf(
|
||||
RuntimeSessionClaimConflictError
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a closed owner session identity to be claimed again', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const first = binding('binding-1', 'conversation-1')
|
||||
await store.put(first)
|
||||
const claimed = await store.claimAcpSession(
|
||||
first.bindingId,
|
||||
'session-1'
|
||||
)
|
||||
await store.put({ ...claimed, state: 'closed' })
|
||||
|
||||
const second = binding('binding-2', 'conversation-2')
|
||||
await store.put(second)
|
||||
await expect(
|
||||
store.claimAcpSession(second.bindingId, 'session-1')
|
||||
).resolves.toMatchObject({
|
||||
bindingId: 'binding-2',
|
||||
acpSessionId: 'session-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps binding and channel ownership immutable', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
|
||||
await expect(
|
||||
store.put({ ...original, channelEpoch: '2' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put({
|
||||
...original,
|
||||
controllerGeneration: 2
|
||||
})
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put({ ...original, hostRevision: 2 })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put(
|
||||
binding('binding-2', original.conversationId)
|
||||
)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
})
|
||||
|
||||
it('keeps the persisted model bridge version and policy immutable', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const original = {
|
||||
...binding('binding-model', 'conversation-model'),
|
||||
modelBridgeVersion:
|
||||
'goodbuddy-model-bridge-v1' as const,
|
||||
modelBridgePolicy: {
|
||||
protocol: 'openai-responses' as const,
|
||||
model: 'gpt-test',
|
||||
modelProfileDigest: digest,
|
||||
supportsImageInput: false
|
||||
}
|
||||
}
|
||||
await store.put(original)
|
||||
await expect(
|
||||
store.put({
|
||||
...original,
|
||||
modelBridgePolicy: {
|
||||
...original.modelBridgePolicy,
|
||||
model: 'different-model'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('model bridge policy is immutable')
|
||||
await expect(
|
||||
store.put({
|
||||
...original,
|
||||
modelBridgeVersion: undefined,
|
||||
modelBridgePolicy: undefined
|
||||
})
|
||||
).rejects.toThrow('modelBridgeVersion is immutable')
|
||||
})
|
||||
|
||||
it('atomically rotates only an idle ready transport and resets its cursors', async () => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const ready = {
|
||||
...binding('binding-1', 'conversation-1'),
|
||||
state: 'ready' as const,
|
||||
acpSessionId: 'session-1',
|
||||
lastOutboundJournaledSequence: '4',
|
||||
lastOutboundDeliveredSequence: '3',
|
||||
lastInboundJournaledSequence: '6',
|
||||
lastMainAckSequence: '5'
|
||||
}
|
||||
await store.put(ready)
|
||||
|
||||
await expect(
|
||||
store.rotateReadyTransport(
|
||||
ready.bindingId,
|
||||
{
|
||||
controllerGeneration: 1,
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
channelEpoch: '1'
|
||||
},
|
||||
{
|
||||
controllerGeneration: 2,
|
||||
daemonBootIdAtOpen: 'boot-2',
|
||||
channelEpoch: '2'
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
acpSessionId: 'session-1',
|
||||
controllerGeneration: 2,
|
||||
daemonBootIdAtOpen: 'boot-2',
|
||||
channelEpoch: '2',
|
||||
lastOutboundJournaledSequence: '0',
|
||||
lastOutboundDeliveredSequence: '0',
|
||||
lastInboundJournaledSequence: '0',
|
||||
lastMainAckSequence: '0'
|
||||
})
|
||||
await expect(
|
||||
store.rotateReadyTransport(
|
||||
ready.bindingId,
|
||||
{
|
||||
controllerGeneration: 1,
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
channelEpoch: '1'
|
||||
},
|
||||
{
|
||||
controllerGeneration: 3,
|
||||
daemonBootIdAtOpen: 'boot-3',
|
||||
channelEpoch: '3'
|
||||
}
|
||||
)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SqliteRuntimeSessionBindingStore', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function databasePath(): Promise<string> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-runtime-bindings-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
return join(directory, 'bindings.sqlite')
|
||||
}
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'creates the database as a private regular file',
|
||||
async () => {
|
||||
const path = await databasePath()
|
||||
const store = new SqliteRuntimeSessionBindingStore(path)
|
||||
const file = await stat(path)
|
||||
|
||||
expect(file.isFile()).toBe(true)
|
||||
expect(file.mode & 0o777).toBe(0o600)
|
||||
store.close()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects in-memory and file URI database paths', () => {
|
||||
expect(
|
||||
() => new SqliteRuntimeSessionBindingStore(':memory:')
|
||||
).toThrow('filesystem path')
|
||||
expect(
|
||||
() =>
|
||||
new SqliteRuntimeSessionBindingStore(
|
||||
'file:runtime-bindings.sqlite'
|
||||
)
|
||||
).toThrow('filesystem path')
|
||||
})
|
||||
|
||||
it('persists bindings and cursors across reopen', async () => {
|
||||
const path = await databasePath()
|
||||
const first = new SqliteRuntimeSessionBindingStore(path)
|
||||
const persisted = {
|
||||
...binding('binding-1', 'conversation-1'),
|
||||
state: 'ready' as const,
|
||||
modelBridgeVersion:
|
||||
'goodbuddy-model-bridge-v1' as const,
|
||||
modelBridgePolicy: {
|
||||
protocol: 'openai-responses' as const,
|
||||
model: 'gpt-test',
|
||||
modelProfileDigest: digest,
|
||||
supportsImageInput: false
|
||||
},
|
||||
lastOutboundJournaledSequence: '4',
|
||||
lastOutboundDeliveredSequence: '3',
|
||||
lastInboundJournaledSequence: '6',
|
||||
lastMainAckSequence: '5'
|
||||
}
|
||||
await first.put(persisted)
|
||||
first.close()
|
||||
|
||||
const reopened = new SqliteRuntimeSessionBindingStore(path)
|
||||
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
|
||||
persisted
|
||||
)
|
||||
const returned = await reopened.getById(persisted.bindingId)
|
||||
if (!returned) {
|
||||
throw new Error('Expected persisted binding')
|
||||
}
|
||||
returned.lastMainAckSequence = '0'
|
||||
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
|
||||
persisted
|
||||
)
|
||||
reopened.dispose()
|
||||
})
|
||||
|
||||
it('persists an atomic ready transport rotation', async () => {
|
||||
const path = await databasePath()
|
||||
const store = new SqliteRuntimeSessionBindingStore(path)
|
||||
const ready = {
|
||||
...binding('binding-1', 'conversation-1'),
|
||||
state: 'ready' as const,
|
||||
acpSessionId: 'session-1',
|
||||
lastOutboundJournaledSequence: '4',
|
||||
lastOutboundDeliveredSequence: '3',
|
||||
lastInboundJournaledSequence: '6',
|
||||
lastMainAckSequence: '5'
|
||||
}
|
||||
await store.put(ready)
|
||||
await store.rotateReadyTransport(
|
||||
ready.bindingId,
|
||||
{
|
||||
controllerGeneration: 1,
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
channelEpoch: '1'
|
||||
},
|
||||
{
|
||||
controllerGeneration: 2,
|
||||
daemonBootIdAtOpen: 'boot-2',
|
||||
channelEpoch: '2'
|
||||
}
|
||||
)
|
||||
store.close()
|
||||
|
||||
const reopened = new SqliteRuntimeSessionBindingStore(path)
|
||||
await expect(reopened.getById(ready.bindingId)).resolves.toMatchObject({
|
||||
acpSessionId: 'session-1',
|
||||
state: 'ready',
|
||||
controllerGeneration: 2,
|
||||
daemonBootIdAtOpen: 'boot-2',
|
||||
channelEpoch: '2',
|
||||
lastOutboundJournaledSequence: '0',
|
||||
lastOutboundDeliveredSequence: '0',
|
||||
lastInboundJournaledSequence: '0',
|
||||
lastMainAckSequence: '0'
|
||||
})
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('rejects live conversation conflicts and permits idempotent writes', async () => {
|
||||
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
await expect(store.put(original)).resolves.toBeUndefined()
|
||||
await expect(
|
||||
store.put(binding('binding-2', original.conversationId))
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('enforces a lower row cap when reopening by pruning only closed rows', async () => {
|
||||
const path = await databasePath()
|
||||
const original = new SqliteRuntimeSessionBindingStore(path, {
|
||||
maximumRows: 3
|
||||
})
|
||||
await original.put(binding('binding-live-1', 'conversation-live-1'))
|
||||
await original.put(binding('binding-live-2', 'conversation-live-2'))
|
||||
await original.put({
|
||||
...binding('binding-closed', 'conversation-closed'),
|
||||
state: 'closed'
|
||||
})
|
||||
original.close()
|
||||
|
||||
const capped = new SqliteRuntimeSessionBindingStore(path, {
|
||||
maximumRows: 2
|
||||
})
|
||||
await expect(
|
||||
capped.getById('binding-closed')
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
capped.getById('binding-live-1')
|
||||
).resolves.toBeDefined()
|
||||
await expect(
|
||||
capped.getById('binding-live-2')
|
||||
).resolves.toBeDefined()
|
||||
capped.close()
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SqliteRuntimeSessionBindingStore(path, {
|
||||
maximumRows: 1
|
||||
})
|
||||
).toThrow(RuntimeSessionBindingConflictError)
|
||||
})
|
||||
|
||||
it('atomically resolves competing claims from separate connections', async () => {
|
||||
const path = await databasePath()
|
||||
const first = new SqliteRuntimeSessionBindingStore(path)
|
||||
const second = new SqliteRuntimeSessionBindingStore(path)
|
||||
await first.put(binding('binding-1', 'conversation-1'))
|
||||
await second.put(binding('binding-2', 'conversation-2'))
|
||||
|
||||
const claims = await Promise.allSettled([
|
||||
first.claimAcpSession('binding-1', 'shared-session'),
|
||||
second.claimAcpSession('binding-2', 'shared-session')
|
||||
])
|
||||
|
||||
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(
|
||||
1
|
||||
)
|
||||
const failure = claims.find(
|
||||
(claim): claim is PromiseRejectedResult => claim.status === 'rejected'
|
||||
)
|
||||
expect(failure?.reason).toBeInstanceOf(RuntimeSessionClaimConflictError)
|
||||
first.close()
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('releases conversation and ACP ownership when a binding closes', async () => {
|
||||
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
|
||||
const first = binding('binding-1', 'conversation-1')
|
||||
await store.put(first)
|
||||
const claimed = await store.claimAcpSession(first.bindingId, 'session-1')
|
||||
await store.put({ ...claimed, state: 'closed' })
|
||||
|
||||
const replacement = {
|
||||
...binding('binding-2', first.conversationId),
|
||||
acpSessionId: 'session-1'
|
||||
}
|
||||
await expect(store.put(replacement)).resolves.toBeUndefined()
|
||||
await expect(
|
||||
store.getByConversation(first.conversationId)
|
||||
).resolves.toMatchObject({ bindingId: replacement.bindingId })
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('rejects immutable mutation and direct ACP identity mutation', async () => {
|
||||
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
await expect(
|
||||
store.put({ ...original, channelEpoch: '2' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put({ ...original, workspaceIdentity: 'workspace-2' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put({ ...original, hostRevision: 2 })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
store.put({ ...original, acpSessionId: 'other-session' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('rejects reopening a closed binding and preserves the closed row', async () => {
|
||||
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
const closed = { ...original, state: 'closed' as const }
|
||||
await store.put(closed)
|
||||
|
||||
await expect(
|
||||
store.put({ ...closed, state: 'ready' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(store.getById(original.bindingId)).resolves.toEqual(closed)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('rejects interrupted bindings becoming ready', async () => {
|
||||
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
const interrupted = { ...original, state: 'interrupted' as const }
|
||||
await store.put(original)
|
||||
await store.put(interrupted)
|
||||
|
||||
await expect(
|
||||
store.put({ ...interrupted, state: 'ready' })
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(store.getById(original.bindingId)).resolves.toEqual(
|
||||
interrupted
|
||||
)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('fails closed when persisted JSON is malformed', async () => {
|
||||
const path = await databasePath()
|
||||
const store = new SqliteRuntimeSessionBindingStore(path)
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
store.close()
|
||||
|
||||
const raw = new DatabaseSync(path)
|
||||
raw
|
||||
.prepare(`
|
||||
UPDATE runtime_session_bindings
|
||||
SET binding_json = ?
|
||||
WHERE binding_id = ?
|
||||
`)
|
||||
.run('{}', original.bindingId)
|
||||
raw.close()
|
||||
|
||||
const reopened = new SqliteRuntimeSessionBindingStore(path)
|
||||
await expect(reopened.put(original)).rejects.toBeInstanceOf(
|
||||
RuntimeSessionBindingCorruptionError
|
||||
)
|
||||
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
|
||||
RuntimeSessionBindingCorruptionError
|
||||
)
|
||||
await expect(
|
||||
reopened.listByController(original.controllerId)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingCorruptionError)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('fails closed when indexed identity disagrees with valid JSON', async () => {
|
||||
const path = await databasePath()
|
||||
const store = new SqliteRuntimeSessionBindingStore(path)
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
await store.put(original)
|
||||
store.close()
|
||||
|
||||
const raw = new DatabaseSync(path)
|
||||
raw
|
||||
.prepare(`
|
||||
UPDATE runtime_session_bindings
|
||||
SET controller_id = ?
|
||||
WHERE binding_id = ?
|
||||
`)
|
||||
.run('controller-corrupt', original.bindingId)
|
||||
raw.close()
|
||||
|
||||
const reopened = new SqliteRuntimeSessionBindingStore(path)
|
||||
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
|
||||
RuntimeSessionBindingCorruptionError
|
||||
)
|
||||
reopened.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeSessionBindingStore lifecycle parity', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces the same legal and terminal transitions in both stores', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-runtime-bindings-parity-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const sqliteStore = new SqliteRuntimeSessionBindingStore(
|
||||
join(directory, 'bindings.sqlite')
|
||||
)
|
||||
const stores: Array<{
|
||||
store:
|
||||
| MemoryRuntimeSessionBindingStore
|
||||
| SqliteRuntimeSessionBindingStore
|
||||
close(): void
|
||||
}> = [
|
||||
{
|
||||
store: new MemoryRuntimeSessionBindingStore(),
|
||||
close() {}
|
||||
},
|
||||
{
|
||||
store: sqliteStore,
|
||||
close() {
|
||||
sqliteStore.close()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for (const entry of stores) {
|
||||
const original = binding('binding-1', 'conversation-1')
|
||||
const running = {
|
||||
...original,
|
||||
state: 'prompt-running' as const,
|
||||
activePromptOperationId: 'operation-1'
|
||||
}
|
||||
await entry.store.put(original)
|
||||
await entry.store.put(running)
|
||||
const advancedRunning = {
|
||||
...running,
|
||||
lastOutboundJournaledSequence: '1'
|
||||
}
|
||||
await entry.store.put(advancedRunning)
|
||||
await expect(
|
||||
entry.store.put(running)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
entry.store.put({
|
||||
...advancedRunning,
|
||||
activePromptOperationId: 'operation-2'
|
||||
})
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
const ready = {
|
||||
...advancedRunning,
|
||||
state: 'ready' as const,
|
||||
activePromptOperationId: undefined
|
||||
}
|
||||
await entry.store.put(ready)
|
||||
await entry.store.put({
|
||||
...ready,
|
||||
state: 'interrupted'
|
||||
})
|
||||
await expect(
|
||||
entry.store.put(ready)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
const interrupted = {
|
||||
...ready,
|
||||
state: 'interrupted' as const,
|
||||
lastOutboundJournaledSequence: '2'
|
||||
}
|
||||
await entry.store.put(interrupted)
|
||||
await entry.store.put({
|
||||
...interrupted,
|
||||
state: 'closed'
|
||||
})
|
||||
await expect(
|
||||
entry.store.put(ready)
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
|
||||
const directReady = binding('binding-2', 'conversation-2')
|
||||
await entry.store.put(directReady)
|
||||
await entry.store.put({
|
||||
...directReady,
|
||||
state: 'ready'
|
||||
})
|
||||
entry.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('caps rows by pruning only closed bindings in both stores', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-runtime-bindings-cap-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const sqliteStore = new SqliteRuntimeSessionBindingStore(
|
||||
join(directory, 'bindings.sqlite'),
|
||||
{ maximumRows: 3 }
|
||||
)
|
||||
const stores = [
|
||||
{
|
||||
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 3 }),
|
||||
close() {}
|
||||
},
|
||||
{
|
||||
store: sqliteStore,
|
||||
close() {
|
||||
sqliteStore.close()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for (const entry of stores) {
|
||||
const authority = {
|
||||
...binding('binding-live', 'conversation-live'),
|
||||
state: 'interrupted' as const
|
||||
}
|
||||
const closed = {
|
||||
...binding('binding-closed', 'conversation-closed'),
|
||||
state: 'closed' as const
|
||||
}
|
||||
const unknown = {
|
||||
...binding('binding-unknown', 'conversation-unknown'),
|
||||
state: 'outcome-unknown' as const,
|
||||
activePromptOperationId: 'operation-unknown'
|
||||
}
|
||||
await entry.store.put(authority)
|
||||
await entry.store.put(unknown)
|
||||
await entry.store.put(closed)
|
||||
await entry.store.put(binding('binding-new', 'conversation-new'))
|
||||
|
||||
await expect(
|
||||
entry.store.getById(closed.bindingId)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
entry.store.getById(authority.bindingId)
|
||||
).resolves.toEqual(authority)
|
||||
await expect(
|
||||
entry.store.getById(unknown.bindingId)
|
||||
).resolves.toEqual(unknown)
|
||||
await expect(
|
||||
entry.store.put(binding('binding-over', 'conversation-over'))
|
||||
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
|
||||
await expect(
|
||||
entry.store.getById(authority.bindingId)
|
||||
).resolves.toEqual(authority)
|
||||
entry.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes closed rows deterministically and paginates both stores', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-runtime-bindings-page-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const sqliteStore = new SqliteRuntimeSessionBindingStore(
|
||||
join(directory, 'bindings.sqlite'),
|
||||
{ maximumRows: 10 }
|
||||
)
|
||||
const stores = [
|
||||
{
|
||||
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 10 }),
|
||||
close() {}
|
||||
},
|
||||
{
|
||||
store: sqliteStore,
|
||||
close() {
|
||||
sqliteStore.close()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for (const entry of stores) {
|
||||
for (const id of ['binding-3', 'binding-1', 'binding-2']) {
|
||||
await entry.store.put(binding(id, `conversation-${id}`))
|
||||
}
|
||||
const firstPage = await entry.store.listByController('controller-1', {
|
||||
limit: 2
|
||||
})
|
||||
expect(firstPage.map((item) => item.bindingId)).toEqual([
|
||||
'binding-1',
|
||||
'binding-2'
|
||||
])
|
||||
await expect(
|
||||
entry.store.listByController('controller-1', {
|
||||
limit: 2,
|
||||
afterBindingId: 'binding-2'
|
||||
})
|
||||
).resolves.toMatchObject([{ bindingId: 'binding-3' }])
|
||||
await expect(
|
||||
entry.store.listByController('controller-1', { limit: 1_001 })
|
||||
).rejects.toBeInstanceOf(RangeError)
|
||||
|
||||
await entry.store.put({
|
||||
...(await entry.store.getById('binding-1'))!,
|
||||
state: 'closed'
|
||||
})
|
||||
await entry.store.put({
|
||||
...(await entry.store.getById('binding-2'))!,
|
||||
state: 'closed'
|
||||
})
|
||||
await expect(entry.store.pruneClosed(1)).resolves.toBe(1)
|
||||
await expect(entry.store.pruneClosed(1_001)).rejects.toBeInstanceOf(
|
||||
RangeError
|
||||
)
|
||||
await expect(
|
||||
entry.store.getById('binding-1')
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
entry.store.getById('binding-2')
|
||||
).resolves.toBeDefined()
|
||||
await expect(
|
||||
entry.store.getById('binding-3')
|
||||
).resolves.toBeDefined()
|
||||
entry.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,906 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
runtimeSessionBindingSchema,
|
||||
type RuntimeSessionBinding
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import { openPrivateSqliteDatabase } from './private-sqlite-database'
|
||||
|
||||
export type RuntimeSessionBindingListOptions = {
|
||||
limit?: number
|
||||
afterBindingId?: string
|
||||
}
|
||||
|
||||
export type RuntimeSessionBindingStoreOptions = {
|
||||
maximumRows?: number
|
||||
}
|
||||
|
||||
export type RuntimeSessionTransportIdentity = Pick<
|
||||
RuntimeSessionBinding,
|
||||
'controllerGeneration' | 'daemonBootIdAtOpen' | 'channelEpoch'
|
||||
>
|
||||
|
||||
const defaultMaximumRows = 10_000
|
||||
const maximumPageSize = 1_000
|
||||
const maximumPruneBatch = 1_000
|
||||
|
||||
function boundedPositiveInteger(
|
||||
value: number,
|
||||
maximum: number,
|
||||
name: string
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
||||
throw new RangeError(`${name} must be between 1 and ${maximum}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveMaximumRows(options?: RuntimeSessionBindingStoreOptions): number {
|
||||
return boundedPositiveInteger(
|
||||
options?.maximumRows ?? defaultMaximumRows,
|
||||
1_000_000,
|
||||
'maximumRows'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveListOptions(
|
||||
options?: RuntimeSessionBindingListOptions
|
||||
): Required<RuntimeSessionBindingListOptions> {
|
||||
return {
|
||||
limit: boundedPositiveInteger(
|
||||
options?.limit ?? maximumPageSize,
|
||||
maximumPageSize,
|
||||
'limit'
|
||||
),
|
||||
afterBindingId: options?.afterBindingId ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Main-side index for ACP session ownership. Implementations must make
|
||||
* each put atomic; callers never mutate an object returned by this interface.
|
||||
*/
|
||||
export interface RuntimeSessionBindingStore {
|
||||
getByConversation(
|
||||
conversationId: string
|
||||
): Promise<RuntimeSessionBinding | undefined>
|
||||
getById(bindingId: string): Promise<RuntimeSessionBinding | undefined>
|
||||
/**
|
||||
* Atomically claims a Runtime-returned ACP session identity for this live
|
||||
* binding. A session identity cannot be shared by live bindings, even when
|
||||
* new/load/resume complete concurrently.
|
||||
*/
|
||||
claimAcpSession(
|
||||
bindingId: string,
|
||||
acpSessionId: string
|
||||
): Promise<RuntimeSessionBinding>
|
||||
/**
|
||||
* Atomically moves an idle durable ACP session onto a newly opened
|
||||
* transport epoch. Prompt identity and ACP session ownership remain fixed;
|
||||
* transport cursors restart at zero for the new epoch.
|
||||
*/
|
||||
rotateReadyTransport(
|
||||
bindingId: string,
|
||||
expected: RuntimeSessionTransportIdentity,
|
||||
next: RuntimeSessionTransportIdentity
|
||||
): Promise<RuntimeSessionBinding>
|
||||
put(binding: RuntimeSessionBinding): Promise<void>
|
||||
pruneClosed(limit?: number): Promise<number>
|
||||
listByController(
|
||||
controllerId: string,
|
||||
options?: RuntimeSessionBindingListOptions
|
||||
): Promise<RuntimeSessionBinding[]>
|
||||
}
|
||||
|
||||
export class RuntimeSessionClaimConflictError extends Error {
|
||||
readonly code = 'runtime-session-claim-conflict'
|
||||
|
||||
constructor(readonly acpSessionId: string) {
|
||||
super('Runtime ACP session identity is already owned by another live binding')
|
||||
this.name = 'RuntimeSessionClaimConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeSessionBindingConflictError extends Error {
|
||||
readonly code = 'runtime-session-binding-conflict'
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RuntimeSessionBindingConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeSessionBindingCorruptionError extends Error {
|
||||
readonly code = 'runtime-session-binding-corruption'
|
||||
|
||||
constructor(readonly bindingId: string, options?: ErrorOptions) {
|
||||
super(`Persisted Runtime binding ${bindingId} is malformed`, options)
|
||||
this.name = 'RuntimeSessionBindingCorruptionError'
|
||||
}
|
||||
}
|
||||
|
||||
const immutableBindingKeys = [
|
||||
'bindingId',
|
||||
'controllerId',
|
||||
'controllerGeneration',
|
||||
'conversationId',
|
||||
'hostId',
|
||||
'hostRevision',
|
||||
'hostKeyGeneration',
|
||||
'workspaceIdentity',
|
||||
'agentInstallationId',
|
||||
'daemonBootIdAtOpen',
|
||||
'runtimeId',
|
||||
'runtimeBundleDigest',
|
||||
'runtimeAdapterDigest',
|
||||
'modelBridgeVersion',
|
||||
'acpCapabilitiesDigest',
|
||||
'channelEpoch'
|
||||
] as const
|
||||
|
||||
const monotonicCursorKeys = [
|
||||
'lastOutboundJournaledSequence',
|
||||
'lastOutboundDeliveredSequence',
|
||||
'lastInboundJournaledSequence',
|
||||
'lastMainAckSequence'
|
||||
] as const
|
||||
|
||||
type RuntimeSessionBindingState = RuntimeSessionBinding['state']
|
||||
|
||||
const allowedStateTransitions: Readonly<
|
||||
Record<
|
||||
RuntimeSessionBindingState,
|
||||
ReadonlySet<RuntimeSessionBindingState>
|
||||
>
|
||||
> = {
|
||||
opening: new Set([
|
||||
'opening',
|
||||
'ready',
|
||||
'prompt-running',
|
||||
'interrupted',
|
||||
'closed'
|
||||
]),
|
||||
ready: new Set(['ready', 'prompt-running', 'interrupted', 'closed']),
|
||||
'prompt-running': new Set([
|
||||
'prompt-running',
|
||||
'ready',
|
||||
'outcome-unknown',
|
||||
'interrupted',
|
||||
'closed'
|
||||
]),
|
||||
reconciling: new Set([
|
||||
'reconciling',
|
||||
'ready',
|
||||
'outcome-unknown',
|
||||
'interrupted',
|
||||
'closed'
|
||||
]),
|
||||
'outcome-unknown': new Set([
|
||||
'outcome-unknown',
|
||||
'reconciling',
|
||||
'ready',
|
||||
'closed'
|
||||
]),
|
||||
interrupted: new Set(['interrupted', 'closed']),
|
||||
closed: new Set(['closed'])
|
||||
}
|
||||
|
||||
function assertBindingUpdateAllowed(
|
||||
previous: RuntimeSessionBinding,
|
||||
next: RuntimeSessionBinding
|
||||
): void {
|
||||
for (const key of immutableBindingKeys) {
|
||||
if (previous[key] !== next[key]) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
`Runtime binding ${key} is immutable`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
previous.modelBridgePolicy,
|
||||
next.modelBridgePolicy
|
||||
)
|
||||
) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding model bridge policy is immutable'
|
||||
)
|
||||
}
|
||||
if (previous.acpSessionId !== next.acpSessionId) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'ACP session identity must be changed through atomic claim'
|
||||
)
|
||||
}
|
||||
for (const key of monotonicCursorKeys) {
|
||||
if (BigInt(next[key]) < BigInt(previous[key])) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
`Runtime binding ${key} cannot regress`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
previous.activePromptOperationId !== undefined &&
|
||||
next.activePromptOperationId !== undefined &&
|
||||
previous.activePromptOperationId !==
|
||||
next.activePromptOperationId
|
||||
) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Active prompt operation identity cannot change before terminalization'
|
||||
)
|
||||
}
|
||||
const startingPrompt =
|
||||
previous.activePromptOperationId === undefined &&
|
||||
next.activePromptOperationId !== undefined
|
||||
const expectedPromptSequence = startingPrompt
|
||||
? previous.state === 'opening'
|
||||
? previous.promptSequence
|
||||
: previous.promptSequence + 1
|
||||
: previous.promptSequence
|
||||
if (
|
||||
!Number.isSafeInteger(expectedPromptSequence) ||
|
||||
next.promptSequence !== expectedPromptSequence
|
||||
) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding prompt sequence is not the next allowed value'
|
||||
)
|
||||
}
|
||||
if (!allowedStateTransitions[previous.state].has(next.state)) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
`Runtime binding cannot transition from ${previous.state} to ${next.state}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function rotateReadyBinding(
|
||||
current: RuntimeSessionBinding,
|
||||
bindingId: string,
|
||||
expected: RuntimeSessionTransportIdentity,
|
||||
next: RuntimeSessionTransportIdentity
|
||||
): RuntimeSessionBinding {
|
||||
if (
|
||||
current.bindingId !== bindingId ||
|
||||
current.state !== 'ready' ||
|
||||
current.activePromptOperationId !== undefined
|
||||
) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Only an idle ready Runtime binding can rotate transport'
|
||||
)
|
||||
}
|
||||
if (
|
||||
current.controllerGeneration !== expected.controllerGeneration ||
|
||||
current.daemonBootIdAtOpen !== expected.daemonBootIdAtOpen ||
|
||||
current.channelEpoch !== expected.channelEpoch
|
||||
) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding transport changed before rotation'
|
||||
)
|
||||
}
|
||||
if (
|
||||
expected.controllerGeneration === next.controllerGeneration &&
|
||||
expected.daemonBootIdAtOpen === next.daemonBootIdAtOpen &&
|
||||
expected.channelEpoch === next.channelEpoch
|
||||
) {
|
||||
return structuredClone(current)
|
||||
}
|
||||
return runtimeSessionBindingSchema.parse({
|
||||
...current,
|
||||
...next,
|
||||
lastOutboundJournaledSequence: '0',
|
||||
lastOutboundDeliveredSequence: '0',
|
||||
lastInboundJournaledSequence: '0',
|
||||
lastMainAckSequence: '0'
|
||||
})
|
||||
}
|
||||
|
||||
type PersistedBindingRow = {
|
||||
binding_id: string
|
||||
controller_id: string
|
||||
conversation_id: string
|
||||
acp_session_id: string
|
||||
state: string
|
||||
binding_json: string
|
||||
}
|
||||
|
||||
const liveBindingPredicate = "state <> 'closed'"
|
||||
|
||||
/**
|
||||
* Durable Runtime binding index. The duplicated, constrained identity columns
|
||||
* are intentional: they let SQLite enforce live ownership while the complete
|
||||
* strict contract remains the canonical JSON payload.
|
||||
*/
|
||||
export class SqliteRuntimeSessionBindingStore
|
||||
implements RuntimeSessionBindingStore
|
||||
{
|
||||
private database?: DatabaseSync
|
||||
private readonly maximumRows: number
|
||||
|
||||
constructor(
|
||||
databasePath: string,
|
||||
options?: RuntimeSessionBindingStoreOptions
|
||||
) {
|
||||
this.maximumRows = resolveMaximumRows(options)
|
||||
const database = openPrivateSqliteDatabase(databasePath)
|
||||
try {
|
||||
database.exec('PRAGMA journal_mode = WAL')
|
||||
database.exec('PRAGMA synchronous = FULL')
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS runtime_session_bindings (
|
||||
binding_id TEXT PRIMARY KEY
|
||||
CHECK(length(binding_id) BETWEEN 1 AND 128),
|
||||
controller_id TEXT NOT NULL
|
||||
CHECK(length(controller_id) BETWEEN 1 AND 128),
|
||||
conversation_id TEXT NOT NULL
|
||||
CHECK(length(conversation_id) BETWEEN 1 AND 128),
|
||||
acp_session_id TEXT NOT NULL
|
||||
CHECK(length(acp_session_id) BETWEEN 1 AND 128),
|
||||
state TEXT NOT NULL CHECK(state IN (
|
||||
'opening',
|
||||
'ready',
|
||||
'prompt-running',
|
||||
'reconciling',
|
||||
'closed',
|
||||
'interrupted',
|
||||
'outcome-unknown'
|
||||
)),
|
||||
binding_json TEXT NOT NULL
|
||||
CHECK(length(binding_json) BETWEEN 2 AND 32768)
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
runtime_session_bindings_live_conversation
|
||||
ON runtime_session_bindings(conversation_id)
|
||||
WHERE ${liveBindingPredicate};
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
runtime_session_bindings_live_acp_session
|
||||
ON runtime_session_bindings(acp_session_id)
|
||||
WHERE ${liveBindingPredicate};
|
||||
CREATE INDEX IF NOT EXISTS
|
||||
runtime_session_bindings_controller
|
||||
ON runtime_session_bindings(controller_id, binding_id);
|
||||
`)
|
||||
this.database = database
|
||||
this.enforceExistingCapacity(database)
|
||||
} catch (error) {
|
||||
this.database = undefined
|
||||
database.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getByConversation(
|
||||
conversationId: string
|
||||
): Promise<RuntimeSessionBinding | undefined> {
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE conversation_id = ? AND ${liveBindingPredicate}
|
||||
`)
|
||||
.all(conversationId) as PersistedBindingRow[]
|
||||
if (rows.length > 1) {
|
||||
throw new RuntimeSessionBindingCorruptionError(rows[0]!.binding_id)
|
||||
}
|
||||
return rows[0] ? this.decode(rows[0]) : undefined
|
||||
}
|
||||
|
||||
async getById(
|
||||
bindingId: string
|
||||
): Promise<RuntimeSessionBinding | undefined> {
|
||||
const row = this.selectById(bindingId)
|
||||
return row ? this.decode(row) : undefined
|
||||
}
|
||||
|
||||
async put(binding: RuntimeSessionBinding): Promise<void> {
|
||||
const parsed = runtimeSessionBindingSchema.parse(structuredClone(binding))
|
||||
const database = this.requireDatabase()
|
||||
this.transaction(database, () => {
|
||||
const previousRow = this.selectById(parsed.bindingId)
|
||||
if (previousRow) {
|
||||
const previous = this.decode(previousRow)
|
||||
assertBindingUpdateAllowed(previous, parsed)
|
||||
} else {
|
||||
this.makeCapacityForInsert(database)
|
||||
}
|
||||
|
||||
if (parsed.state !== 'closed') {
|
||||
this.assertConversationAvailable(
|
||||
parsed.conversationId,
|
||||
parsed.bindingId
|
||||
)
|
||||
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(`
|
||||
INSERT INTO runtime_session_bindings (
|
||||
binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(binding_id) DO UPDATE SET
|
||||
controller_id = excluded.controller_id,
|
||||
conversation_id = excluded.conversation_id,
|
||||
acp_session_id = excluded.acp_session_id,
|
||||
state = excluded.state,
|
||||
binding_json = excluded.binding_json
|
||||
`)
|
||||
.run(
|
||||
parsed.bindingId,
|
||||
parsed.controllerId,
|
||||
parsed.conversationId,
|
||||
parsed.acpSessionId,
|
||||
parsed.state,
|
||||
JSON.stringify(parsed)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async claimAcpSession(
|
||||
bindingId: string,
|
||||
acpSessionId: string
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const database = this.requireDatabase()
|
||||
let result: RuntimeSessionBinding | undefined
|
||||
this.transaction(database, () => {
|
||||
const row = this.selectById(bindingId)
|
||||
if (!row) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot claim an ACP session for an unknown binding'
|
||||
)
|
||||
}
|
||||
const current = this.decode(row)
|
||||
if (current.state === 'closed') {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot claim an ACP session for a closed binding'
|
||||
)
|
||||
}
|
||||
const claimed = runtimeSessionBindingSchema.parse({
|
||||
...current,
|
||||
acpSessionId
|
||||
})
|
||||
this.assertSessionAvailable(claimed.acpSessionId, bindingId)
|
||||
database
|
||||
.prepare(`
|
||||
UPDATE runtime_session_bindings
|
||||
SET acp_session_id = ?, binding_json = ?
|
||||
WHERE binding_id = ?
|
||||
`)
|
||||
.run(
|
||||
claimed.acpSessionId,
|
||||
JSON.stringify(claimed),
|
||||
claimed.bindingId
|
||||
)
|
||||
result = structuredClone(claimed)
|
||||
})
|
||||
if (!result) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'ACP session claim did not produce a binding'
|
||||
)
|
||||
}
|
||||
return structuredClone(result)
|
||||
}
|
||||
|
||||
async rotateReadyTransport(
|
||||
bindingId: string,
|
||||
expected: RuntimeSessionTransportIdentity,
|
||||
next: RuntimeSessionTransportIdentity
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const database = this.requireDatabase()
|
||||
let result: RuntimeSessionBinding | undefined
|
||||
this.transaction(database, () => {
|
||||
const row = this.selectById(bindingId)
|
||||
if (!row) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot rotate transport for an unknown Runtime binding'
|
||||
)
|
||||
}
|
||||
const rotated = rotateReadyBinding(
|
||||
this.decode(row),
|
||||
bindingId,
|
||||
expected,
|
||||
next
|
||||
)
|
||||
database
|
||||
.prepare(`
|
||||
UPDATE runtime_session_bindings
|
||||
SET binding_json = ?
|
||||
WHERE binding_id = ?
|
||||
`)
|
||||
.run(JSON.stringify(rotated), rotated.bindingId)
|
||||
result = structuredClone(rotated)
|
||||
})
|
||||
if (!result) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding transport rotation did not produce a binding'
|
||||
)
|
||||
}
|
||||
return structuredClone(result)
|
||||
}
|
||||
|
||||
async listByController(
|
||||
controllerId: string,
|
||||
options?: RuntimeSessionBindingListOptions
|
||||
): Promise<RuntimeSessionBinding[]> {
|
||||
const page = resolveListOptions(options)
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE controller_id = ? AND binding_id > ?
|
||||
ORDER BY binding_id
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(
|
||||
controllerId,
|
||||
page.afterBindingId,
|
||||
page.limit
|
||||
) as PersistedBindingRow[]
|
||||
return rows.map((row) => this.decode(row))
|
||||
}
|
||||
|
||||
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
|
||||
const boundedLimit = boundedPositiveInteger(
|
||||
limit,
|
||||
maximumPruneBatch,
|
||||
'limit'
|
||||
)
|
||||
const database = this.requireDatabase()
|
||||
let deleted = 0
|
||||
this.transaction(database, () => {
|
||||
deleted = this.deleteClosed(database, boundedLimit)
|
||||
})
|
||||
return deleted
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.close()
|
||||
}
|
||||
|
||||
private selectById(bindingId: string): PersistedBindingRow | undefined {
|
||||
return this.requireDatabase()
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE binding_id = ?
|
||||
`)
|
||||
.get(bindingId) as PersistedBindingRow | undefined
|
||||
}
|
||||
|
||||
private decode(row: PersistedBindingRow): RuntimeSessionBinding {
|
||||
try {
|
||||
const parsed = runtimeSessionBindingSchema.parse(
|
||||
JSON.parse(row.binding_json)
|
||||
)
|
||||
if (
|
||||
parsed.bindingId !== row.binding_id ||
|
||||
parsed.controllerId !== row.controller_id ||
|
||||
parsed.conversationId !== row.conversation_id ||
|
||||
parsed.acpSessionId !== row.acp_session_id ||
|
||||
parsed.state !== row.state
|
||||
) {
|
||||
throw new Error('Indexed binding fields do not match payload')
|
||||
}
|
||||
return structuredClone(parsed)
|
||||
} catch (error) {
|
||||
throw new RuntimeSessionBindingCorruptionError(row.binding_id, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private assertConversationAvailable(
|
||||
conversationId: string,
|
||||
bindingId: string
|
||||
): void {
|
||||
const owner = this.requireDatabase()
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE conversation_id = ? AND binding_id <> ?
|
||||
AND ${liveBindingPredicate}
|
||||
`)
|
||||
.get(conversationId, bindingId) as PersistedBindingRow | undefined
|
||||
if (owner) {
|
||||
this.decode(owner)
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Conversation is already owned by another live Runtime binding'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private assertSessionAvailable(
|
||||
acpSessionId: string,
|
||||
bindingId: string
|
||||
): void {
|
||||
const owner = this.requireDatabase()
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE acp_session_id = ? AND binding_id <> ?
|
||||
AND ${liveBindingPredicate}
|
||||
`)
|
||||
.get(acpSessionId, bindingId) as PersistedBindingRow | undefined
|
||||
if (owner) {
|
||||
this.decode(owner)
|
||||
throw new RuntimeSessionClaimConflictError(acpSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private makeCapacityForInsert(database: DatabaseSync): void {
|
||||
const row = database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
||||
)
|
||||
.get() as { count: number }
|
||||
const required = row.count - this.maximumRows + 1
|
||||
if (required <= 0) {
|
||||
return
|
||||
}
|
||||
this.deleteClosed(database, required)
|
||||
const remaining = database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
||||
)
|
||||
.get() as { count: number }
|
||||
if (remaining.count >= this.maximumRows) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding store capacity is exhausted by authoritative bindings'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enforceExistingCapacity(database: DatabaseSync): void {
|
||||
const row = database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
||||
)
|
||||
.get() as { count: number }
|
||||
const excess = row.count - this.maximumRows
|
||||
if (excess <= 0) {
|
||||
return
|
||||
}
|
||||
this.transaction(database, () => {
|
||||
this.deleteClosed(database, excess)
|
||||
const remaining = database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
||||
)
|
||||
.get() as { count: number }
|
||||
if (remaining.count > this.maximumRows) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding store exceeds capacity with authoritative bindings'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private deleteClosed(database: DatabaseSync, limit: number): number {
|
||||
const rows = database
|
||||
.prepare(`
|
||||
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
||||
state, binding_json
|
||||
FROM runtime_session_bindings
|
||||
WHERE state = 'closed'
|
||||
ORDER BY binding_id
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(limit) as PersistedBindingRow[]
|
||||
for (const row of rows) {
|
||||
const binding = this.decode(row)
|
||||
if (binding.state !== 'closed') {
|
||||
throw new RuntimeSessionBindingCorruptionError(binding.bindingId)
|
||||
}
|
||||
}
|
||||
const remove = database.prepare(
|
||||
'DELETE FROM runtime_session_bindings WHERE binding_id = ?'
|
||||
)
|
||||
let deleted = 0
|
||||
for (const row of rows) {
|
||||
deleted += Number(remove.run(row.binding_id).changes)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
private transaction(database: DatabaseSync, operation: () => void): void {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
operation()
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
try {
|
||||
database.exec('ROLLBACK')
|
||||
} catch {
|
||||
// Preserve the original domain, validation, or SQLite failure.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
if (!this.database) {
|
||||
throw new Error('Runtime session binding store is closed')
|
||||
}
|
||||
return this.database
|
||||
}
|
||||
}
|
||||
|
||||
/** A strict store useful for tests and non-persistent composition roots. */
|
||||
export class MemoryRuntimeSessionBindingStore
|
||||
implements RuntimeSessionBindingStore
|
||||
{
|
||||
private readonly bindings = new Map<string, RuntimeSessionBinding>()
|
||||
private readonly maximumRows: number
|
||||
|
||||
constructor(options?: RuntimeSessionBindingStoreOptions) {
|
||||
this.maximumRows = resolveMaximumRows(options)
|
||||
}
|
||||
|
||||
async getByConversation(
|
||||
conversationId: string
|
||||
): Promise<RuntimeSessionBinding | undefined> {
|
||||
const binding = [...this.bindings.values()].find(
|
||||
(candidate) =>
|
||||
candidate.conversationId === conversationId &&
|
||||
candidate.state !== 'closed'
|
||||
)
|
||||
return binding ? structuredClone(binding) : undefined
|
||||
}
|
||||
|
||||
async getById(
|
||||
bindingId: string
|
||||
): Promise<RuntimeSessionBinding | undefined> {
|
||||
const binding = this.bindings.get(bindingId)
|
||||
return binding ? structuredClone(binding) : undefined
|
||||
}
|
||||
|
||||
async put(binding: RuntimeSessionBinding): Promise<void> {
|
||||
const parsed = runtimeSessionBindingSchema.parse(binding)
|
||||
const previous = this.bindings.get(parsed.bindingId)
|
||||
if (previous) {
|
||||
assertBindingUpdateAllowed(previous, parsed)
|
||||
} else {
|
||||
this.makeCapacityForInsert()
|
||||
}
|
||||
if (parsed.state !== 'closed') {
|
||||
const conversationOwner = [...this.bindings.values()].find(
|
||||
(candidate) =>
|
||||
candidate.bindingId !== parsed.bindingId &&
|
||||
candidate.state !== 'closed' &&
|
||||
candidate.conversationId === parsed.conversationId
|
||||
)
|
||||
if (conversationOwner) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Conversation is already owned by another live Runtime binding'
|
||||
)
|
||||
}
|
||||
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
|
||||
}
|
||||
this.bindings.set(parsed.bindingId, structuredClone(parsed))
|
||||
}
|
||||
|
||||
async claimAcpSession(
|
||||
bindingId: string,
|
||||
acpSessionId: string
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const current = this.bindings.get(bindingId)
|
||||
if (!current) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot claim an ACP session for an unknown binding'
|
||||
)
|
||||
}
|
||||
if (current.state === 'closed') {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot claim an ACP session for a closed binding'
|
||||
)
|
||||
}
|
||||
this.assertSessionAvailable(acpSessionId, bindingId)
|
||||
const claimed = runtimeSessionBindingSchema.parse({
|
||||
...current,
|
||||
acpSessionId
|
||||
})
|
||||
this.bindings.set(bindingId, structuredClone(claimed))
|
||||
return structuredClone(claimed)
|
||||
}
|
||||
|
||||
async rotateReadyTransport(
|
||||
bindingId: string,
|
||||
expected: RuntimeSessionTransportIdentity,
|
||||
next: RuntimeSessionTransportIdentity
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const current = this.bindings.get(bindingId)
|
||||
if (!current) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Cannot rotate transport for an unknown Runtime binding'
|
||||
)
|
||||
}
|
||||
const rotated = rotateReadyBinding(
|
||||
current,
|
||||
bindingId,
|
||||
expected,
|
||||
next
|
||||
)
|
||||
this.bindings.set(bindingId, structuredClone(rotated))
|
||||
return structuredClone(rotated)
|
||||
}
|
||||
|
||||
private assertSessionAvailable(
|
||||
acpSessionId: string,
|
||||
bindingId: string
|
||||
): void {
|
||||
const owner = [...this.bindings.values()].find(
|
||||
(candidate) =>
|
||||
candidate.bindingId !== bindingId &&
|
||||
candidate.state !== 'closed' &&
|
||||
candidate.acpSessionId === acpSessionId
|
||||
)
|
||||
if (owner) {
|
||||
throw new RuntimeSessionClaimConflictError(acpSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async listByController(
|
||||
controllerId: string,
|
||||
options?: RuntimeSessionBindingListOptions
|
||||
): Promise<RuntimeSessionBinding[]> {
|
||||
const page = resolveListOptions(options)
|
||||
return [...this.bindings.values()]
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.controllerId === controllerId &&
|
||||
binding.bindingId > page.afterBindingId
|
||||
)
|
||||
.sort((left, right) =>
|
||||
left.bindingId < right.bindingId
|
||||
? -1
|
||||
: left.bindingId > right.bindingId
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
.slice(0, page.limit)
|
||||
.map((binding) => structuredClone(binding))
|
||||
}
|
||||
|
||||
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
|
||||
const boundedLimit = boundedPositiveInteger(
|
||||
limit,
|
||||
maximumPruneBatch,
|
||||
'limit'
|
||||
)
|
||||
return this.deleteClosed(boundedLimit)
|
||||
}
|
||||
|
||||
private makeCapacityForInsert(): void {
|
||||
const required = this.bindings.size - this.maximumRows + 1
|
||||
if (required <= 0) {
|
||||
return
|
||||
}
|
||||
this.deleteClosed(required)
|
||||
if (this.bindings.size >= this.maximumRows) {
|
||||
throw new RuntimeSessionBindingConflictError(
|
||||
'Runtime binding store capacity is exhausted by authoritative bindings'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private deleteClosed(limit: number): number {
|
||||
const closedIds = [...this.bindings.values()]
|
||||
.filter((binding) => binding.state === 'closed')
|
||||
.map((binding) => binding.bindingId)
|
||||
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.slice(0, limit)
|
||||
for (const bindingId of closedIds) {
|
||||
this.bindings.delete(bindingId)
|
||||
}
|
||||
return closedIds.length
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,34 @@ import type {
|
||||
} from './runtime'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionSpaceDescriptor } from '../execution-space'
|
||||
|
||||
function executionSpace(
|
||||
cacheIdentity: string
|
||||
): ExecutionSpaceDescriptor {
|
||||
return {
|
||||
kind: 'local',
|
||||
rootPath: cacheIdentity,
|
||||
cacheIdentity,
|
||||
routeIdentity: cacheIdentity,
|
||||
workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess']
|
||||
}
|
||||
}
|
||||
|
||||
function remoteExecutionSpace(
|
||||
hostId: string,
|
||||
cacheIdentity: string
|
||||
): ExecutionSpaceDescriptor {
|
||||
return {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project',
|
||||
cacheIdentity,
|
||||
routeIdentity: cacheIdentity,
|
||||
workspaceAccess:
|
||||
{} as ExecutionSpaceDescriptor['workspaceAccess']
|
||||
}
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const releaseConversation = vi.fn(async () => undefined)
|
||||
@@ -111,30 +139,26 @@ describe('SelectedRuntimeManager', () => {
|
||||
const manager = new SelectedRuntimeManager(create)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
|
||||
const projectOne = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const firstSpace = executionSpace('local:project-one')
|
||||
const secondSpace = executionSpace('local:project-two')
|
||||
const projectOne = await manager.getRuntime(selection, firstSpace)
|
||||
const projectOneAgain = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const projectTwo = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
executionSpace('local:project-one')
|
||||
)
|
||||
const projectTwo = await manager.getRuntime(selection, secondSpace)
|
||||
|
||||
expect(projectOneAgain).toBe(projectOne)
|
||||
expect(projectTwo).not.toBe(projectOne)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
firstSpace
|
||||
)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
secondSpace
|
||||
)
|
||||
await manager.dispose()
|
||||
})
|
||||
@@ -162,6 +186,64 @@ describe('SelectedRuntimeManager', () => {
|
||||
expect(second.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retires only cached runtimes for an edited SSH Host', async () => {
|
||||
const firstHost = runtime()
|
||||
const secondHost = runtime()
|
||||
const local = runtime()
|
||||
const replacement = runtime()
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(firstHost.value)
|
||||
.mockResolvedValueOnce(secondHost.value)
|
||||
.mockResolvedValueOnce(local.value)
|
||||
.mockResolvedValueOnce(replacement.value)
|
||||
const manager = new SelectedRuntimeManager(create)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
const firstSpace = remoteExecutionSpace(
|
||||
'host-one',
|
||||
'ssh:host-one:revision-one'
|
||||
)
|
||||
const secondSpace = remoteExecutionSpace(
|
||||
'host-two',
|
||||
'ssh:host-two:revision-one'
|
||||
)
|
||||
const localSpace = executionSpace('local:project')
|
||||
|
||||
const firstController = await manager.getRuntime(
|
||||
selection,
|
||||
firstSpace
|
||||
)
|
||||
const secondController = await manager.getRuntime(
|
||||
selection,
|
||||
secondSpace
|
||||
)
|
||||
const localController = await manager.getRuntime(
|
||||
selection,
|
||||
localSpace
|
||||
)
|
||||
|
||||
await manager.invalidateHost('host-one')
|
||||
|
||||
expect(firstHost.dispose).toHaveBeenCalledOnce()
|
||||
expect(secondHost.dispose).not.toHaveBeenCalled()
|
||||
expect(local.dispose).not.toHaveBeenCalled()
|
||||
expect(
|
||||
await manager.getRuntime(selection, secondSpace)
|
||||
).toBe(secondController)
|
||||
expect(
|
||||
await manager.getRuntime(selection, localSpace)
|
||||
).toBe(localController)
|
||||
expect(
|
||||
await manager.getRuntime(selection, firstSpace)
|
||||
).not.toBe(firstController)
|
||||
expect(create).toHaveBeenCalledTimes(4)
|
||||
|
||||
await manager.dispose()
|
||||
expect(secondHost.dispose).toHaveBeenCalledOnce()
|
||||
expect(local.dispose).toHaveBeenCalledOnce()
|
||||
expect(replacement.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disposes a connection-test runtime without caching it', async () => {
|
||||
const tested = runtime()
|
||||
const cached = runtime()
|
||||
@@ -194,7 +276,10 @@ describe('SelectedRuntimeManager', () => {
|
||||
const selection = { provider: 'opencode' as const }
|
||||
|
||||
await expect(
|
||||
manager.getNativeSnapshot(selection, 'C:\\Projects\\One')
|
||||
manager.getNativeSnapshot(
|
||||
selection,
|
||||
executionSpace('local:project-one')
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
provider: 'opencode',
|
||||
inventoryStatus: 'available'
|
||||
@@ -202,7 +287,10 @@ describe('SelectedRuntimeManager', () => {
|
||||
expect(inspected.getNativeSnapshot).toHaveBeenCalledOnce()
|
||||
expect(inspected.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
await manager.getRuntime(selection, 'C:\\Projects\\One')
|
||||
await manager.getRuntime(
|
||||
selection,
|
||||
executionSpace('local:project-one')
|
||||
)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
await manager.dispose()
|
||||
})
|
||||
@@ -297,6 +385,152 @@ describe('SelectedRuntimeManager', () => {
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('retires a drainable Runtime without forcing it and keeps routing conversation release', async () => {
|
||||
let finishDrain!: () => void
|
||||
const drain = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve
|
||||
})
|
||||
const active = runtime()
|
||||
const beginDrain = vi.fn(async () => undefined)
|
||||
const waitForDrain = vi.fn(async () => await drain)
|
||||
const forceShutdown = vi.fn(async () => undefined)
|
||||
Object.assign(active.value, {
|
||||
beginDrain,
|
||||
waitForDrain,
|
||||
forceShutdown
|
||||
})
|
||||
const manager = new SelectedRuntimeManager(
|
||||
vi.fn(async () => active.value)
|
||||
)
|
||||
const controller = await manager.getRuntime({
|
||||
provider: 'opencode'
|
||||
})
|
||||
const stream = controller.run(
|
||||
{
|
||||
requestId: '00000000-0000-4000-8000-000000000021',
|
||||
conversationId: 'conversation-draining',
|
||||
prompt: 'finish normally',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
await stream.next()
|
||||
await stream.next()
|
||||
|
||||
await manager.reset()
|
||||
expect(beginDrain).toHaveBeenCalledOnce()
|
||||
expect(forceShutdown).not.toHaveBeenCalled()
|
||||
expect(active.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await manager.releaseConversation('conversation-draining')
|
||||
expect(active.releaseConversation).toHaveBeenCalledWith(
|
||||
'conversation-draining'
|
||||
)
|
||||
finishDrain()
|
||||
await vi.waitFor(() =>
|
||||
expect(active.dispose).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(forceShutdown).not.toHaveBeenCalled()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('evicts only the least-recently-used idle Runtime at the cache bound', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
const third = runtime()
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first.value)
|
||||
.mockResolvedValueOnce(second.value)
|
||||
.mockResolvedValueOnce(third.value)
|
||||
const manager = new SelectedRuntimeManager(create, 2)
|
||||
const selection = (suffix: string) => ({
|
||||
provider: 'model' as const,
|
||||
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
|
||||
})
|
||||
|
||||
const firstController = await manager.getRuntime(selection('31'))
|
||||
await manager.getRuntime(selection('32'))
|
||||
await expect(
|
||||
manager.getRuntime(selection('31'))
|
||||
).resolves.toBe(firstController)
|
||||
await manager.getRuntime(selection('33'))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(second.dispose).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(first.dispose).not.toHaveBeenCalled()
|
||||
expect(third.dispose).not.toHaveBeenCalled()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('bounds current and drain-retained Runtime controllers across resets', async () => {
|
||||
const created = [runtime(), runtime(), runtime()]
|
||||
for (const item of created) {
|
||||
Object.assign(item.value, {
|
||||
beginDrain: vi.fn(async () => undefined),
|
||||
waitForDrain: vi.fn(async () => await new Promise(() => undefined)),
|
||||
forceShutdown: vi.fn(async () => undefined)
|
||||
})
|
||||
}
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(created[0]!.value)
|
||||
.mockResolvedValueOnce(created[1]!.value)
|
||||
.mockResolvedValueOnce(created[2]!.value)
|
||||
const manager = new SelectedRuntimeManager(create, 1, 2)
|
||||
const selection = (suffix: string) => ({
|
||||
provider: 'model' as const,
|
||||
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
|
||||
})
|
||||
|
||||
await manager.getRuntime(selection('41'))
|
||||
await manager.reset()
|
||||
await manager.getRuntime(selection('42'))
|
||||
await manager.reset()
|
||||
await expect(
|
||||
manager.getRuntime(selection('43'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
await manager.dispose()
|
||||
expect(created[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(created[1]!.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reserves retained capacity while an invalidated Runtime is still being created', async () => {
|
||||
let finishCreate!: (runtime: AgentRuntime) => void
|
||||
const pendingCreate = new Promise<AgentRuntime>((resolve) => {
|
||||
finishCreate = resolve
|
||||
})
|
||||
const created = runtime()
|
||||
const create = vi.fn(async () => await pendingCreate)
|
||||
const manager = new SelectedRuntimeManager(create, 1, 1)
|
||||
const selection = (suffix: string) => ({
|
||||
provider: 'model' as const,
|
||||
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
|
||||
})
|
||||
|
||||
const firstRuntime = manager
|
||||
.getRuntime(selection('51'))
|
||||
.catch((error: unknown) => error)
|
||||
const reset = manager.reset()
|
||||
|
||||
await expect(
|
||||
manager.getRuntime(selection('52'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
|
||||
finishCreate(created.value)
|
||||
await expect(firstRuntime).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.stringMatching(/设置已更改/iu)
|
||||
})
|
||||
)
|
||||
await reset
|
||||
expect(created.dispose).toHaveBeenCalledOnce()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('releases a conversation from every selected runtime', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
@@ -321,4 +555,40 @@ describe('SelectedRuntimeManager', () => {
|
||||
)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('uses only the resolver-owned execution-space cache identity', async () => {
|
||||
const create = vi.fn(async () => runtime().value)
|
||||
const manager = new SelectedRuntimeManager(create)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
const remote = (
|
||||
cacheIdentity: string
|
||||
): ExecutionSpaceDescriptor => ({
|
||||
kind: 'ssh',
|
||||
hostId: 'host-one',
|
||||
remoteRootPath: '/srv/project',
|
||||
cacheIdentity,
|
||||
routeIdentity: 'remote-route',
|
||||
workspaceAccess:
|
||||
{} as ExecutionSpaceDescriptor['workspaceAccess']
|
||||
})
|
||||
|
||||
const baseline = await manager.getRuntime(
|
||||
selection,
|
||||
remote('resolver-identity-one')
|
||||
)
|
||||
await expect(
|
||||
manager.getRuntime(
|
||||
selection,
|
||||
remote('resolver-identity-one')
|
||||
)
|
||||
).resolves.toBe(baseline)
|
||||
await expect(
|
||||
manager.getRuntime(
|
||||
selection,
|
||||
remote('resolver-identity-two')
|
||||
)
|
||||
).resolves.not.toBe(baseline)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
await manager.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,12 @@ import type {
|
||||
RuntimeConversationCompactOutcome
|
||||
} from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
import type { ExecutionSpaceDescriptor } from '../execution-space'
|
||||
|
||||
export type SelectedRuntimeResolver = {
|
||||
getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<AgentRuntime>
|
||||
getStatus(
|
||||
selection: AgentRuntimeSelection
|
||||
@@ -26,65 +27,124 @@ export type SelectedRuntimeResolver = {
|
||||
): Promise<AgentRuntimeStatus>
|
||||
getNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot>
|
||||
compactConversation(
|
||||
request: RuntimeConversationCompactInput,
|
||||
workspacePath: string | undefined,
|
||||
executionSpace: ExecutionSpaceDescriptor | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome>
|
||||
releaseConversation(conversationId: string): Promise<void>
|
||||
reset?(): Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeEntry = {
|
||||
operation: Promise<AgentRuntimeController>
|
||||
controller?: AgentRuntimeController
|
||||
hostId?: string
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAXIMUM_CACHED_RUNTIMES = 8
|
||||
const DEFAULT_MAXIMUM_RETAINED_RUNTIMES = 16
|
||||
|
||||
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
Promise<AgentRuntimeController>
|
||||
>()
|
||||
private readonly entries = new Map<string, RuntimeEntry>()
|
||||
private disposed = false
|
||||
private readonly retiring = new Set<Promise<void>>()
|
||||
private readonly retiringControllers =
|
||||
new Set<AgentRuntimeController>()
|
||||
private pendingRetirementControllers = 0
|
||||
private readonly tests = new Set<Promise<AgentRuntimeStatus>>()
|
||||
private readonly snapshots = new Set<
|
||||
Promise<RuntimeNativeSnapshot>
|
||||
>()
|
||||
private useSequence = 0
|
||||
|
||||
constructor(
|
||||
private readonly createRuntime: (
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
) => Promise<AgentRuntime>
|
||||
) {}
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
) => Promise<AgentRuntime>,
|
||||
private readonly maximumCachedRuntimes =
|
||||
DEFAULT_MAXIMUM_CACHED_RUNTIMES,
|
||||
private readonly maximumRetainedRuntimes =
|
||||
DEFAULT_MAXIMUM_RETAINED_RUNTIMES
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumCachedRuntimes) ||
|
||||
maximumCachedRuntimes < 1 ||
|
||||
maximumCachedRuntimes > 64
|
||||
) {
|
||||
throw new RangeError('Invalid selected Runtime cache limit')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maximumRetainedRuntimes) ||
|
||||
maximumRetainedRuntimes < maximumCachedRuntimes ||
|
||||
maximumRetainedRuntimes > 64
|
||||
) {
|
||||
throw new RangeError('Invalid retained Runtime limit')
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<AgentRuntimeController> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
agentRuntimeSelectionKey(selection),
|
||||
workspacePath ?? ''
|
||||
executionSpace?.cacheIdentity ?? null
|
||||
])
|
||||
const existing = this.entries.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
existing.lastUsed = ++this.useSequence
|
||||
return existing.operation
|
||||
}
|
||||
const operation = this.createRuntime(selection, workspacePath).then(
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes - 1)
|
||||
if (this.entries.size >= this.maximumCachedRuntimes) {
|
||||
throw new Error(
|
||||
'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话'
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.entries.size +
|
||||
this.retiringControllers.size +
|
||||
this.pendingRetirementControllers >=
|
||||
this.maximumRetainedRuntimes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话'
|
||||
)
|
||||
}
|
||||
const operation = this.createRuntime(selection, executionSpace).then(
|
||||
async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.entries.get(key)?.operation !== operation
|
||||
) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
const controller = new AgentRuntimeController(runtime)
|
||||
entry.controller = controller
|
||||
return controller
|
||||
}
|
||||
)
|
||||
this.entries.set(key, operation)
|
||||
const entry: RuntimeEntry = {
|
||||
operation,
|
||||
...(executionSpace?.kind === 'ssh'
|
||||
? { hostId: executionSpace.hostId }
|
||||
: {}),
|
||||
lastUsed: ++this.useSequence
|
||||
}
|
||||
this.entries.set(key, entry)
|
||||
try {
|
||||
return await operation
|
||||
} catch (error) {
|
||||
if (this.entries.get(key) === operation) {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
throw error
|
||||
@@ -114,14 +174,14 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
async getNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const operation = this.runNativeSnapshot(
|
||||
selection,
|
||||
workspacePath
|
||||
executionSpace
|
||||
)
|
||||
this.snapshots.add(operation)
|
||||
try {
|
||||
@@ -133,27 +193,31 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
async compactConversation(
|
||||
request: RuntimeConversationCompactInput,
|
||||
workspacePath: string | undefined,
|
||||
executionSpace: ExecutionSpaceDescriptor | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeConversationCompactOutcome> {
|
||||
const runtime = await this.getRuntime(
|
||||
request.runtimeSelection,
|
||||
workspacePath
|
||||
executionSpace
|
||||
)
|
||||
return runtime.compactConversation(request, signal)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const controllers = await Promise.allSettled([
|
||||
...this.entries.values()
|
||||
...[...this.entries.values()].map(({ operation }) => operation)
|
||||
])
|
||||
const current = controllers.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
await Promise.allSettled(
|
||||
controllers.flatMap((result) =>
|
||||
result.status === 'fulfilled'
|
||||
? [result.value.releaseConversation(conversationId)]
|
||||
: []
|
||||
[...new Set([...current, ...this.retiringControllers])].map(
|
||||
async (controller) => {
|
||||
await controller.releaseConversation(conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes)
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
@@ -164,6 +228,20 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
)
|
||||
}
|
||||
|
||||
async invalidateHost(hostId: string): Promise<void> {
|
||||
const entries: RuntimeEntry[] = []
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.hostId !== hostId) {
|
||||
continue
|
||||
}
|
||||
this.entries.delete(key)
|
||||
entries.push(entry)
|
||||
}
|
||||
await Promise.allSettled(
|
||||
entries.map((entry) => this.startRetiring(entry, false))
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
const entries = [...this.entries.values()]
|
||||
@@ -171,6 +249,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
await Promise.allSettled(
|
||||
entries.map((entry) => this.startRetiring(entry, true))
|
||||
)
|
||||
await Promise.allSettled(
|
||||
[...this.retiringControllers].map(
|
||||
async (controller) => await controller.dispose()
|
||||
)
|
||||
)
|
||||
await Promise.allSettled([...this.tests])
|
||||
await Promise.allSettled([...this.snapshots])
|
||||
await Promise.allSettled([...this.retiring])
|
||||
@@ -195,9 +278,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
private async runNativeSnapshot(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
executionSpace?: ExecutionSpaceDescriptor
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
const runtime = await this.createRuntime(selection, workspacePath)
|
||||
const runtime = await this.createRuntime(selection, executionSpace)
|
||||
try {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
@@ -212,22 +295,75 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
}
|
||||
|
||||
private async startRetiring(
|
||||
entry: Promise<AgentRuntimeController>,
|
||||
entry: RuntimeEntry,
|
||||
waitForDisposal: boolean
|
||||
): Promise<void> {
|
||||
this.pendingRetirementControllers += 1
|
||||
let controllerPending = true
|
||||
try {
|
||||
const controller = await entry
|
||||
const disposal = controller.dispose()
|
||||
this.retiring.add(disposal)
|
||||
void disposal.then(
|
||||
() => this.retiring.delete(disposal),
|
||||
() => this.retiring.delete(disposal)
|
||||
const controller =
|
||||
entry.controller ?? await entry.operation
|
||||
this.pendingRetirementControllers -= 1
|
||||
controllerPending = false
|
||||
const disposal = this.trackRetiringController(
|
||||
controller,
|
||||
waitForDisposal
|
||||
)
|
||||
if (waitForDisposal) {
|
||||
await disposal
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (controllerPending) {
|
||||
this.pendingRetirementControllers -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackRetiringController(
|
||||
controller: AgentRuntimeController,
|
||||
force: boolean
|
||||
): Promise<void> {
|
||||
this.retiringControllers.add(controller)
|
||||
const disposal = force
|
||||
? controller.dispose()
|
||||
: controller.retire()
|
||||
this.retiring.add(disposal)
|
||||
void disposal.then(
|
||||
() => {
|
||||
this.retiring.delete(disposal)
|
||||
this.retiringControllers.delete(controller)
|
||||
},
|
||||
() => {
|
||||
this.retiring.delete(disposal)
|
||||
this.retiringControllers.delete(controller)
|
||||
}
|
||||
)
|
||||
return disposal
|
||||
}
|
||||
|
||||
private evictIdleEntries(targetSize: number): void {
|
||||
if (this.entries.size <= targetSize) {
|
||||
return
|
||||
}
|
||||
const candidates = [...this.entries.entries()]
|
||||
.filter((entry): entry is [string, RuntimeEntry] => {
|
||||
const controller = entry[1].controller
|
||||
return controller !== undefined && controller.canRetire
|
||||
})
|
||||
.sort(
|
||||
(left, right) => left[1].lastUsed - right[1].lastUsed
|
||||
)
|
||||
for (const [key, entry] of candidates) {
|
||||
if (this.entries.size <= targetSize) {
|
||||
break
|
||||
}
|
||||
if (this.entries.get(key) !== entry) {
|
||||
continue
|
||||
}
|
||||
this.entries.delete(key)
|
||||
void this.startRetiring(entry, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user