Files
goodbuddy/src/main/agent/local-runtime-registry.test.ts
T
mesalogo e5aa9ed688 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 需配套更新。
2026-09-15 08:14:42 +08:00

140 lines
5.6 KiB
TypeScript

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()
}
})
})