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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
|
||||
return {
|
||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
args,
|
||||
cwd: scratchDirectory,
|
||||
env: {
|
||||
|
||||
Reference in New Issue
Block a user