fix: stabilize Runtime workflows for 0.12.3
OpenCode status checks could cold-start throwaway servers, unrelated conversations could block each other, and model, Continue, or remote paths could replay or duplicate work under failure and backlog conditions. Settings could also lose edits or overwrite unrelated model fields. This update makes status checks passive, keeps per-conversation ordering while allowing concurrency, preserves native long-running work with bounded display capture and backpressure, narrows retries to known pre-dispatch failures, and aligns remote Ask permissions without reducing Execute. It also keeps Embedding updates narrow, improves settings and browser guidance, removes the unused legacy Main gateway state, and records the validated Desktop 0.12.3 and Agent 0.11.17 release metadata. Release note: 改善 OpenCode 切换、多会话长任务、模型重试、远程 Ask/Execute 与设置保存可靠性,并完成 Desktop 0.12.3 / Agent 0.11.17 发布准备。
This commit is contained in:
@@ -17,7 +17,6 @@ import {
|
||||
} from '../shared/model-request-policy'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import {
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../shared/model-request-customization'
|
||||
@@ -573,7 +572,7 @@ function prepareProviderRequest(
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
const canonicalHeaders = canonicalModelRequestHeaders(headers)
|
||||
const canonicalHeaders = Object.fromEntries(headers.entries())
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (profile.authentication === 'api-key') {
|
||||
headers.set('x-api-key', profile.apiKey!)
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode'
|
||||
],
|
||||
@@ -69,6 +71,7 @@ describe('Agent CLI fixed command contract', () => {
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode: 'ask',
|
||||
opencodeEntrypoint: '/runtime/bin/opencode'
|
||||
})
|
||||
await expect(
|
||||
@@ -83,6 +86,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode',
|
||||
'--api-key',
|
||||
|
||||
@@ -185,6 +185,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -192,6 +193,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
const protocol = options.protocol
|
||||
@@ -206,6 +208,10 @@ async function runModelBridgeHelper(
|
||||
if (imageInput !== 'true' && imageInput !== 'false') {
|
||||
throw new Error('Invalid model bridge image-input option')
|
||||
}
|
||||
const workMode = options['work-mode']
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -214,6 +220,7 @@ async function runModelBridgeHelper(
|
||||
protocol: protocol as ModelBridgeProtocol,
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,10 +44,14 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
permission?: 'ask'
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
build?: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
provider: Record<
|
||||
string,
|
||||
@@ -389,6 +393,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -409,7 +414,8 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -487,6 +493,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
loopbackOrigin: string
|
||||
name?: string
|
||||
supportsImageInput?: boolean
|
||||
workMode?: 'ask' | 'execute'
|
||||
}): OpenCodeModelBridgeProviderConfig {
|
||||
const model = boundedMetadataText(input.model, 'Model name')
|
||||
const name = boundedMetadataText(
|
||||
@@ -500,13 +507,25 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
: {}),
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
// poison the no-replay ledger before the user request can run.
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
},
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
build: {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
provider: {
|
||||
[providerId]: {
|
||||
|
||||
@@ -465,14 +465,19 @@ describe('model bridge loopback helper', () => {
|
||||
name: 'Private model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
supportsImageInput: true
|
||||
supportsImageInput: true,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
model: `${providerId}/private-model`,
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
@@ -500,6 +505,19 @@ describe('model bridge loopback helper', () => {
|
||||
expect(JSON.stringify(config)).not.toContain('provider.example')
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Execute provider permissions unchanged', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config).not.toHaveProperty('permission')
|
||||
expect(config.agent).not.toHaveProperty('build')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model bridge Unix broker', () => {
|
||||
|
||||
@@ -42,6 +42,16 @@ describe('OpenCode direct launch profile', () => {
|
||||
process.env.HOME ?? fixture.workspaceDirectory,
|
||||
PATH: process.env.PATH || '/usr/bin:/bin'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
).toMatchObject({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Execute directly with the SSH account environment', () => {
|
||||
@@ -199,6 +209,8 @@ describe('OpenCode direct launch profile', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
assertAbsoluteManagedPath
|
||||
} from './managed-paths'
|
||||
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type OpenCodeLaunchProfile = {
|
||||
executable: string
|
||||
processExecutable: string
|
||||
@@ -143,16 +152,25 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.model,
|
||||
'--supports-image-input',
|
||||
modelBridge.policy.supportsImageInput ? 'true' : 'false',
|
||||
'--work-mode',
|
||||
input.workMode,
|
||||
'--opencode-entrypoint',
|
||||
executablePath
|
||||
]
|
||||
}
|
||||
|
||||
const environment =
|
||||
input.workMode === 'ask'
|
||||
? {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
|
||||
}
|
||||
: executeEnvironment(process.env, workspaceDirectory)
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
cwd: workspaceDirectory,
|
||||
env: executeEnvironment(process.env, workspaceDirectory),
|
||||
env: environment,
|
||||
workMode: input.workMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
|
||||
mainAckSequence = '0'
|
||||
failAppend = false
|
||||
capacityBlocked = false
|
||||
appendAttempts = 0
|
||||
|
||||
appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } {
|
||||
this.appendAttempts += 1
|
||||
if (this.capacityBlocked) {
|
||||
throw new EventJournalCapacityError(
|
||||
'journal capacity',
|
||||
@@ -1251,6 +1253,34 @@ describe('RuntimeAcpBackend', () => {
|
||||
expect(fixture.outputFrames).toEqual(['later'])
|
||||
})
|
||||
|
||||
it('backs off while durable journal capacity remains unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
fixture,
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation()
|
||||
)
|
||||
fixture.journal.capacityBlocked = true
|
||||
const output = fixture.process.emit('stdout', 'later')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(775)
|
||||
expect(fixture.journal.appendAttempts).toBeLessThanOrEqual(6)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
|
||||
fixture.journal.capacityBlocked = false
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await output
|
||||
|
||||
expect(fixture.outputFrames).toEqual(['later'])
|
||||
await fixture.backend.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops with output quota when the detached durable journal is exhausted', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -83,7 +83,8 @@ const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
|
||||
export type RuntimeAcpBackendErrorCode =
|
||||
| 'capacity'
|
||||
@@ -1976,6 +1977,7 @@ export class RuntimeAcpBackend {
|
||||
try {
|
||||
await this.#enqueueOutput(binding, payload, async () => {
|
||||
const sequence = binding.nextOutputSequence.toString()
|
||||
let retryDelay = ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS
|
||||
while (true) {
|
||||
try {
|
||||
this.#options.journal.appendAcpFrame({
|
||||
@@ -1998,7 +2000,11 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
this.#assertBeforeDeadline(binding)
|
||||
await delay(ACP_JOURNAL_RETRY_MILLISECONDS)
|
||||
await delay(retryDelay)
|
||||
retryDelay = Math.min(
|
||||
ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS,
|
||||
retryDelay * 2
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.nextOutputSequence += 1n
|
||||
|
||||
Reference in New Issue
Block a user