fix: restore remote conversation recovery and clean obsolete agents
Wait for model delivery acknowledgements before completing remote prompts and preserve completed outcomes when idle Runtime cleanup fails. Explicit messages after an unknown outcome now open a fresh binding with history instead of reusing the failed session; recovery remains attach-only. Run obsolete payload cleanup through the current Agent after activation, retaining history and unresolved process ownership. Live legacy daemons and shared Runtime versions remain deferred. Includes focused regressions, local OpenCode simulation, and isolated Linux validation evidence. Typecheck and lint passed; the full test run timed out. Release note: Remote conversations can continue after an uncertain result, and completed answers no longer fail solely because Runtime cleanup fails. Updates remove verified inactive Agent payloads and owned orphan processes while retaining active or uncertain installations and conversation history.
This commit is contained in:
@@ -3494,6 +3494,104 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['opencode', 'continue'] as const)('starts a fresh %s binding after a cached unknown terminal', async runtimeId => {
|
||||
const fixture = ownedChannel({ runtimeId, state: 'outcome-unknown' })
|
||||
const next = ownedChannel({ runtimeId })
|
||||
try {
|
||||
await expect(collect(fixture.instance.run(request, new AbortController().signal)))
|
||||
.rejects.toThrow(/终态未知/iu)
|
||||
const original = (await fixture.store.getByConversation(request.conversationId))!
|
||||
expect(original).toMatchObject({ state: 'outcome-unknown', activePromptOperationId: request.requestId })
|
||||
await expect(collect(fixture.instance.run(
|
||||
{ ...request, remoteRecoveryOnly: true }, new AbortController().signal
|
||||
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
expect(fixture.channel.close).not.toHaveBeenCalled()
|
||||
fixture.pageOwnedPromptTranscript.mockImplementation(next.pageOwnedPromptTranscript)
|
||||
const events = await collect(fixture.instance.run({
|
||||
...request, requestId: 'after-unknown', prompt: 'new explicit request',
|
||||
history: [{ role: 'user', content: 'previous question' }, { role: 'assistant', content: 'previous answer' }]
|
||||
}, new AbortController().signal))
|
||||
expect(events.some(event => event.type === 'done')).toBe(true)
|
||||
expect(await fixture.store.getById(original.bindingId)).toMatchObject({ state: 'closed', activePromptOperationId: undefined })
|
||||
const binding = (await fixture.store.getByConversation(request.conversationId))!
|
||||
expect(binding.bindingId).not.toBe(original.bindingId)
|
||||
expect(binding).toMatchObject({ state: 'ready', promptSequence: 0 })
|
||||
expect(fixture.channel.close).toHaveBeenCalledOnce()
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.startOwnedPrompt.mock.calls[1]![0]).toMatchObject({
|
||||
bindingId: binding.bindingId, operationId: 'after-unknown', requestId: 'after-unknown'
|
||||
})
|
||||
expect(fixture.startOwnedPrompt.mock.calls[1]![0]).not.toHaveProperty('acpSessionId')
|
||||
expect(JSON.stringify(fixture.startOwnedPrompt.mock.calls[1]![0])).toContain('previous answer')
|
||||
expect(JSON.stringify(fixture.startOwnedPrompt.mock.calls[1]![0])).toContain('new explicit request')
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally { await fixture.instance.dispose(); await next.instance.dispose() }
|
||||
})
|
||||
|
||||
it.each(['opencode', 'continue'] as const)('does not prepare or start attach-only recovery from a cached ready %s session', async runtimeId => {
|
||||
const fixture = ownedChannel({ runtimeId })
|
||||
try {
|
||||
await collect(fixture.instance.run(request, new AbortController().signal))
|
||||
const binding = await fixture.store.getByConversation(request.conversationId)
|
||||
await expect(collect(fixture.instance.run(
|
||||
{ ...request, remoteRecoveryOnly: true }, new AbortController().signal
|
||||
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toEqual(binding)
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
expect(fixture.channel.close).not.toHaveBeenCalled()
|
||||
} finally { await fixture.instance.dispose() }
|
||||
})
|
||||
|
||||
it('preserves a cached running operation when another explicit request arrives', async () => {
|
||||
const fixture = ownedChannel()
|
||||
try {
|
||||
const stream = fixture.instance.run(request, new AbortController().signal)
|
||||
await stream.next()
|
||||
await stream.return()
|
||||
const binding = await fixture.store.getByConversation(request.conversationId)
|
||||
await expect(collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'different-request' }, new AbortController().signal
|
||||
))).rejects.toThrow(/必须先恢复或取消原请求/iu)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toEqual(binding)
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.channel.close).not.toHaveBeenCalled()
|
||||
const recovered = await collect(fixture.instance.run(
|
||||
{ ...request, remoteRecoveryOnly: true }, new AbortController().signal
|
||||
))
|
||||
expect(recovered.some(event => event.type === 'done')).toBe(true)
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
} finally { await fixture.instance.dispose() }
|
||||
})
|
||||
|
||||
it('continues a cached ready session after a failed terminal', async () => {
|
||||
const fixture = ownedChannel({ state: 'failed' })
|
||||
const next = ownedChannel()
|
||||
try {
|
||||
await expect(collect(fixture.instance.run(request, new AbortController().signal)))
|
||||
.rejects.toThrow('owned failure')
|
||||
const binding = (await fixture.store.getByConversation(request.conversationId))!
|
||||
expect(binding).toMatchObject({ state: 'ready', activePromptOperationId: undefined })
|
||||
fixture.pageOwnedPromptTranscript.mockImplementation(next.pageOwnedPromptTranscript)
|
||||
const events = await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-failure' }, new AbortController().signal
|
||||
))
|
||||
expect(events.some(event => event.type === 'done')).toBe(true)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toMatchObject({
|
||||
bindingId: binding.bindingId, state: 'ready', promptSequence: binding.promptSequence + 1
|
||||
})
|
||||
expect(fixture.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', binding.acpSessionId)
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
expect(fixture.channel.close).not.toHaveBeenCalled()
|
||||
} finally { await fixture.instance.dispose(); await next.instance.dispose() }
|
||||
})
|
||||
|
||||
it('never starts a replacement prompt for attach-only recovery', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const recoveryRequest = {
|
||||
|
||||
@@ -1746,6 +1746,31 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal: AbortSignal
|
||||
): Promise<SessionRecord> {
|
||||
let current = this.sessions.get(conversationId)
|
||||
if (current && request.remoteRecoveryOnly === true && !(
|
||||
current.binding.state === 'prompt-running' &&
|
||||
current.binding.activePromptOperationId === request.requestId
|
||||
)) {
|
||||
throw new RemotePromptRecoveryUnavailableError(
|
||||
'没有可安全附加的远端 Agent 请求,且恢复不会重放任务'
|
||||
)
|
||||
}
|
||||
if (current?.binding.state === 'prompt-running' &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
current.binding.activePromptOperationId !== request.requestId) {
|
||||
throw new Error('当前对话仍有 Agent 托管请求,必须先恢复或取消原请求')
|
||||
}
|
||||
if (current && current.binding.state !== 'ready' &&
|
||||
current.binding.state !== 'prompt-running') {
|
||||
// Let cold-open retire an unusable binding before starting a new request.
|
||||
this.sessions.delete(conversationId)
|
||||
this.contexts.delete(conversationId)
|
||||
await this.awaitLocalOperation(
|
||||
'关闭结果未知会话通道',
|
||||
this.closeContext(current.context),
|
||||
signal
|
||||
).catch(() => undefined)
|
||||
current = undefined
|
||||
}
|
||||
if (current?.binding.state === 'ready' && this.options.runtimeId === 'continue' &&
|
||||
this.options.modelProfile !== undefined) {
|
||||
// Continue keeps native history in process memory; seed the next process
|
||||
|
||||
Reference in New Issue
Block a user