fix: reuse runtime processes across project sessions

Multiple projects could spawn duplicate heavyweight runtimes, while settings
changes and remote reconnection could cancel or strand native conversations.
Share compatible local OpenCode and DeepSeek Harness process owners and
Agent-owned ACP connections, retaining workspace routing, per-session modes,
model attribution, question ownership, targeted cancellation and idle cleanup.

Restore remote pending questions and terminal task projections across reconnect
and desktop restart, and preserve accepted ordinary requests during settings
replacement while cancelling expert-team parents with their replaced scheduler.
Extend regression coverage and Agent type checks. Record measured resource and
real-model validation results, including failures and limitations.

Also include the conversation media-generation design drafts (not an implemented
feature), documentation entry links and dedicated Linux test-directory guidance.
Desktop and Agent changes must be delivered together for shared process results.

Validation: typecheck and lint passed; staged content and documentation links
checked. Prior full regression and live-model evidence are recorded in progress
notes; no new full test suite or model calls were run for this commit.

Release note: 多项目会话现在复用兼容的 OpenCode 和 DeepSeek Harness 进程,减少重复启动并保持工作区、工具和取消隔离。修复远端问答在断线、重启及配置更新后的恢复与状态同步问题,Desktop 与 Agent 需配套更新。
This commit is contained in:
mesalogo
2026-09-15 08:14:42 +08:00
parent b755951ecd
commit e5aa9ed688
65 changed files with 4116 additions and 365 deletions
+64 -2
View File
@@ -20,6 +20,8 @@ import {
import type { RuntimeEvent } from './runtime'
import {
AcpRemoteRuntime,
RemotePromptCancelledError,
RemotePromptRecoveryUnavailableError,
type AcpRemoteRuntimeOptions
} from './acp-remote-runtime'
import type {
@@ -2979,6 +2981,42 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
})
it('uses the recovered generation for retained and new Sessions and mode changes', async () => {
const fixture = ownedChannel()
const fresh = ownedChannel()
try {
await collect(fixture.instance.run(
{ ...request, workMode: 'ask' }, new AbortController().signal
))
Object.defineProperty(fixture.channel, 'generation', { value: 2 })
await collect(fixture.instance.run(
{ ...request, requestId: 'after-reconnect', workMode: 'execute' },
new AbortController().signal
))
Object.defineProperty(fresh.channel, 'generation', { value: 2 })
await collect(fresh.instance.run(
{ ...request, requestId: 'new-after-reconnect', conversationId: 'another-conversation' },
new AbortController().signal
))
expect([
...fixture.preparePrompt.mock.calls.slice(1),
...fresh.preparePrompt.mock.calls
].map(([prepared]) => ({
controllerGeneration: prepared.controllerGeneration,
connectionGeneration: prepared.connectionGeneration
}))).toEqual([
{ controllerGeneration: 2, connectionGeneration: 2 },
{ controllerGeneration: 2, connectionGeneration: 2 }
])
expect(fixture.startOwnedPrompt).toHaveBeenNthCalledWith(
2, expect.objectContaining({ acpSessionId: 'owned-session' })
)
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
} finally {
await Promise.all([fixture.instance.dispose(), fresh.instance.dispose()])
}
})
it('does not abandon an accepted Agent prompt for a later request', async () => {
const fixture = ownedChannel({ waitForCancellation: true })
const first = fixture.instance.run(
@@ -3115,6 +3153,30 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
})
it('definitively ends recovery after the original Agent installation is replaced', async () => {
const fixture = ownedChannel({ waitForCancellation: true })
const first = fixture.instance.run(request, new AbortController().signal)
await first.next()
await first.return()
const channelFactory = vi.fn(async () => fixture.channel)
const replacement = factoryRuntime(channelFactory, fixture.store, {
identity: { ...identity, agentInstallationId: 'replacement-agent' },
modelBridgePolicy,
modelProfile: ownedModelProfile
})
try {
await expect(collect(replacement.run(
{ ...request, remoteRecoveryOnly: true },
new AbortController().signal
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
expect(channelFactory).not.toHaveBeenCalled()
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
} finally {
await Promise.all([replacement.dispose(), fixture.instance.dispose()])
}
})
it('reconciles an owned cancellation before the next prompt in the same conversation', async () => {
const fixture = ownedChannel()
fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise<never>(() => {}))
@@ -3124,7 +3186,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
const pending = stream.next()
await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce())
controller.abort(new Error('cancel pending question'))
await expect(pending).rejects.toThrow('cancel pending question')
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined()
const events = await collect(fixture.instance.run(
{ ...request, requestId: 'after-cancel' }, new AbortController().signal
@@ -3170,7 +3232,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
expect(settled).toBe(false)
releaseEscalation()
await expect(pending).rejects.toThrow('cancel owned prompt')
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
})
it.each([
+35 -6
View File
@@ -138,6 +138,13 @@ export class RemotePromptRecoveryUnavailableError extends Error {
}
}
export class RemotePromptCancelledError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options)
this.name = 'RemotePromptCancelledError'
}
}
type ActivePrompt = {
reportedQuestions?: Set<string>
subagentProgress?: OpenCodeSubagentProgress
@@ -1016,9 +1023,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
false
).catch(() => undefined)
if (
result?.status === 'terminal' &&
(result.processTree === 'empty' ||
result.terminalState === 'completed')
result?.status === 'terminal'
) {
const terminal = await this.persist(
context,
@@ -1310,6 +1315,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
return runtimeSessionBindingSchema.parse({
bindingId,
...this.options.identity,
controllerGeneration: context.channel.generation,
conversationId,
runtimeId: this.options.runtimeId,
acpSessionId: `opening-${randomUUID()}`,
@@ -1353,6 +1359,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
'没有可安全附加的远端 Agent 请求,且恢复不会重放任务'
)
}
if (
request.remoteRecoveryOnly === true &&
binding &&
!this.bindingIdentityMatches(binding)
) {
throw new RemotePromptRecoveryUnavailableError(
'原远端 Agent 或 Runtime 已变更,无法恢复原请求且不会重放任务'
)
}
if (
request.remoteRecoveryOnly !== true &&
this.options.modelProfile !== undefined &&
@@ -1741,6 +1756,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
current.context,
current.binding
)
current.binding = await this.rotateBindingTransport(
current.binding,
current.context,
signal
)
current.binding = await this.persist(
current.context,
current.binding,
@@ -2685,9 +2705,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
)
session.binding = binding
if (terminal.state === 'cancelled') {
throw signal.aborted
? abortReason(signal)
: new Error('远端 Runtime 请求已取消')
throw new RemotePromptCancelledError(
'远端 Runtime 请求已取消',
signal.aborted ? { cause: abortReason(signal) } : undefined
)
}
if (terminal.state === 'failed') {
throw new Error(
@@ -2810,6 +2831,14 @@ export class AcpRemoteRuntime implements AgentRuntime {
signal,
authorize
)
} catch (error) {
if (signal.aborted && session.binding.state === 'closed') {
throw new RemotePromptCancelledError(
error instanceof Error ? error.message : '远端 Runtime 请求已取消',
{ cause: error }
)
}
throw error
} finally {
this.sessionReservations.delete(request.conversationId)
this.notifyDrain()
+4
View File
@@ -134,6 +134,10 @@ describe('createAgentRuntime model compatibility', () => {
expect(runtime.runtimeId).toBe('deepseek-harness')
await expect(runtime.getStatus()).resolves.toMatchObject({
available: true
})
expect(deepseekHarnessLauncher).not.toHaveBeenCalled()
await expect(runtime.testConnection!()).resolves.toMatchObject({
available: false
})
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
+39 -15
View File
@@ -3,7 +3,8 @@ import {
type ModelRuntimeOptions
} from './model-runtime'
import { ContinueAgentRuntime } from './continue-runtime'
import { OpenCodeRuntime } from './opencode-runtime'
import { OpenCodeRuntime, type OpenCodeRuntimeOptions } from './opencode-runtime'
import type { LocalRuntimeRegistry } from './local-runtime-registry'
import {
DeepSeekHarnessRuntime,
type DeepSeekHarnessRuntimeOptions
@@ -57,6 +58,7 @@ const noModelTaskTools: ModelToolProviderLike = {
}
export type AgentCapabilityContext = {
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
mcpServers?: ResolvedMcpServer[]
@@ -213,8 +215,8 @@ export function createAgentRuntime(
if (!capabilities.deepseekHarnessLauncher) {
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
}
return new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
const options: DeepSeekHarnessRuntimeOptions = {
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
baseUrl: profile.baseUrl,
model: profile.modelName,
supportsImageInput: profile.supportsImageInput === true,
@@ -225,16 +227,26 @@ export function createAgentRuntime(
},
skillPackages: capabilities.skillPackages,
extensionPackages: capabilities.deepseekHarnessExtensions,
toolProvider: new ModelToolProvider(
getWorkspaceAccess(),
capabilities.mcpServers,
undefined,
capabilities.knowledgeGateway,
capabilities.webSearchEnabled === true,
{},
capabilities.launchEnvironmentProvider
createToolProvider: (directory) => new ModelToolProvider(
new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined,
capabilities.knowledgeGateway, capabilities.webSearchEnabled === true,
{}, capabilities.launchEnvironmentProvider
)
})
}
const create = () => new DeepSeekHarnessRuntime(options)
if (!capabilities.localRuntimeRegistry) return create()
const configuration = { ...options, defaultWorkspace: undefined, createToolProvider: undefined }
return capabilities.localRuntimeRegistry.acquire(
`deepseek-harness:${profile.id}`,
{
...configuration,
mcpServers: capabilities.mcpServers,
webSearchEnabled: capabilities.webSearchEnabled === true,
launchEnvironment: capabilities.launchEnvironmentProvider?.()
},
workspace,
create
)
}
if (provider === 'continue') {
@@ -293,7 +305,7 @@ export function createAgentRuntime(
'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议'
)
}
return new OpenCodeRuntime({
const options: OpenCodeRuntimeOptions = {
baseUrl,
embedded,
binaryPath:
@@ -311,13 +323,25 @@ export function createAgentRuntime(
skillInstructions: capabilities.skillInstructions,
skillPackages: capabilities.skillPackages,
sharedCacheRoot: capabilities.opencodeSharedCacheRoot,
defaultWorkspace: workspace,
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
knowledgeGateway: capabilities.knowledgeGateway,
mcpServers: capabilities.mcpServers,
customization: settings?.runtimeCustomization.opencode,
launchEnvironmentProvider:
capabilities.launchEnvironmentProvider
})
}
const create = () => new OpenCodeRuntime(options)
if (!capabilities.localRuntimeRegistry) return create()
const configuration = { ...options, defaultWorkspace: undefined }
return capabilities.localRuntimeRegistry.acquire(
`opencode:${settings?.opencodeModelProfile?.id ?? 'native'}`,
{
...configuration,
launchEnvironment: capabilities.launchEnvironmentProvider?.()
},
workspace,
create
)
}
const defaultModelProfile =
+177 -1
View File
@@ -37,6 +37,10 @@ import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
import { SelectedRuntimeManager } from './selected-runtime-manager'
import { LocalRuntimeRegistry } from './local-runtime-registry'
import { createAgentRuntime } from './create-runtime'
import { defaultRuntimeSettings } from '../../shared/contracts'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call'
@@ -385,6 +389,81 @@ function createInProcessLaunch(
}
describe('DeepSeek Harness real ACP control-plane E2E', () => {
it.each([2, 10])('shares one production-composed Host across %i projects with independent native file and shell tools', async (projectCount) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-reuse-')))
const directories = Array.from(
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
)
await Promise.all(directories.map(async (directory, index) => {
await mkdir(directory)
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
}))
const inProcess = createInProcessLaunch(root, {
stream(options) {
const id = latestUserText(options)
const read = toolResultText(options, `${id}-read`)
if (read === undefined) return toolCall(`${id}-read`, 'read', { file_path: 'marker.txt' })
const write = toolResultText(options, `${id}-write`)
if (write === undefined) return toolCall(`${id}-write`, 'write', {
file_path: `${id}.txt`, content: read
})
const shell = toolResultText(options, `${id}-shell`)
if (shell === undefined) return toolCall(`${id}-shell`, process.platform === 'win32' ? 'pwsh' : 'bash', {
command: process.platform === 'win32' ? 'Get-Content marker.txt' : 'cat marker.txt',
description: 'Read the session workspace fixture'
})
return textResponse(shell)
}
})
const launch = vi.fn(inProcess.launch)
const registry = new LocalRuntimeRegistry()
const resolver = new ExecutionSpaceResolver()
const profile = {
id: '00000000-0000-4000-8000-000000000009',
name: 'Reuse fixture', baseUrl: 'http://127.0.0.1:9',
modelName: 'reuse', protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model'
}
const settings = {
...defaultRuntimeSettings, provider: 'deepseek-harness',
modelProfiles: [profile], deepseekHarnessModelProfile: profile
} as ResolvedRuntimeSettings
const manager = new SelectedRuntimeManager(async (_selection, space) =>
createAgentRuntime(root, settings, {
executionSpace: space, localRuntimeRegistry: registry,
deepseekHarnessLauncher: launch
}), 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
try {
const selection = { provider: 'deepseek-harness' as const }
for (let wave = 0; wave < 2; wave++) {
const outputs = await Promise.all(directories.flatMap((directory, project) =>
[0, 1].map(async (conversation) => {
const runtime = await manager.getRuntime(selection, resolver.resolveLocal(directory))
const id = `p${project}-c${conversation}-w${wave}`
const events = await collect(runtime.run({
requestId: id, conversationId: `p${project}-c${conversation}`,
prompt: id, workMode: 'execute'
}, AbortSignal.timeout(20_000)))
expect(events.at(-1)?.type).toBe('done')
expect(await readFile(join(directory, `${id}.txt`), 'utf8')).toContain(`PROJECT_${project}`)
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join(''))
.toContain(`PROJECT_${project}`)
return events
})
))
expect(outputs).toHaveLength(projectCount * 2)
expect(launch).toHaveBeenCalledOnce()
}
await manager.releaseConversation('p0-c0')
expect(launch).toHaveBeenCalledOnce()
expect(inProcess.hosts).toHaveLength(1)
} finally {
await manager.dispose()
await registry.dispose()
await rm(root, { recursive: true, force: true })
}
}, 60_000)
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
const workspace = join(root, 'workspace')
@@ -428,6 +507,103 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
}
})
it('keeps native Skills, plugin inventory, MCP calls and images scoped across shared projects', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-shared-capabilities-')))
const directories = [join(root, 'one'), join(root, 'two')]
await Promise.all(directories.map(directory => mkdir(directory)))
const plugin = join(root, 'inventory-plugin.mjs')
await writeFile(plugin, [
"export const name = 'shared-inventory'",
"export const inject = ['skills']",
'export function apply(ctx) {',
"ctx.skills.register({ name: 'shared-plugin-skill', description: 'Shared plugin fixture', content: '# Shared plugin', source: 'custom' })",
'}'
].join('\n'))
const models = [new FakeGameModel(), new FakeGameModel()]
const images = [createCanvas(1, 1).toBuffer('image/png'), createCanvas(2, 1).toBuffer('image/png')]
const seenImages = new Map<number, GenerateOptions['messages'][number]['content']>()
const inProcess = createInProcessLaunch(root, {
stream(options) {
const project = latestUserText(options).includes('PROJECT_1') ? 1 : 0
seenImages.set(project, options.messages.flatMap(message => message.content).filter(block => block.type === 'image'))
return models[project]!.stream(options)
}
})
const launch = vi.fn(inProcess.launch)
const calls = vi.spyOn(ModelToolProvider.prototype, 'callTool')
const registry = new LocalRuntimeRegistry()
const profile = {
id: '00000000-0000-4000-8000-000000000010',
name: 'Capabilities fixture', baseUrl: 'http://127.0.0.1:9',
modelName: 'capabilities', protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model',
supportsImageInput: true
}
const resolver = new ExecutionSpaceResolver()
const runtimes = directories.map(directory => createAgentRuntime(root, {
...defaultRuntimeSettings, provider: 'deepseek-harness',
modelProfiles: [profile], deepseekHarnessModelProfile: profile
} as ResolvedRuntimeSettings, {
executionSpace: resolver.resolveLocal(directory), localRuntimeRegistry: registry,
deepseekHarnessLauncher: launch,
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
deepseekHarnessExtensions: [{ id: 'shared-inventory', entrypoint: plugin, configuration: {} }],
mcpServers: [{
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
enabled: true, allowDynamicTools: false, assignments: ['deepseek-harness'],
secretConfigured: false, transport: 'stdio',
command: process.versions.electron ? 'node' : process.execPath,
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
}]
}))
try {
for (const mode of ['execute', 'ask'] as const) {
await Promise.all(runtimes.map(async (runtime, project) => {
const events = await collect(runtime.run({
requestId: `capabilities-${project}-${mode}`, conversationId: `capabilities-${project}`,
workMode: mode,
prompt: `${mode === 'ask' ? 'ASK_BOUNDARY_PROBE' : 'Use Skill and MCP'} PROJECT_${project}`,
...(mode === 'execute' ? { images: [{
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!.toString('base64')
}] } : {})
}, AbortSignal.timeout(30_000), async () => 'once'))
expect(events.at(-1)?.type).toBe('done')
const model = models[project]!
if (mode === 'execute') {
expect(model.skillResult).toContain('window.__GOODBUDDY_GAME__')
expect(model.blueprint).toMatchObject({ title: 'Prism Relay' })
const seen = seenImages.get(project)!
expect(seen).toHaveLength(1)
const image = seen[0]!
if (image.type !== 'image') throw new Error('Expected image block')
expect(image.attachment).toMatchObject({ width: project + 1, height: 1 })
const stored = await inProcess.hosts[0]!.context.attachments.readImage(image.attachment)
expect(Buffer.from(stored.data).equals(images[project]!)).toBe(true)
} else {
expect(model.askToolNames).not.toContain(model.mcpToolName)
expect(model.askToolResult).toContain('Ask 模式不允许执行非只读工具')
}
expect(await runtime.getNativeSnapshot?.()).toMatchObject({
skills: expect.arrayContaining([expect.objectContaining({ id: 'shared-plugin-skill', source: 'plugin' })])
})
}))
expect(launch).toHaveBeenCalledOnce()
expect(calls).toHaveBeenCalledTimes(2)
}
expect(new Set(calls.mock.contexts).size).toBe(2)
expect(calls.mock.calls.map(call => call[3]?.conversationId).sort())
.toEqual(['capabilities-0', 'capabilities-1'])
await runtimes[0]!.releaseConversation?.('capabilities-0')
expect(await runtimes[1]!.getStatus()).toMatchObject({ available: true })
expect(launch).toHaveBeenCalledOnce()
} finally {
calls.mockRestore()
await Promise.all(runtimes.map(runtime => runtime.dispose()))
await registry.dispose()
await rm(root, { recursive: true, force: true })
}
}, 60_000)
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
const workspace = join(root, 'workspace')
@@ -780,7 +956,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
)
try {
await runtime.getStatus()
await runtime.testConnection()
expect(inProcess.hosts[0]?.extensionFailures).toEqual([])
await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({
provider: 'deepseek-harness',
+39 -12
View File
@@ -443,6 +443,16 @@ function toolProvider(
}
describe('DeepSeekHarnessRuntime', () => {
it('reports passive readiness without launching a Host or reading a model', async () => {
const harness = setup()
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
available: true, detail: expect.stringContaining('首次使用')
})
expect(harness.launch).not.toHaveBeenCalled()
await harness.runtime.dispose()
expect(harness.launch).not.toHaveBeenCalled()
})
it('includes bounded failed-extension cleanup in the startup budget', () => {
expect(deepSeekHarnessStartupBudget(11)).toEqual({
hostTimeoutMs: 76_000,
@@ -474,7 +484,7 @@ describe('DeepSeekHarnessRuntime', () => {
]
})
const status = harness.runtime.getStatus()
const status = harness.runtime.testConnection()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10_001)
@@ -504,7 +514,7 @@ describe('DeepSeekHarnessRuntime', () => {
}
})
const status = harness.runtime.getStatus()
const status = harness.runtime.testConnection()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(12_000)
@@ -528,7 +538,7 @@ describe('DeepSeekHarnessRuntime', () => {
}
})
const status = harness.runtime.getStatus()
const status = harness.runtime.testConnection()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await harness.runtime.dispose()
@@ -633,7 +643,7 @@ describe('DeepSeekHarnessRuntime', () => {
advertisedImageInput: false
})
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
available: false,
detail: expect.stringContaining('图片能力')
})
@@ -904,11 +914,14 @@ describe('DeepSeekHarnessRuntime', () => {
}
])
const harness = setup({ toolProvider: provider })
await harness.runtime.getStatus()
const running = collect(harness.runtime.run(
request('catalog', 'execute'), new AbortController().signal
))
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1))
await expect(
harness.extension('goodbuddy/tools/list', {
sessionId: 'session-catalog'
sessionId: 'session-1'
})
).resolves.toEqual({
tools: [
@@ -938,10 +951,12 @@ describe('DeepSeekHarnessRuntime', () => {
expect(
JSON.stringify(
await harness.extension('goodbuddy/tools/list', {
sessionId: 'session-catalog'
sessionId: 'session-1'
})
)
).not.toContain('secret')
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
@@ -1396,8 +1411,9 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('bounds cancellation when the harness prompt never settles', async () => {
const harness = setup({ useProductionPromptTimeout: true })
it('releases an uncooperative cancelled session without terminating its peer', async () => {
const provider = toolProvider()
const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider })
const controller = new AbortController()
const running = collect(
harness.runtime.run(
@@ -1409,6 +1425,10 @@ describe('DeepSeekHarnessRuntime', () => {
expect(harness.promptGates).toHaveLength(1)
)
const peer = collect(harness.runtime.run(
request('cancel-peer'), new AbortController().signal
))
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2))
controller.abort(new Error('cancelled by user'))
await expect(running).rejects.toThrow('cancelled by user')
@@ -1416,7 +1436,14 @@ describe('DeepSeekHarnessRuntime', () => {
method: 'session/cancel',
params: { sessionId: 'session-1' }
})
expect(harness.child.terminate).toHaveBeenCalled()
expect(harness.requests).toContainEqual({
method: 'goodbuddy/session/release',
params: { sessionId: 'session-1' }
})
expect(harness.child.terminate).not.toHaveBeenCalled()
expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId)
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
expect((await peer).at(-1)?.type).toBe('done')
await harness.runtime.dispose()
})
@@ -1447,7 +1474,7 @@ describe('DeepSeekHarnessRuntime', () => {
it('reports process exit and fully disposes the connection and child', async () => {
const harness = setup()
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
available: true
})
harness.exit.resolve({ exitCode: 9 })
@@ -1469,7 +1496,7 @@ describe('DeepSeekHarnessRuntime', () => {
new Error('method not found')
)
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
available: false,
detail: 'method not found'
})
+105 -34
View File
@@ -204,11 +204,13 @@ export type DeepSeekHarnessRuntimeOptions = {
skillPackages?: RuntimeSkillPackage[]
extensionPackages?: ControlledHarnessExtensionPackage[]
toolProvider?: ModelToolProviderLike
createToolProvider?: (workspace: string) => ModelToolProviderLike
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
}
type ActiveRun = {
request: AgentExecutionRequest
toolProvider?: ModelToolProviderLike
toolController: AbortController
authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][]
@@ -454,7 +456,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private disposed = false
private fatalError?: Error
private stderrBytes = 0
private readonly sessions = new Map<string, string>()
private readonly sessions = new Map<string, { id: string; workspace: string }>()
private readonly workspaceTools = new Map<string, ModelToolProviderLike>()
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
private readonly sessionInitializations = new Map<
string,
@@ -832,10 +835,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
'DeepSeek Harness MCP 工具上下文不可用'
)
}
if (!this.options.toolProvider) {
return { tools: [] }
}
const run = this.activeRuns.get(params.sessionId)
if (!run?.toolProvider || run.closed) return { tools: [] }
const context = {
conversationId:
run?.request.conversationId ??
@@ -848,7 +849,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
knowledgeCapabilityToken:
run?.request.knowledgeCapabilityToken
}
const tools = await this.options.toolProvider.listTools(
const tools = await run.toolProvider.listTools(
context,
connection.signal
)
@@ -869,7 +870,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
if (
!run ||
run.closed ||
!this.options.toolProvider ||
!run.toolProvider ||
typeof name !== 'string' ||
!argumentsValue ||
typeof argumentsValue !== 'object' ||
@@ -935,7 +936,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
if (!isWebTool) {
const approval =
this.options.toolProvider.getApproval(
run.toolProvider.getApproval(
tool,
argumentsValue as Record<string, unknown>,
argumentSummary,
@@ -950,7 +951,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
)
}
}
const result = await this.options.toolProvider.callTool(
const result = await run.toolProvider.callTool(
name,
argumentsValue as Record<string, unknown>,
run.toolController.signal,
@@ -1091,6 +1092,35 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
async getStatus(): Promise<AgentRuntimeStatus> {
return {
id: 'deepseek-harness',
label: 'DeepSeek Harness',
available: !this.disposed && !this.fatalError,
supportsToolExecution: true,
detail: this.disposed
? 'DeepSeek Harness Runtime 已关闭'
: this.fatalError?.message ??
(this.state
? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限`
: 'DeepSeek Harness 已配置,将在首次使用时启动')
}
}
get hasRetainedSessions(): boolean {
return this.sessions.size > 0 || this.sessionInitializations.size > 0
}
private toolsFor(workspace: string): ModelToolProviderLike | undefined {
if (!this.options.createToolProvider) return this.options.toolProvider
let provider = this.workspaceTools.get(workspace)
if (!provider) {
provider = this.options.createToolProvider(workspace)
this.workspaceTools.set(workspace, provider)
}
return provider
}
async testConnection(): Promise<AgentRuntimeStatus> {
try {
await this.getState()
return {
@@ -1114,10 +1144,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
}
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
async getNativeSnapshot(workspace?: string): Promise<RuntimeNativeSnapshot> {
const state = await this.getState()
const response = await withTimeout(
state.agent.extMethod(GOODBUDDY_NATIVE_SNAPSHOT, {}),
state.agent.extMethod(
GOODBUDDY_NATIVE_SNAPSHOT,
workspace ? { workspace } : {}
),
this.initializationTimeoutMs,
'原生能力清单'
)
@@ -1302,11 +1335,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private async getSession(
state: HarnessState,
conversationId: string
conversationId: string,
workspace: string
): Promise<string> {
const current = this.sessions.get(conversationId)
if (current?.workspace === workspace) {
return current.id
}
if (current) {
return current
await this.releaseConversation(conversationId)
}
const pending = this.sessionInitializations.get(conversationId)
if (pending) {
@@ -1314,14 +1351,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
const creation = state.agent
.newSession({
cwd: state.workspace,
cwd: workspace,
mcpServers: []
})
.then((response) => {
if (!response.sessionId) {
throw new Error('DeepSeek Harness 未返回 ACP 会话 ID')
}
this.sessions.set(conversationId, response.sessionId)
this.sessions.set(conversationId, { id: response.sessionId, workspace })
return response.sessionId
})
this.sessionInitializations.set(conversationId, creation)
@@ -1445,12 +1482,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
try {
state = await this.getState()
signal.throwIfAborted()
const workspace = request.executionWorkspace
? await realpath(request.executionWorkspace)
: state.workspace
sessionId = await this.getSession(
state,
request.conversationId
request.conversationId,
workspace
)
run = {
request,
toolProvider: this.toolsFor(workspace),
toolController,
authorize,
updates: [],
@@ -1586,7 +1628,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.shutdownTimeoutMs,
'取消请求'
).catch(async () => {
await this.terminate(state!.child)
await withTimeout(
state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }),
this.shutdownTimeoutMs,
'释放已取消会话'
).catch(async () => {
await this.terminate(state!.child)
})
await this.forgetSession(request.conversationId)
})
}
}
@@ -1609,25 +1658,39 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
async releaseConversation(conversationId: string): Promise<void> {
const sessionId = this.sessions.get(conversationId)
this.sessions.delete(conversationId)
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
if (!sessionId || !this.state) {
await this.options.toolProvider
?.releaseConversation(conversationId)
.catch(() => undefined)
return
await this.sessionInitializations.get(conversationId)?.catch(() => undefined)
const session = this.sessions.get(conversationId)
const sessionId = session?.id
if (sessionId && this.state) {
await this.state.agent
.extMethod(GOODBUDDY_RELEASE, { sessionId })
.catch(async () => {
await this.state?.agent
.cancel({ sessionId })
.catch(() => undefined)
})
}
await this.state.agent
.extMethod(GOODBUDDY_RELEASE, { sessionId })
.catch(async () => {
await this.state?.agent
.cancel({ sessionId })
.catch(() => undefined)
})
await this.options.toolProvider
await this.forgetSession(conversationId)
}
private async forgetSession(conversationId: string): Promise<void> {
const session = this.sessions.get(conversationId)
const provider = session
? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider
: this.options.toolProvider
this.sessions.delete(conversationId)
if (session) this.proxyToolCatalogs.delete(session.id)
await provider
?.releaseConversation(conversationId)
.catch(() => undefined)
if (
session && provider &&
this.workspaceTools.get(session.workspace) === provider &&
![...this.sessions.values()].some((other) => other.workspace === session.workspace)
) {
this.workspaceTools.delete(session.workspace)
await provider.dispose()
}
}
async dispose(): Promise<void> {
@@ -1662,8 +1725,16 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.proxyToolCatalogs.clear()
this.sessionInitializations.clear()
this.conversationTails.clear()
const providers = new Set([
...this.workspaceTools.values(),
...(this.options.toolProvider ? [this.options.toolProvider] : [])
])
this.workspaceTools.clear()
const disposeTools = () => Promise.allSettled(
[...providers].map((provider) => provider.dispose())
)
if (!state) {
await this.options.toolProvider?.dispose().catch(() => undefined)
await disposeTools()
return
}
await withTimeout(
@@ -1671,7 +1742,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.shutdownTimeoutMs,
'内部控制面关闭'
).catch(() => undefined)
await this.options.toolProvider?.dispose().catch(() => undefined)
await disposeTools()
await this.terminate(state.child)
await withTimeout(
Promise.allSettled([
@@ -926,7 +926,9 @@ export class GoodBuddyHarnessControlPlane {
}
}
private async nativeSnapshot(): Promise<Record<string, unknown>> {
private async nativeSnapshot(
workspace = this.config.workspace
): Promise<Record<string, unknown>> {
const controller = new AbortController()
const timer = setTimeout(
() =>
@@ -938,7 +940,7 @@ export class GoodBuddyHarnessControlPlane {
try {
const skills = await Promise.race([
this.ctx.skills.list({
cwd: this.config.workspace,
cwd: workspace,
signal: controller.signal
}),
new Promise<never>((_resolve, reject) => {
@@ -1132,12 +1134,11 @@ export class GoodBuddyHarnessControlPlane {
}
if (
!isAbsolute(params.cwd) ||
params.cwd !== this.config.workspace ||
params.mcpServers.length > 0
) {
throw RequestError.invalidParams(
undefined,
'the controlled workspace and no MCP servers are required'
'an absolute workspace and no MCP servers are required'
)
}
const sessionId = SessionId(randomUUID())
@@ -1393,7 +1394,11 @@ export class GoodBuddyHarnessControlPlane {
return { released: true }
}
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
return this.nativeSnapshot()
const workspace = params.workspace === undefined
? this.config.workspace
: requiredString(params, 'workspace')
if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute')
return this.nativeSnapshot(workspace)
}
if (method === GOODBUDDY_SHUTDOWN) {
await this.dispose()
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
import { LocalRuntimeRegistry } from './local-runtime-registry'
import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime'
function fixture() {
const requests: AgentExecutionRequest[] = []
const sessions = new Set<string>()
const runtime: AgentRuntime & { readonly hasRetainedSessions: boolean } = {
runtimeId: 'opencode',
requiresToolApproval: false,
supportsToolExecution: true,
get hasRetainedSessions() { return sessions.size > 0 },
getStatus: vi.fn(async () => ({
id: 'opencode' as const, label: 'OpenCode', available: true,
supportsToolExecution: true, detail: 'ready'
})),
async *run(request) {
requests.push(request)
sessions.add(request.conversationId)
yield { requestId: request.requestId, type: 'done' }
},
releaseConversation: vi.fn(async (id) => { sessions.delete(id) }),
dispose: vi.fn(async () => { sessions.clear() })
}
return { runtime, requests, sessions }
}
async function run(runtime: AgentRuntime, id: string) {
const events: RuntimeEvent[] = []
for await (const event of runtime.run({
requestId: id, conversationId: id, prompt: 'continue', workMode: 'execute'
}, new AbortController().signal)) events.push(event)
return events
}
describe('LocalRuntimeRegistry', () => {
it('shares the Runtime but fixes each request to its resolved workspace', async () => {
const registry = new LocalRuntimeRegistry()
const host = fixture()
const create = vi.fn(() => host.runtime)
const first = registry.acquire('opencode:profile', { model: 'one' }, '/first', create)
const second = registry.acquire('opencode:profile', { model: 'one' }, '/second', create)
await Promise.all([run(first, 'a'), run(second, 'b')])
expect(create).toHaveBeenCalledOnce()
expect(host.requests.map((request) => request.executionWorkspace).sort()).toEqual(['/first', '/second'])
await first.dispose()
expect(host.runtime.dispose).not.toHaveBeenCalled()
await run(second, 'b')
await registry.releaseConversation('a')
expect(host.sessions).toEqual(new Set(['b']))
await registry.dispose()
expect(host.runtime.dispose).toHaveBeenCalledOnce()
})
it('closes a no-session idle host and recreates it through an existing facade', async () => {
vi.useFakeTimers()
const registry = new LocalRuntimeRegistry(100)
const first = fixture()
const second = fixture()
const create = vi.fn().mockReturnValueOnce(first.runtime).mockReturnValueOnce(second.runtime)
try {
const facade = registry.acquire('opencode:profile', {}, '/first', create)
await facade.getStatus()
await vi.advanceTimersByTimeAsync(100)
expect(first.runtime.dispose).toHaveBeenCalledOnce()
await run(facade, 'a')
await vi.advanceTimersByTimeAsync(1_000)
expect(second.runtime.dispose).not.toHaveBeenCalled()
await registry.releaseConversation('a')
await vi.advanceTimersByTimeAsync(100)
expect(second.runtime.dispose).toHaveBeenCalledOnce()
} finally {
await registry.dispose()
vi.useRealTimers()
}
})
it('drains an old configuration without closing a parallel replacement', async () => {
const registry = new LocalRuntimeRegistry()
const old = fixture()
let release!: () => void
const pending = new Promise<void>((resolve) => { release = resolve })
old.runtime.run = async function* (request) {
await pending
yield { requestId: request.requestId, type: 'done' }
}
old.runtime.respondToQuestion = vi.fn(async () => undefined)
const original = registry.acquire('opencode:profile', { model: 'old' }, '/one', () => old.runtime)
const active = run(original, 'a')
await Promise.resolve()
const next = fixture()
const replacement = registry.acquire('opencode:profile', { model: 'new' }, '/two', () => next.runtime)
expect(old.runtime.dispose).not.toHaveBeenCalled()
await original.respondToQuestion?.('active-question', [])
expect(old.runtime.respondToQuestion).toHaveBeenCalledWith('active-question', [])
await run(replacement, 'b')
release()
await active
await vi.waitFor(() => expect(old.runtime.dispose).toHaveBeenCalledOnce())
expect(next.runtime.dispose).not.toHaveBeenCalled()
await registry.dispose()
})
it('does not renew or reacquire an idle owner during passive status polling', async () => {
vi.useFakeTimers()
const registry = new LocalRuntimeRegistry(100)
const host = fixture()
const inspection = fixture()
const restarted = fixture()
const create = vi.fn()
.mockReturnValueOnce(host.runtime)
.mockReturnValueOnce(inspection.runtime)
.mockReturnValueOnce(restarted.runtime)
try {
const facade = registry.acquire('opencode:profile', {}, '/first', create)
for (let poll = 0; poll < 3; poll++) {
await vi.advanceTimersByTimeAsync(30)
expect(await facade.getStatus()).toMatchObject({ available: true })
}
expect(host.runtime.dispose).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10)
expect(host.runtime.dispose).toHaveBeenCalledOnce()
expect(create).toHaveBeenCalledOnce()
await facade.getStatus()
expect(inspection.runtime.dispose).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(200)
expect(create).toHaveBeenCalledTimes(2)
expect(restarted.runtime.dispose).not.toHaveBeenCalled()
await run(facade, 'a')
expect(create).toHaveBeenCalledTimes(3)
expect(restarted.sessions).toEqual(new Set(['a']))
} finally {
await registry.dispose()
vi.useRealTimers()
}
})
})
+282
View File
@@ -0,0 +1,282 @@
import { isDeepStrictEqual } from 'node:util'
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
import type {
AgentQuestionAnswer,
RuntimeConversationCompactInput
} from '../../shared/contracts'
const IDLE_RUNTIME_MS = 60_000
type LocalProcessRuntime = AgentRuntime & {
readonly hasRetainedSessions: boolean
}
class LocalRuntimeEntry {
private instance?: LocalProcessRuntime
private closing?: Promise<void>
private active = 0
private retired = false
private idle?: ReturnType<typeof setTimeout>
readonly description: Pick<AgentRuntime,
'runtimeId' | 'requiresToolApproval' | 'supportsToolExecution' |
'supportsScopedDataTools' | 'capability'>
constructor(
readonly configuration: unknown,
private readonly create: () => LocalProcessRuntime,
private readonly idleMs: number,
private readonly onClosed: () => void
) {
const runtime = this.instance = create()
this.description = {
runtimeId: runtime.runtimeId,
requiresToolApproval: runtime.requiresToolApproval,
supportsToolExecution: runtime.supportsToolExecution,
supportsScopedDataTools: runtime.supportsScopedDataTools,
capability: runtime.capability
}
this.scheduleIdle()
}
private clearIdle(): void {
if (this.idle) clearTimeout(this.idle)
this.idle = undefined
}
private scheduleIdle(): void {
this.clearIdle()
if (this.active > 0) return
if (this.retired) {
void this.close().finally(this.onClosed).catch(() => undefined)
} else if (!this.instance?.hasRetainedSessions) {
this.idle = setTimeout(() => {
this.idle = undefined
void this.close().catch(() => undefined)
}, this.idleMs)
this.idle.unref?.()
}
}
private close(): Promise<void> {
this.clearIdle()
if (this.closing) return this.closing
const runtime = this.instance
this.instance = undefined
const closing = Promise.resolve().then(() => runtime?.dispose()).finally(() => {
if (this.closing === closing) this.closing = undefined
})
this.closing = closing
return closing
}
async enter(): Promise<AgentRuntime> {
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
this.clearIdle()
this.active += 1
try {
await this.closing
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
return this.instance ??= this.create()
} catch (error) {
this.leave()
throw error
}
}
leave(): void {
this.active -= 1
this.scheduleIdle()
}
async invoke<T>(operation: (runtime: AgentRuntime) => Promise<T>): Promise<T> {
const runtime = await this.enter()
try {
return await operation(runtime)
} finally {
this.leave()
}
}
async getStatus() {
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
if (this.instance) return this.instance.getStatus()
// Status inspection must not acquire or extend the execution owner's
// lifetime. Both local runtimes can inspect configuration without starting.
const inspection = this.create()
try {
return await inspection.getStatus()
} finally {
await inspection.dispose()
}
}
async respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]): Promise<void> {
const runtime = this.instance
if (!runtime?.respondToQuestion) throw new Error('当前 Runtime 不支持回答交互式问题')
// An active request may finish on a retired configuration, including its
// outstanding native question. This does not admit a new request.
this.clearIdle()
this.active += 1
try {
await runtime.respondToQuestion(questionId, answers)
} finally {
this.leave()
}
}
async releaseConversation(conversationId: string): Promise<void> {
const runtime = this.instance
if (!runtime) return
this.clearIdle()
this.active += 1
try {
await runtime.releaseConversation?.(conversationId)
} finally {
this.leave()
}
}
retire(): void {
this.retired = true
this.scheduleIdle()
}
async dispose(): Promise<void> {
this.retired = true
await this.close()
this.onClosed()
}
}
/** Main-owned local processes; workspace facades never own their lifetime. */
export class LocalRuntimeRegistry {
private readonly current = new Map<string, LocalRuntimeEntry>()
private readonly entries = new Set<LocalRuntimeEntry>()
private disposed = false
constructor(private readonly idleMs = IDLE_RUNTIME_MS) {}
acquire(
key: string,
configuration: unknown,
workspace: string,
create: () => LocalProcessRuntime
): AgentRuntime {
if (this.disposed) throw new Error('Runtime 正在关闭')
let entry = this.current.get(key)
if (entry && !isDeepStrictEqual(entry.configuration, configuration)) {
this.current.delete(key)
entry.retire()
entry = undefined
}
if (!entry) {
const created = new LocalRuntimeEntry(configuration, create, this.idleMs, () => {
this.entries.delete(created)
if (this.current.get(key) === created) this.current.delete(key)
})
this.current.set(key, created)
this.entries.add(created)
entry = created
}
return new WorkspaceRuntime(entry, workspace)
}
async releaseConversation(conversationId: string): Promise<void> {
await Promise.all([...this.entries].map((entry) => entry.releaseConversation(conversationId)))
}
reset(keep?: AgentRuntime): void {
const retained = keep instanceof WorkspaceRuntime ? keep.owner : undefined
for (const [key, entry] of this.current) {
if (entry === retained) continue
this.current.delete(key)
entry.retire()
}
}
async dispose(): Promise<void> {
this.disposed = true
this.current.clear()
await Promise.all([...this.entries].map((entry) => entry.dispose()))
}
}
class WorkspaceRuntime implements AgentRuntime {
readonly sharedProcess = true
private disposed = false
constructor(
private readonly entry: LocalRuntimeEntry,
private readonly workspace: string
) {}
get owner(): LocalRuntimeEntry { return this.entry }
get runtimeId() { return this.entry.description.runtimeId }
get requiresToolApproval() { return this.entry.description.requiresToolApproval }
get supportsToolExecution() { return this.entry.description.supportsToolExecution }
get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools }
get capability() { return this.entry.description.capability }
private check(): void {
if (this.disposed) throw new Error('Runtime 工作区已释放')
}
getStatus() {
this.check()
return this.entry.getStatus()
}
testConnection() {
this.check()
return this.entry.invoke((runtime) => runtime.testConnection?.() ?? runtime.getStatus())
}
getNativeSnapshot() {
this.check()
return this.entry.invoke((runtime) => {
if (!runtime.getNativeSnapshot) throw new Error('当前 Runtime 不支持原生能力清单')
return runtime.getNativeSnapshot(this.workspace)
})
}
async *run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
this.check()
signal.throwIfAborted()
const runtime = await this.entry.enter()
try {
yield* runtime.run({ ...request, executionWorkspace: this.workspace }, signal, authorize)
} finally {
this.entry.leave()
}
}
compactConversation(request: RuntimeConversationCompactInput, signal: AbortSignal) {
this.check()
return this.entry.invoke((runtime) => {
if (!runtime.compactConversation) throw new Error('当前 Runtime 不支持手动压缩')
return runtime.compactConversation(request, signal)
})
}
respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]) {
this.check()
return this.entry.respondToQuestion(questionId, answers)
}
releaseConversation(conversationId: string) {
return this.entry.releaseConversation(conversationId)
}
async dispose(): Promise<void> {
this.disposed = true
}
}
+220
View File
@@ -0,0 +1,220 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { expect, it, vi } from 'vitest'
import { createCanvas } from '@napi-rs/canvas'
import { defaultRuntimeSettings } from '../../shared/contracts'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
import { createAgentRuntime } from './create-runtime'
import { LocalRuntimeRegistry } from './local-runtime-registry'
import { OpenCodeRuntime } from './opencode-runtime'
import { SelectedRuntimeManager } from './selected-runtime-manager'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { KnowledgeService } from '../knowledge/knowledge-service'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath)).each([2, 10])('uses one real OpenCode server across %i projects for tools, continuation and compaction', async (projectCount) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-reuse-')))
const directories = Array.from(
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
)
await Promise.all(directories.map(async (directory, index) => {
await mkdir(directory)
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
}))
let modelCalls = 0
const toolResults: unknown[] = []
const capabilityResults: string[] = []
const observedImages = new Set<string>()
const requestImages = new Map<string, string>()
const observedRequestImages = new Map<string, Set<string>>()
const images = directories.slice(0, 2).map((_, index) =>
createCanvas(index + 1, 1).toBuffer('image/png').toString('base64'))
const knowledge = projectCount === 2 ? new KnowledgeService({
databasePath: join(root, 'knowledge.sqlite'), managedRoot: join(root, 'knowledge')
}) : undefined
const gateway = knowledge ? new KnowledgeMcpGateway(knowledge) : undefined
await knowledge?.initialize()
await gateway?.start()
const grants = gateway ? vi.spyOn(gateway, 'grantCustomMcp') : undefined
const mcpCalls = gateway ? vi.spyOn(gateway as unknown as {
callCustomMcpTool(token: string, binding: unknown, args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>
}, 'callCustomMcpTool') : undefined
const model = createServer((request, response) => {
void (async () => {
let content = ''
for await (const chunk of request) content += String(chunk)
const body = JSON.parse(content) as {
messages: Array<{ role: string; content: unknown }>
tools?: Array<{ function: { name: string } }>
}
modelCalls++
let userIndex = -1
body.messages.forEach((message, index) => { if (message.role === 'user') userIndex = index })
const results = body.messages.slice(userIndex + 1).filter((message) => message.role === 'tool')
const result = results.at(-1)
if (result) toolResults.push(result.content)
const resultText = JSON.stringify(results.map(message => message.content))
const marker = resultText.match(/PROJECT_\d+/u)?.[0]
const read = body.tools?.some((tool) => tool.function.name === 'read')
let tool: { name: string; arguments: Record<string, unknown> } | undefined =
read && !result ? { name: 'read', arguments: { filePath: 'marker.txt' } } : undefined
if (projectCount === 2 && read) {
const requestId = JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1]
for (const image of images) {
if (JSON.stringify(body.messages).includes(image)) {
observedImages.add(image)
if (requestId) {
const seen = observedRequestImages.get(requestId) ?? new Set<string>()
seen.add(image)
observedRequestImages.set(requestId, seen)
}
}
}
if (results.length === 1) {
tool = { name: 'skill', arguments: { name: 'web-3d-game' } }
} else if (results.length === 2) {
const name = body.tools?.find(item => item.function.name.endsWith('_create_game_blueprint'))?.function.name
if (!name) throw new Error('Shared OpenCode MCP fixture is not exposed')
tool = {
name, arguments: {
theme: 'neon-ruins',
seed: JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1],
targetCount: 5
}
}
} else if (results.length === 3) {
capabilityResults.push(resultText)
}
}
const callTool = tool !== undefined
response.writeHead(200, { 'Content-Type': 'text/event-stream' })
const delta = callTool
? {
role: 'assistant',
tool_calls: [{
index: 0, id: `read-${modelCalls}`, type: 'function',
function: { name: tool!.name, arguments: JSON.stringify(tool!.arguments) }
}]
}
: { role: 'assistant', content: marker ?? 'CONTEXT_SUMMARY' }
for (const [value, finish] of [[delta, null], [{}, callTool ? 'tool_calls' : 'stop']]) {
response.write(`data: ${JSON.stringify({
id: `reuse-${modelCalls}`, object: 'chat.completion.chunk', created: 1,
model: 'reuse', choices: [{ index: 0, delta: value, finish_reason: finish }]
})}\n\n`)
}
response.end('data: [DONE]\n\n')
})().catch(() => response.destroy())
})
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
const port = (model.address() as { port: number }).port
const profile = {
id: '00000000-0000-4000-8000-000000000008',
name: 'Reuse fixture', modelName: 'reuse', baseUrl: `http://127.0.0.1:${port}/v1`,
protocol: 'openai-chat-completions' as const, authentication: 'none' as const,
supportsImageInput: projectCount === 2
}
const settings = {
...defaultRuntimeSettings, provider: 'opencode', opencodeBinaryPath: binaryPath,
modelProfiles: [profile], opencodeModelProfile: profile
} as ResolvedRuntimeSettings
const registry = new LocalRuntimeRegistry()
const resolver = new ExecutionSpaceResolver()
const create = vi.fn(async (_selection, executionSpace) => createAgentRuntime(root, settings, {
executionSpace, localRuntimeRegistry: registry, knowledgeGateway: gateway,
...(projectCount === 2 ? {
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
mcpServers: [{
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
enabled: true, allowDynamicTools: false, assignments: ['opencode' as const],
secretConfigured: false, transport: 'stdio' as const,
command: process.versions.electron ? 'node' : process.execPath,
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
}]
} : {})
}))
const manager = new SelectedRuntimeManager(create, 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
const connections = vi.spyOn(OpenCodeRuntime.prototype, 'testConnection')
const launches = vi.spyOn(
OpenCodeRuntime.prototype as unknown as { launchEmbedded(): Promise<unknown> },
'launchEmbedded'
)
const nativeSessions = new Set<string>()
try {
const selection = { provider: 'opencode' as const }
const facades = await Promise.all(directories.map((directory) =>
manager.getRuntime(selection, resolver.resolveLocal(directory))
))
for (let wave = 0; wave < 2; wave++) {
await Promise.all(facades.flatMap((runtime, project) => [0, 1].map(async (conversation) => {
let output = ''
const requestId = crypto.randomUUID()
if (projectCount === 2 && wave === 0) requestImages.set(requestId, images[project]!)
for await (const event of runtime.run({
requestId, conversationId: `project-${project}-${conversation}`,
prompt: `Read marker.txt for wave ${wave}. REQUEST_${requestId}`, workMode: 'execute',
...(projectCount === 2 && wave === 0 ? { images: [{
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!
}] } : {})
}, AbortSignal.timeout(40_000))) {
if (event.type === 'text') output += event.delta
if (event.type === 'done' && event.sessionId) nativeSessions.add(event.sessionId)
}
expect(output, JSON.stringify(toolResults)).toBe(`PROJECT_${project}`)
})))
}
expect(nativeSessions.size).toBe(projectCount * 2)
if (projectCount === 2) {
expect(observedImages.size).toBe(2)
for (const [requestId, image] of requestImages) {
expect(observedRequestImages.get(requestId)).toEqual(new Set([image]))
}
expect(capabilityResults).toHaveLength(8)
for (const result of capabilityResults) {
expect(result).toContain('window.__GOODBUDDY_GAME__')
expect(result).toContain('Prism Relay')
}
expect(mcpCalls).toHaveBeenCalledTimes(8)
for (const [token, , args] of mcpCalls!.mock.calls) {
const grantIndex = grants!.mock.results.findIndex(result => result.value === token)
expect(grantIndex).toBeGreaterThanOrEqual(0)
expect(args.seed).toBe(grants!.mock.calls[grantIndex]![0])
}
}
for (let index = 0; index < facades.length; index++) {
await expect(facades[index]!.compactConversation({
requestId: crypto.randomUUID(), conversationId: `project-${index}-0`,
runtimeSelection: selection, history: [], historyMessageIds: []
}, AbortSignal.timeout(30_000))).resolves.toMatchObject({ result: { compacted: true } })
}
await Promise.all(facades.map((runtime) => runtime.testConnection()))
// Every facade invokes the same process-owning Runtime, even beyond capacity 1.
expect(new Set(connections.mock.instances).size).toBe(1)
expect(launches).toHaveBeenCalledOnce()
for (const [index, directory] of directories.entries()) {
expect(await readFile(join(directory, 'marker.txt'), 'utf8')).toBe(`PROJECT_${index}`)
}
await manager.releaseConversation('project-0-0')
} finally {
connections.mockRestore()
launches.mockRestore()
mcpCalls?.mockRestore()
grants?.mockRestore()
await manager.dispose()
await registry.dispose()
await gateway?.dispose()
await knowledge?.dispose()
model.closeAllConnections()
await new Promise<void>((resolve) => model.close(() => resolve()))
await rm(root, { recursive: true, force: true })
}
}, 120_000)
+25
View File
@@ -1692,6 +1692,31 @@ describe("OpenCodeRuntime embedded launcher", () => {
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
});
it("keeps a shared cold start alive when only its first waiter cancels", async () => {
const child = fakeChild();
let healthy = false;
const { deps, spawnMock } = dependencies(child, {
startupTimeoutMs: 2_000,
checkServerHealth: vi.fn(async () => healthy),
});
const runtime = new OpenCodeRuntime(options(), deps);
const controller = new AbortController();
const pending = runtime.run({
requestId: "cancel-initializer", conversationId: "first",
prompt: "test", executionWorkspace: resolve("one"),
}, controller.signal).next();
const rejected = expect(pending).rejects.toThrow("启动已取消");
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
const peer = runtime.testConnection();
controller.abort();
await rejected;
expect(child.kill).not.toHaveBeenCalled();
healthy = true;
await expect(peer).resolves.toMatchObject({ available: true });
expect(spawnMock).toHaveBeenCalledOnce();
await runtime.dispose();
});
it("bounds session setup without imposing a full-run deadline", async () => {
const harness = runClient([]);
(
+64 -29
View File
@@ -729,7 +729,9 @@ export class OpenCodeRuntime implements AgentRuntime {
private clientInitialization?: Promise<OpencodeClient>;
private server?: OpenCodeServer;
private startingChild?: SpawnedProcess;
private readonly sessions = new Map<string, string>();
private readonly sessions = new Map<string, { id: string; directory: string }>();
private initializationController?: AbortController;
private initializationWaiters = 0;
private readonly sessionInitializations = new Map<string, Promise<string>>();
private readonly pendingQuestions = new Map<
string,
@@ -761,6 +763,10 @@ export class OpenCodeRuntime implements AgentRuntime {
};
}
get hasRetainedSessions(): boolean {
return this.sessions.size > 0 || this.sessionInitializations.size > 0;
}
private usesEmbeddedPermissionMediation(): boolean {
return this.options.embedded && !this.options.baseUrl;
}
@@ -1276,19 +1282,38 @@ export class OpenCodeRuntime implements AgentRuntime {
if (this.client && !this.server) {
return this.client;
}
const existingInitialization = this.clientInitialization;
const cached = Boolean(this.client && this.server);
// Cached health checks are shared, but each caller owns only its wait.
const initialization = this.clientInitialization ??= this.initializeClient(
cached ? undefined : signal,
).finally(() => {
if (this.clientInitialization === initialization) {
this.clientInitialization = undefined;
this.initializationWaiters += 1;
if (!this.clientInitialization) {
const controller = this.initializationController = new AbortController();
const initialization = this.initializeClient(
cached ? undefined : controller.signal,
).finally(() => {
if (this.clientInitialization === initialization) {
this.clientInitialization = undefined;
this.initializationController = undefined;
}
});
this.clientInitialization = initialization;
}
try {
return signal
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization;
} catch (error) {
if (signal?.aborted && !cached && !this.client) {
throw new Error("OpenCode Server 启动已取消", { cause: error });
}
});
return signal && (cached || existingInitialization)
? await awaitWithAbort(initialization, signal)
: await initialization;
throw error;
} finally {
this.initializationWaiters -= 1;
if (this.initializationWaiters === 0 && !this.client) {
this.initializationController?.abort(
new Error("OpenCode 初始化已无等待请求"),
);
await this.clientInitialization?.catch(() => undefined);
}
}
}
private async controlRequest<T>(
@@ -1460,6 +1485,7 @@ export class OpenCodeRuntime implements AgentRuntime {
private async discoverAgents(
client: OpencodeClient,
signal?: AbortSignal,
directory = this.options.defaultWorkspace,
): Promise<
Array<{
id: string;
@@ -1475,7 +1501,7 @@ export class OpenCodeRuntime implements AgentRuntime {
(controlSignal) =>
client.app.agents(
{
directory: this.options.defaultWorkspace,
directory,
},
{ signal: controlSignal },
),
@@ -1521,7 +1547,9 @@ export class OpenCodeRuntime implements AgentRuntime {
if (!this.supportsNativeCustomization()) {
throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent");
}
const agents = await this.discoverAgents(client, signal);
const agents = await this.discoverAgents(
client, signal, request.executionWorkspace ?? this.options.defaultWorkspace,
);
if (
!agents.some(
(agent) =>
@@ -1537,7 +1565,9 @@ export class OpenCodeRuntime implements AgentRuntime {
return selected;
}
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
async getNativeSnapshot(
directory = this.options.defaultWorkspace,
): Promise<RuntimeNativeSnapshot> {
const controlled = this.supportsNativeCustomization();
const empty: RuntimeNativeSnapshot = {
provider: "opencode",
@@ -1572,7 +1602,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const response = await this.controlRequest("连接检查", (signal) =>
client.session.list(
{
directory: this.options.defaultWorkspace,
directory,
limit: 1,
},
{ signal },
@@ -1602,7 +1632,6 @@ export class OpenCodeRuntime implements AgentRuntime {
};
}
const directory = this.options.defaultWorkspace;
const assignedSkillIds = new Set(
(this.options.skillPackages ?? []).map((skill) => skill.id),
);
@@ -1626,7 +1655,7 @@ export class OpenCodeRuntime implements AgentRuntime {
{ signal },
),
),
this.discoverAgents(client),
this.discoverAgents(client, undefined, directory),
this.controlRequest("读取工具清单", (signal) =>
client.tool.ids({ directory }, { signal }),
),
@@ -1914,8 +1943,11 @@ export class OpenCodeRuntime implements AgentRuntime {
permission?: PermissionRuleset,
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(request.conversationId);
if (current?.directory === directory) {
return { id: current.id, created: false };
}
if (current) {
return { id: current, created: false };
await this.releaseConversation(request.conversationId);
}
const pending = this.sessionInitializations.get(request.conversationId);
if (pending) {
@@ -1959,7 +1991,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const stillCurrent =
this.sessionInitializations.get(request.conversationId) === creation;
if (signal.aborted || !stillCurrent) {
if (this.sessions.get(request.conversationId) !== sessionId) {
if (this.sessions.get(request.conversationId)?.id !== sessionId) {
void client.session
.delete(
{
@@ -1975,7 +2007,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
throw new Error("OpenCode 会话初始化已失效");
}
this.sessions.set(request.conversationId, sessionId);
this.sessions.set(request.conversationId, { id: sessionId, directory });
return sessionId;
});
this.sessionInitializations.set(request.conversationId, creation);
@@ -2072,7 +2104,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal: AbortSignal,
client: OpencodeClient,
): AsyncGenerator<RuntimeEvent, void, void> {
const directory = this.options.defaultWorkspace;
const directory = resolve(request.executionWorkspace ?? this.options.defaultWorkspace);
const runtimeControl =
request.runtimeControl?.provider === "opencode"
? request.runtimeControl
@@ -3065,7 +3097,8 @@ export class OpenCodeRuntime implements AgentRuntime {
executionSignal = AbortSignal.any([executionSignal, server.signal]);
}
}
const sessionId = this.sessions.get(request.conversationId);
const session = this.sessions.get(request.conversationId);
const sessionId = session?.id;
if (!sessionId || !client) {
return {
result: {
@@ -3076,6 +3109,7 @@ export class OpenCodeRuntime implements AgentRuntime {
},
};
}
const directory = session!.directory;
const context = await this.controlRequest(
"读取原生上下文",
(controlSignal) =>
@@ -3122,7 +3156,7 @@ export class OpenCodeRuntime implements AgentRuntime {
"订阅 Compact 事件流",
(controlSignal) =>
client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{ directory },
eventSubscriptionOptions(controlSignal),
),
subscriptionSignal,
@@ -3162,7 +3196,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const compact = await client.session.summarize(
{
sessionID: sessionId,
directory: this.options.defaultWorkspace,
directory,
providerID: configuredModel.providerID,
modelID: configuredModel.modelID,
auto: false,
@@ -3220,6 +3254,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
async dispose(): Promise<void> {
this.initializationController?.abort(new Error("OpenCode Runtime 已关闭"));
this.pendingQuestions.clear();
const startingChild = this.startingChild;
this.startingChild = undefined;
@@ -3238,16 +3273,16 @@ export class OpenCodeRuntime implements AgentRuntime {
}
async releaseConversation(conversationId: string): Promise<void> {
const sessionId = this.sessions.get(conversationId);
const session = this.sessions.get(conversationId);
this.sessions.delete(conversationId);
if (!sessionId || !this.client) {
if (!session || !this.client) {
return;
}
await this.controlRequest("释放会话", (signal) =>
this.client!.session.delete(
{
sessionID: sessionId,
directory: this.options.defaultWorkspace,
sessionID: session.id,
directory: session.directory,
},
{ signal },
),
+53
View File
@@ -11,6 +11,39 @@ import {
RuntimeReplacementCapacityError
} from './runtime-controller'
it('routes a shared request question to its original Runtime across replacement', async () => {
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const previous: AgentRuntime = {
sharedProcess: true, requiresToolApproval: false, supportsToolExecution: true,
getStatus: vi.fn(), dispose: vi.fn(async () => undefined),
respondToQuestion: vi.fn(async () => undefined),
async *run(request) {
yield { type: 'question', requestId: request.requestId, questionId: 'question-old', questions: [] }
await gate
yield { type: 'done', requestId: request.requestId }
}
}
const next: AgentRuntime = { ...previous, respondToQuestion: vi.fn(async () => undefined) }
const controller = new AgentRuntimeController(previous, 1)
const stream = controller.run({
requestId: 'old-request', conversationId: 'old-conversation', prompt: 'test', workMode: 'execute'
}, new AbortController().signal)
try {
expect((await stream.next()).value?.type).toBe('question')
await controller.replace(next)
await controller.respondToQuestion('question-old', [])
expect(previous.respondToQuestion).toHaveBeenCalledWith('question-old', [])
expect(next.respondToQuestion).not.toHaveBeenCalled()
release()
expect((await stream.next()).value?.type).toBe('done')
} finally {
release()
await stream.return()
await controller.dispose()
}
})
class TestRuntime implements AgentRuntime {
readonly dispose = vi.fn(async () => {})
readonly started: Promise<void>
@@ -176,6 +209,26 @@ describe('AgentRuntimeController', () => {
await controller.dispose()
})
it('lets a shared local request finish while replacing its workspace facade', async () => {
const previous = Object.assign(new TestRuntime(true), { sharedProcess: true })
const controller = new AgentRuntimeController(previous, 1)
const stream = controller.run({
requestId: '00000000-0000-4000-8000-000000000071',
conversationId: 'shared-replacement', prompt: 'continue', workMode: 'ask'
}, new AbortController().signal)
const pending = stream.next()
await previous.started
await controller.replace(new TestRuntime())
expect(previous.dispose).not.toHaveBeenCalled()
previous.finish()
await expect(pending).resolves.toMatchObject({
done: false, value: { type: 'text' }
})
await stream.next()
expect(previous.dispose).toHaveBeenCalledOnce()
await controller.dispose()
})
it('drains remote sessions without interrupting their active prompt', async () => {
const previous = new DrainableTestRuntime()
const next = new TestRuntime()
+19 -6
View File
@@ -80,6 +80,7 @@ export class AgentRuntimeController implements AgentRuntime {
private retirement?: Promise<void>
private disposal?: Promise<void>
private readonly ownedConversationIds = new Set<string>()
private readonly questionOwners = new Map<string, { slot: RuntimeSlot; requestId: string }>()
constructor(
runtime: AgentRuntime,
@@ -147,11 +148,15 @@ export class AgentRuntimeController implements AgentRuntime {
return this.ownedConversationIds.size
}
get sharedProcess(): boolean {
return this.current.runtime.sharedProcess === true
}
get canRetire(): boolean {
return (
!this.closing &&
this.activeRequestCount === 0 &&
this.ownedConversationIds.size === 0
(this.sharedProcess || this.ownedConversationIds.size === 0)
)
}
@@ -269,7 +274,7 @@ export class AgentRuntimeController implements AgentRuntime {
slot.activeRequests += 1
try {
const status = await operation(slot.runtime)
if (slot !== this.current) {
if (slot !== this.current && !(slot.runtime.sharedProcess && stage === 'compact')) {
throw new Error('Runtime 已切换,请重试')
}
return status
@@ -347,13 +352,16 @@ export class AgentRuntimeController implements AgentRuntime {
signal,
effectiveAuthorize
)) {
if (slot !== this.current && !slot.drainable) {
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
throw new Error('Runtime 已切换,当前请求已中断')
}
toolDenied = false
if (event.type === 'question') {
this.questionOwners.set(event.questionId, { slot, requestId: request.requestId })
}
yield event
}
if (slot !== this.current && !slot.drainable) {
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
throw new Error('Runtime 已切换,当前请求已中断')
}
} catch (error) {
@@ -368,6 +376,9 @@ export class AgentRuntimeController implements AgentRuntime {
throw error
}
} finally {
for (const [questionId, owner] of this.questionOwners) {
if (owner.requestId === request.requestId) this.questionOwners.delete(questionId)
}
slot.activeRequests -= 1
if (
slot.retiring &&
@@ -419,14 +430,16 @@ export class AgentRuntimeController implements AgentRuntime {
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void> {
if (this.closing) {
const owner = this.questionOwners.get(questionId)
if (this.closing && !owner) {
throw new Error('Agent Runtime 正在关闭')
}
const runtime = this.current.runtime
const runtime = (owner?.slot ?? this.current).runtime
if (!runtime.respondToQuestion) {
throw new Error('当前 Runtime 不支持回答交互式问题')
}
await runtime.respondToQuestion(questionId, answers)
this.questionOwners.delete(questionId)
}
private retireSlot(slot: RuntimeSlot): Promise<void> {
+6 -1
View File
@@ -86,6 +86,9 @@ export type RuntimeConversationCompactOutcome = {
}
export interface AgentRuntime {
/** A workspace facade whose sessions are owned by a shared local Runtime. */
readonly sharedProcess?: boolean
readonly hasRetainedSessions?: boolean
readonly runtimeId?: AgentRuntimeStatus['id']
readonly requiresToolApproval: boolean
readonly supportsToolExecution: boolean
@@ -94,7 +97,7 @@ export interface AgentRuntime {
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>
getNativeSnapshot?(): Promise<RuntimeNativeSnapshot>
getNativeSnapshot?(workspace?: string): Promise<RuntimeNativeSnapshot>
compactConversation?(
request: RuntimeConversationCompactInput,
signal: AbortSignal
@@ -132,6 +135,8 @@ export type RemoteRecoveredSubagent =
ConversationSubagentActivity
export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
/** Resolved Main-side workspace, never taken from the current UI selection. */
executionWorkspace?: string
workMode?: WorkMode
images?: AgentImage[]
imageContextNotice?: ConversationMessage['imageContextNotice']
@@ -510,6 +510,44 @@ describe('SelectedRuntimeManager', () => {
await manager.dispose()
})
it('answers owned questions after settings reset while rejecting new work', async () => {
const active = runtime()
active.value.respondToQuestion = vi.fn(async () => undefined)
active.value.run = async function* (request) {
yield {
requestId: request.requestId, type: 'question', questionId: 'retained-question',
questions: [{ header: 'Choice', question: 'Continue?', options: [], multiple: false, custom: true }]
}
yield { requestId: request.requestId, type: 'done' }
}
const manager = new SelectedRuntimeManager(async () => active.value)
const controller = await manager.getRuntime({ provider: 'opencode' }, executionSpace('project-one'))
const stream = controller.run({
requestId: 'retained-request', conversationId: 'retained-conversation',
prompt: 'Ask a question', workMode: 'execute'
}, new AbortController().signal)
try {
expect((await stream.next()).value?.type).toBe('question')
await manager.reset()
expect(active.dispose).not.toHaveBeenCalled()
await expect(controller.respondToQuestion('unknown-question', [['Yes']]))
.rejects.toThrow('正在关闭')
await expect(controller.respondToQuestion('retained-question', [['Yes']]))
.resolves.toBeUndefined()
expect(active.value.respondToQuestion).toHaveBeenCalledWith('retained-question', [['Yes']])
await expect(controller.run({
requestId: 'new-request', conversationId: 'new-conversation',
prompt: 'New work', workMode: 'execute'
}, new AbortController().signal).next()).rejects.toThrow('正在关闭')
expect((await stream.next()).value?.type).toBe('done')
await stream.next()
await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce())
} finally {
await stream.return()
await manager.dispose()
}
})
it('routes application exit through cached runtime controllers without ordinary disposal', async () => {
const active = runtime()
const detachForApplicationExit = vi.fn(() => undefined)
@@ -595,7 +633,8 @@ describe('SelectedRuntimeManager', () => {
await expect(
manager.getRuntime(selection('43'))
).rejects.toThrow(/退役容量/iu)
expect(create).toHaveBeenCalledTimes(2)
expect(create).toHaveBeenCalledTimes(3)
expect(created[2]!.dispose).toHaveBeenCalledOnce()
await manager.dispose()
expect(created[0]!.dispose).toHaveBeenCalledOnce()
expect(created[1]!.dispose).toHaveBeenCalledOnce()
@@ -607,7 +646,10 @@ describe('SelectedRuntimeManager', () => {
finishCreate = resolve
})
const created = runtime()
const create = vi.fn(async () => await pendingCreate)
const rejected = runtime()
const create = vi.fn()
.mockReturnValueOnce(pendingCreate)
.mockResolvedValueOnce(rejected.value)
const manager = new SelectedRuntimeManager(create, 1, 1)
const selection = (suffix: string) => ({
provider: 'model' as const,
@@ -622,7 +664,8 @@ describe('SelectedRuntimeManager', () => {
await expect(
manager.getRuntime(selection('52'))
).rejects.toThrow(/退役容量/iu)
expect(create).toHaveBeenCalledOnce()
expect(create).toHaveBeenCalledTimes(2)
expect(rejected.dispose).toHaveBeenCalledOnce()
finishCreate(created.value)
await expect(firstRuntime).resolves.toEqual(
+18 -16
View File
@@ -76,7 +76,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
private readonly observeFailure?:
DesktopDiagnosticFailureObserver,
private readonly createStatusRuntime = createRuntime
private readonly createStatusRuntime = createRuntime,
private readonly releaseSharedConversation?: (conversationId: string) => Promise<void>
) {
if (
!Number.isSafeInteger(maximumCachedRuntimes) ||
@@ -111,21 +112,6 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
return existing.operation
}
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 (
@@ -135,6 +121,21 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
await runtime.dispose()
throw new Error('Runtime 设置已更改,请重新选择')
}
if (!runtime.sharedProcess) {
const currentCount = [...this.entries.values()].filter(
(candidate) => candidate !== entry && !candidate.controller?.sharedProcess
).length
const retiredCount = [...this.retiringControllers].filter(
(candidate) => !candidate.sharedProcess
).length + this.pendingRetirementControllers
if (
currentCount >= this.maximumCachedRuntimes ||
currentCount + retiredCount >= this.maximumRetainedRuntimes
) {
await runtime.dispose()
throw new Error('Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话')
}
}
const controller = new AgentRuntimeController(
runtime,
undefined,
@@ -234,6 +235,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
}
async releaseConversation(conversationId: string): Promise<void> {
await this.releaseSharedConversation?.(conversationId)
const controllers = await Promise.allSettled([
...[...this.entries.values()].map(({ operation }) => operation)
])