fix: recover remote conversations after uncertain prompts

An interrupted managed SSH prompt could leave its ACP binding as the permanent owner of the conversation, so every later user message failed with the same uncertain-recovery error. A later explicit prompt now retires that binding and starts a fresh ACP session without replaying the uncertain operation.

Ask-mode Runtime startup also waited on the wrong bubblewrap executable identity. The Agent now supervises the bounded transition to the signed Runtime or model-bridge executable, and the Agent version advances to 0.11.10.

Release note: 修复远程 ACP 请求中断后同一会话无法继续输入,以及 Ask 模式可能在模型调用前启动失败的问题。
This commit is contained in:
mesalogo
2026-08-30 11:02:20 +08:00
parent 8e08ab0bac
commit 967796539f
8 changed files with 164 additions and 40 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.9",
"agentVersion": "0.11.10",
"protocol": {
"major": 2,
"minor": 0
@@ -284,7 +284,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
不会把确定性失败错误保留为 `outcome-unknown`。
不会把确定性失败错误保留为 `outcome-unknown`。未确认终态的 Prompt 仍不会自动重放;
用户之后显式发送的新 Prompt 会关闭旧的非就绪 Main binding,并以新的 ACP session
继续当前 Conversation,避免一次恢复不确定永久阻塞后续输入。
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
的有界转换,再登记为运行中进程。
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
@@ -119,6 +119,47 @@ describe('direct Linux stdio Runtime ownership', () => {
registry.close()
})
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
const registry = createRegistry()
const child = fakeChild()
const bwrapIdentity = identity()
const helperIdentity = {
...bwrapIdentity,
executablePath: '/agent/node'
}
const spawn: DirectLinuxStdioSpawn = () => {
queueMicrotask(() => child.emit('spawn'))
return child
}
const readProcessIdentity = vi
.fn()
.mockResolvedValueOnce(bwrapIdentity)
.mockResolvedValue(helperIdentity)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: {
...profile(),
processExecutable: '/agent/node'
},
identity: { launchId: 'launch-1', processId: 'process-1' },
installationId: 'installation-1',
registry,
deadlineAt: '2030-01-01T00:00:00.000Z',
maximumInputBytes: 1024,
platform: 'linux',
spawn,
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity
})
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
expect(owner.processIdentity).toEqual(helperIdentity)
child.exitCode = 0
child.emit('close', 0, null)
registry.close()
})
it('rejects PID reuse before sending any signal', async () => {
const registry = createRegistry()
const child = fakeChild()
@@ -31,6 +31,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
const DEFAULT_MAXIMUM_LISTENERS = 8
const DEFAULT_STOP_TIMEOUT_MS = 10_000
const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000
const TERM_GRACE_MS = 2_000
const POLL_INTERVAL_MS = 25
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
@@ -603,8 +604,18 @@ export async function launchDirectLinuxStdioProcessOwner(
await waitForSpawn(child, options.signal, timeoutMs)
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
const processIdentity = await readIdentity(child.pid)
assertLaunchedIdentity(processIdentity, profile)
const processIdentity = await waitForLaunchedIdentity({
child,
pid: child.pid,
profile,
readIdentity,
signal: options.signal,
deadline: Math.min(
Date.parse(effectiveDeadlineAt),
now() + DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS
),
now
})
const supervisorIdentityDigest = supervisorDigest({
installationId,
ownerId,
@@ -871,15 +882,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
}
}
function assertLaunchedIdentity(
identity: LinuxRuntimeProcessIdentity,
async function waitForLaunchedIdentity(input: {
child: DirectLinuxStdioChild
pid: number
profile: OpenCodeLaunchProfile
): void {
if (
identity.executablePath !== profile.processExecutable ||
identity.processGroupId !== identity.pid
) {
throw identityError('Spawned Runtime does not match the launch identity')
readIdentity: typeof readLinuxRuntimeProcessIdentity
signal?: AbortSignal
deadline: number
now: () => number
}): Promise<LinuxRuntimeProcessIdentity> {
for (;;) {
input.signal?.throwIfAborted()
if (
input.child.exitCode !== null ||
input.child.signalCode !== null
) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime process exited during launch',
'process'
)
}
let identity: LinuxRuntimeProcessIdentity
try {
identity = await input.readIdentity(input.pid)
} catch (error) {
if (!isMissingProcess(error)) {
throw error
}
if (input.now() >= input.deadline) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime launch identity was not observed',
'process'
)
}
await delay(
Math.min(
POLL_INTERVAL_MS,
Math.max(1, input.deadline - input.now())
)
)
continue
}
if (identity.processGroupId !== identity.pid) {
throw identityError(
'Spawned Runtime does not match the launch identity'
)
}
if (identity.executablePath === input.profile.processExecutable) {
return identity
}
if (identity.executablePath !== input.profile.executable) {
throw identityError(
'Spawned Runtime does not match the launch identity'
)
}
if (input.now() >= input.deadline) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime launch did not reach its supervised executable',
'process'
)
}
await delay(
Math.min(
POLL_INTERVAL_MS,
Math.max(1, input.deadline - input.now())
)
)
}
}
@@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => {
'--opencode-entrypoint',
resolve(fixture.bundleDirectory, 'bin', 'opencode')
])
expect(profile.processExecutable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(profile.args.join('\0')).not.toMatch(
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
)
+1 -1
View File
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
return {
executable: OPENCODE_BWRAP_EXECUTABLE,
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
processExecutable: runtimeCommand.executable,
args,
cwd: scratchDirectory,
env: {
+25 -13
View File
@@ -599,7 +599,7 @@ describe('AcpRemoteRuntime', () => {
})
it.each(['prompt-running', 'outcome-unknown'] as const)(
'does not replace a %s binding after identity changes',
'retires a %s binding before starting an explicit later prompt',
async (state) => {
const store = new MemoryRuntimeSessionBindingStore()
const active = {
@@ -625,7 +625,13 @@ describe('AcpRemoteRuntime', () => {
lastMainAckSequence: '0'
}
await store.put(active)
const factory = vi.fn(async () => fakeServer().channel)
const replacement = fakeServer()
const factory = vi.fn(
async (bindingId: string) => {
expect(bindingId).not.toBe(active.bindingId)
return replacement.channel
}
)
const instance = factoryRuntime(factory, store, {
identity: {
...identity,
@@ -633,22 +639,28 @@ describe('AcpRemoteRuntime', () => {
}
})
await expect(
collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
await collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
).rejects.toThrow('不会自动重放')
)
expect(factory).not.toHaveBeenCalled()
expect(factory).toHaveBeenCalledOnce()
expect(factory).not.toHaveBeenCalledWith(active.bindingId)
await expect(
store.getById(active.bindingId)
).resolves.toMatchObject({
bindingId: active.bindingId,
state: 'closed',
activePromptOperationId: undefined
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: active.bindingId,
state: 'outcome-unknown',
activePromptOperationId: request.requestId
bindingId: factory.mock.calls[0]![0],
state: 'ready',
activePromptOperationId: undefined
})
await instance.dispose()
}
+9 -14
View File
@@ -833,7 +833,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
return interrupted
}
private async persistRejectedPrompt(
private async persistClosedBinding(
binding: RuntimeSessionBinding
): Promise<RuntimeSessionBinding> {
const closed = runtimeSessionBindingSchema.parse({
@@ -1198,24 +1198,19 @@ export class AcpRemoteRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<SessionRecord> {
const old = await this.awaitLocalOperation(
let binding = await this.awaitLocalOperation(
'读取会话绑定',
this.options.bindingStore.getByConversation(conversationId),
signal
)
if (
old &&
old.state !== 'closed' &&
old.state !== 'ready'
binding &&
binding.state !== 'closed' &&
binding.state !== 'ready'
) {
await this.persistInterrupted(old)
throw new Error(
old.state === 'prompt-running'
? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放'
: '远端 ACP 会话恢复状态不确定,已中断且不会自动重放'
)
await this.persistClosedBinding(binding)
binding = undefined
}
let binding = old
let bindingId = binding?.bindingId ?? randomUUID()
if (
binding?.state === 'ready' &&
@@ -1437,7 +1432,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
error,
'runtime/preparePrompt'
)
? this.persistRejectedPrompt(binding)
? this.persistClosedBinding(binding)
: this.persistInterrupted(binding)
await persist.catch(() => undefined)
}
@@ -1483,7 +1478,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
error,
'runtime/preparePrompt'
)
? this.persistRejectedPrompt(current.binding)
? this.persistClosedBinding(current.binding)
: this.persistInterrupted(current.binding)
current.binding = await persist.catch(() => current.binding)
this.sessions.delete(conversationId)