fix: prevent managed runtime stalls during file changes
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。
This commit is contained in:
mesalogo
2026-09-09 03:07:55 +08:00
parent 177ab2140e
commit 5a77dfdb93
20 changed files with 198 additions and 28 deletions
+2
View File
@@ -48,6 +48,7 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
export type OpenCodeModelBridgeProviderConfig = {
model: string
snapshot: false
permission: 'ask' | 'allow'
agent: {
title: {
@@ -521,6 +522,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
const baseURL = `${origin}/v1`
return {
model: openCodeModelBridgeModelId(input.protocol, model),
snapshot: false,
permission: input.workMode === 'ask' ? 'ask' : 'allow',
// GoodBuddy owns conversation titles. A first-prompt title request can
// race the real ACP prompt, abandon its Provider response, and correctly
+1
View File
@@ -134,6 +134,7 @@ describe('model bridge loopback helper', () => {
expect(options.env.NODE_OPTIONS).toBeUndefined()
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
expect(config.model).toBe('goodbuddy-anthropic/private-model')
expect(config.snapshot).toBe(false)
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
expect(config.plugin).toHaveLength(1)
}
@@ -24,6 +24,19 @@ afterEach(() => {
})
describe('OpenCode direct launch profile', () => {
it.each(['ask', 'execute'] as const)(
'disables unused automatic Git snapshots for %s',
(workMode) => {
const profile = createOpenCodeLaunchProfile({
...createFixture(),
workMode
})
const config = JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
expect(config.snapshot).toBe(false)
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
}
)
it('runs Ask directly in the Workspace', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
@@ -146,6 +146,8 @@ export function createOpenCodeLaunchProfile(input: {
const environment = {
...environmentBase,
OPENCODE_CONFIG_CONTENT: JSON.stringify({
// Native snapshot summaries are not consumed by GoodBuddy.
snapshot: false,
permission,
agent: { build: { permission } },
}),
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import {
createContinueUtilityProcessChild,
@@ -6,11 +7,12 @@ import {
} from './continue-utility-process-adapter'
import { waitForProcessExit } from './child-process-termination'
function createSource(): ContinueUtilityProcessSource & EventEmitter {
function createSource(stdout?: PassThrough): ContinueUtilityProcessSource & EventEmitter {
const emitter =
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
Object.defineProperties(emitter, {
pid: { value: 42 },
stdout: { value: stdout },
stderr: { value: undefined }
})
emitter.kill = vi.fn(() => true)
@@ -33,6 +35,17 @@ function createSource(): ContinueUtilityProcessSource & EventEmitter {
}
describe('Continue utility process adapter', () => {
it('drains unused stdout so native output cannot block the host', async () => {
const stdout = new PassThrough({ highWaterMark: 1024 })
createContinueUtilityProcessChild(createSource(stdout))
expect(stdout.readableFlowing).toBe(true)
await new Promise<void>((resolve) => {
stdout.end(Buffer.alloc(128 * 1024), resolve)
})
expect(stdout.readableLength).toBe(0)
})
it('maps Electron exit to close and completes helper waits immediately', async () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
@@ -8,6 +8,7 @@ type UtilityErrorListener = (
export type ContinueUtilityProcessSource = {
readonly pid?: number
readonly stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null
readonly stderr?: ContinueHostChild['stderr']
kill(): boolean
onExit(listener: (code: number) => void): void
@@ -20,6 +21,8 @@ export type ContinueUtilityProcessSource = {
export function createContinueUtilityProcessChild(
utility: ContinueUtilityProcessSource
): ContinueHostChild {
// Responses arrive over HTTP, but an unread console pipe can block the host.
utility.stdout?.resume()
let exitCode: number | null = null
let killed = false
const closeListeners = new Map<
@@ -71,6 +71,8 @@ it.skipIf(!existsSync(binaryPath))(
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 {
+36
View File
@@ -288,6 +288,41 @@ async function collectRun(
}
describe("OpenCodeRuntime embedded launcher", () => {
it.each(["native", "profile"] as const)(
"disables unused automatic Git snapshots with %s model configuration",
async (configuration) => {
const { deps, spawnMock } = dependencies(fakeChild());
const runtime = new OpenCodeRuntime(
options({
modelProfile: configuration === "profile"
? {
id: "00000000-0000-4000-8000-000000000011",
name: "Test model",
baseUrl: "https://model.example/v1",
modelName: "test-model",
protocol: "openai-responses",
authentication: "none",
supportsImageInput: false,
}
: undefined,
}),
deps,
);
try {
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true,
});
const config = JSON.parse(
spawnMock.mock.calls[0]?.[2]?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
);
expect(config.snapshot).toBe(false);
expect(config.permission["*"]).toBe("allow");
} finally {
await runtime.dispose();
}
},
);
it("forwards retry scheduling and resumes only on a matching native busy event", async () => {
const next = Date.now() + 120_000;
const setup = runClient([
@@ -640,6 +675,7 @@ describe("OpenCodeRuntime embedded launcher", () => {
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
) as Record<string, unknown>;
expect(config).toEqual({
snapshot: false,
skills: {
paths: [join(configDirectory!, "skills")],
urls: [],
+6 -8
View File
@@ -1089,14 +1089,12 @@ export class OpenCodeRuntime implements AgentRuntime {
env.XDG_DATA_HOME = join(registration.root, "xdg-data");
env.XDG_STATE_HOME = join(registration.root, "xdg-state");
const skillConfig = createOpenCodeSkillConfig(registration, skillIds);
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
profile
? {
...createOpenCodeProviderConfig(profile),
...skillConfig,
}
: skillConfig,
);
// GoodBuddy does not use native Git snapshots or their synchronous diffs.
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
...(profile ? createOpenCodeProviderConfig(profile) : {}),
...skillConfig,
snapshot: false,
});
if (!profile && this.options.configPath.trim()) {
env.OPENCODE_CONFIG = resolve(this.options.configPath);
}
+1
View File
@@ -389,6 +389,7 @@ const launchContinueHost: ContinueHostLauncher = (
get pid() {
return utilityChild.pid
},
stdout: utilityChild.stdout,
stderr: utilityChild.stderr,
kill: () => utilityChild.kill(),
onExit: (listener) => {