Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Automatic native Git snapshot diffs could block concurrent OpenCode sessions after broad rewrites or line-ending conversions. Managed desktop and Agent profiles now skip snapshots that GoodBuddy does not use, while preserving file tools, native subagents, workspace Git operations, and Ask/Execute rules. Drain otherwise-unused Continue utility-host stdout to prevent console output from filling the pipe and blocking requests. Correct the lifecycle regression's body observer to watch the underlying reader rather than a buffered wrapper. Prepare Desktop 0.12.10 and Agent 0.11.22 with aligned bilingual notes and feature documentation. Existing runtimes require restart or Agent update. Full desktop UI before/after and Linux Agent production-path checks passed; 3626 tests passed, with 61 existing platform/manual skips. Release note: 修复批量文件改写或换行转换后的托管 OpenCode 并行停顿,以及 Continue 控制台输出阻塞;本机升级重启后生效,远程项目请配套更新 Agent。
139 lines
5.4 KiB
TypeScript
139 lines
5.4 KiB
TypeScript
// @vitest-environment node
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { createServer } from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
|
|
import { expect, it } from 'vitest'
|
|
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
|
import { OpenCodeRuntime } from './opencode-runtime'
|
|
|
|
const binaryPath = join(
|
|
process.cwd(), '.runtime-resources', process.arch,
|
|
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
|
)
|
|
|
|
it.skipIf(!existsSync(binaryPath))(
|
|
'closes real event streams after parallel runs without cancelling a peer',
|
|
async () => {
|
|
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-lifecycle-'))
|
|
let cancelledController: AbortController | undefined
|
|
const model = createServer((request, response) => {
|
|
void (async () => {
|
|
let body = ''
|
|
for await (const chunk of request) body += chunk.toString()
|
|
if (body.includes('CANCEL_LIFECYCLE')) {
|
|
setTimeout(() => cancelledController?.abort(
|
|
new DOMException('Cancelled', 'AbortError')
|
|
), 30)
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
for (const [delta, finishReason] of [
|
|
[{ role: 'assistant', content: 'LIFECYCLE_OK' }, null],
|
|
[{}, 'stop']
|
|
]) {
|
|
response.write(`data: ${JSON.stringify({
|
|
id: 'lifecycle', object: 'chat.completion.chunk', created: 1,
|
|
model: 'lifecycle',
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
})}\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 address = model.address()
|
|
if (!address || typeof address === 'string') throw new Error('No model port')
|
|
const gateway = new KnowledgeMcpGateway({} as never)
|
|
await gateway.start()
|
|
let openEventBodies = 0
|
|
const runtime = new OpenCodeRuntime({
|
|
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
|
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
|
modelProfile: {
|
|
id: crypto.randomUUID(), name: 'Lifecycle fixture', modelName: 'lifecycle',
|
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
|
protocol: 'openai-chat-completions', authentication: 'none'
|
|
}
|
|
}, {
|
|
createClient: (options) => createOpencodeClient({
|
|
...options,
|
|
fetch: async (request, init) => {
|
|
const response = await fetch(request, init)
|
|
const url = request instanceof Request ? request.url : request
|
|
if (new URL(url).pathname !== '/event' || !response.body) return response
|
|
openEventBodies++
|
|
const reader = response.body.getReader()
|
|
let closed = false
|
|
const close = (): void => {
|
|
if (!closed) openEventBodies--
|
|
closed = true
|
|
}
|
|
// The wrapper may stay full after fetch aborts, with no further pull.
|
|
void reader.closed.then(close, close)
|
|
return new Response(new ReadableStream({
|
|
async pull(controller) {
|
|
try {
|
|
const item = await reader.read()
|
|
if (item.done) {
|
|
close()
|
|
controller.close()
|
|
} else controller.enqueue(item.value)
|
|
} catch (error) {
|
|
close()
|
|
controller.error(error)
|
|
}
|
|
},
|
|
async cancel(reason) {
|
|
close()
|
|
await reader.cancel(reason)
|
|
}
|
|
}), { status: response.status, headers: response.headers })
|
|
}
|
|
})
|
|
})
|
|
const run = async (controller = new AbortController(), cancel = false): Promise<string> => {
|
|
const requestId = crypto.randomUUID()
|
|
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
|
|
const deadline = setTimeout(() => controller.abort(), 30_000)
|
|
let text = ''
|
|
try {
|
|
for await (const event of runtime.run({
|
|
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
|
|
prompt: cancel ? 'CANCEL_LIFECYCLE' : 'Reply LIFECYCLE_OK without tools.',
|
|
knowledgeCapabilityToken: token
|
|
}, controller.signal)) {
|
|
if (event.type === 'text') text += event.delta
|
|
}
|
|
return text
|
|
} finally {
|
|
clearTimeout(deadline)
|
|
gateway.revoke(token)
|
|
}
|
|
}
|
|
try {
|
|
for (const concurrency of [1, 4, 8]) {
|
|
const results = await Promise.all(Array.from({ length: concurrency }, () => run()))
|
|
expect(results).toEqual(Array(concurrency).fill('LIFECYCLE_OK'))
|
|
await expect.poll(() => openEventBodies).toBe(0)
|
|
}
|
|
cancelledController = new AbortController()
|
|
const cancelled = run(cancelledController, true)
|
|
await Promise.all([
|
|
expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }),
|
|
expect(run()).resolves.toBe('LIFECYCLE_OK')
|
|
])
|
|
await expect.poll(() => openEventBodies).toBe(0)
|
|
} finally {
|
|
await runtime.dispose()
|
|
await gateway.dispose()
|
|
model.closeAllConnections()
|
|
await new Promise<void>((resolve) => model.close(() => resolve()))
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
},
|
|
120_000
|
|
)
|