fix: preserve remote task success after recoverable tool errors
Align remote ACP tool recovery with local runtime behavior when the model responds after a tool failure and completes normally. Preserve original errors and derive recovery state from committed remote events so reconnecting safely replays partial terminal events. Validation: 461 focused tests, typecheck, lint, and shared Linux Host recovery scenarios passed. Full-suite validation has two pre-existing heartbeat migration fixture failures involving the pinned column. Release note: 修复远端 Agent 在读取等工具失败后已正常回复,任务仍被误判为失败的问题,并修复断线重连时的工具状态恢复。
This commit is contained in:
@@ -3544,6 +3544,40 @@ describe('AssistantDatabase', () => {
|
||||
inspected.close()
|
||||
})
|
||||
|
||||
it('derives remote recovery evidence from semantic order across a restart', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-tool-recovery-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
let database = new AssistantDatabase(path)
|
||||
database.initialize('C:\\Workspace')
|
||||
const taskId = '00000000-0000-4000-8000-000000000702'
|
||||
database.createTask({ id: taskId, title: 'Recovery', instructions: 'Read', workMode: 'execute' })
|
||||
const append = (sequence: string, kind: string, payload: object) => database.appendRemoteTaskEventOnce({
|
||||
taskId, bindingId: 'tool-recovery', operationId: taskId,
|
||||
semanticSequence: sequence, eventIndex: 0, kind, payload
|
||||
})
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false)
|
||||
append('1', 'text', { delta: 'Before failure' })
|
||||
append('2', 'tool', { state: 'failed', callId: 'read' })
|
||||
append('3', 'reasoning', { delta: 'Still thinking' })
|
||||
append('4', 'text', { delta: '\n\t\u3000' })
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false)
|
||||
append('10', 'text', { delta: 'Handled the failure' })
|
||||
// Late insertion of an older event must not change semantic ordering.
|
||||
append('9', 'tool', { state: 'failed', callId: 'read-again' })
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true)
|
||||
database.close()
|
||||
database = new AssistantDatabase(path)
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true)
|
||||
append('11', 'tool', { state: 'recoverable', callId: 'read' })
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true)
|
||||
append('12', 'tool', { state: 'failed', callId: 'third-read' })
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false)
|
||||
expect(database.hasRemoteResponseTextAfterToolFailure('00000000-0000-4000-8000-000000000703')).toBe(false)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists inherited remote metrics and replay without pinning the conversation', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-inherited-remote-metrics-')
|
||||
|
||||
@@ -5405,6 +5405,57 @@ export class AssistantDatabase {
|
||||
return row.sequence === null ? '0' : String(row.sequence)
|
||||
}
|
||||
|
||||
hasRemoteResponseTextAfterToolFailure(taskIdInput: string): boolean {
|
||||
const taskId = assistantIdSchema.parse(taskIdInput)
|
||||
// Use event order, not display blocks: tool blocks are updated in place,
|
||||
// and display capture can be truncated before the final response arrives.
|
||||
const rows = this.requireDatabase().prepare(
|
||||
`SELECT kind, payload_json FROM task_events
|
||||
WHERE task_id = ? AND remote_semantic_sequence IS NOT NULL
|
||||
AND (kind = 'text' OR
|
||||
(kind = 'tool' AND json_extract(payload_json, '$.state') = 'failed'))
|
||||
ORDER BY CAST(remote_semantic_sequence AS INTEGER) DESC, remote_event_index DESC`
|
||||
).iterate(taskId)
|
||||
for (const row of rows) {
|
||||
if (row.kind === 'tool') return false
|
||||
const event = JSON.parse(row.payload_json as string) as { delta?: string }
|
||||
if (event.delta?.trim()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getRemoteTaskActivityStates(taskIdInput: string): {
|
||||
tools: Map<string, ConversationToolActivity['state']>
|
||||
subagents: Map<string, Pick<ConversationSubagentActivity, 'state' | 'error'>>
|
||||
} {
|
||||
const taskId = assistantIdSchema.parse(taskIdInput)
|
||||
const tools = new Map<string, ConversationToolActivity['state']>()
|
||||
const subagents = new Map<string, Pick<ConversationSubagentActivity, 'state' | 'error'>>()
|
||||
// Terminal message projection changes unfinished activities. Only their
|
||||
// original remote events can supply states for deterministic terminal replay.
|
||||
const rows = this.requireDatabase().prepare(
|
||||
`SELECT kind, json_extract(payload_json, '$.callId') AS call_id,
|
||||
json_extract(payload_json, '$.childTaskId') AS child_task_id,
|
||||
json_extract(payload_json, '$.state') AS state,
|
||||
json_extract(payload_json, '$.error') AS error
|
||||
FROM task_events
|
||||
WHERE task_id = ? AND remote_semantic_sequence IS NOT NULL
|
||||
AND kind IN ('tool', 'subagent')
|
||||
ORDER BY CAST(remote_semantic_sequence AS INTEGER), remote_event_index`
|
||||
).iterate(taskId)
|
||||
for (const row of rows) {
|
||||
if (row.kind === 'tool') {
|
||||
tools.set(row.call_id as string, row.state as ConversationToolActivity['state'])
|
||||
} else {
|
||||
subagents.set(row.child_task_id as string, {
|
||||
state: row.state as ConversationSubagentActivity['state'],
|
||||
error: row.error === null ? undefined : row.error as string
|
||||
})
|
||||
}
|
||||
}
|
||||
return { tools, subagents }
|
||||
}
|
||||
|
||||
private queryHighestCommittedRemoteTaskEventSequence(
|
||||
database: DatabaseSync,
|
||||
bindingId: string,
|
||||
|
||||
Reference in New Issue
Block a user