fix(sync-continuation): improve error handling and toast cleanup

- Add proper error handling in executeSyncContinuation with try-catch blocks
- Ensure toast cleanup happens in all error paths via finally block
- Add anchorMessageCount tracking for accurate result fetching after continuation
- Improve fetchSyncResult to filter messages after anchor point
- Add silent failure detection when no new response is generated
This commit is contained in:
YeonGyu-Kim 2026-02-10 16:17:24 +09:00
parent 45dfc4ec66
commit 231e790a0c
3 changed files with 71 additions and 34 deletions

View File

@ -50,11 +50,13 @@ export async function executeSyncContinuation(
let resumeAgent: string | undefined let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined let resumeModel: { providerID: string; modelID: string } | undefined
let resumeVariant: string | undefined let resumeVariant: string | undefined
let anchorMessageCount: number | undefined
try { try {
try { try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
const messages = (messagesResp.data ?? []) as SessionMessage[] const messages = (messagesResp.data ?? []) as SessionMessage[]
anchorMessageCount = messages.length
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
@ -91,39 +93,34 @@ export async function executeSyncContinuation(
parts: [{ type: "text", text: args.prompt }], parts: [{ type: "text", text: args.prompt }],
}, },
}) })
} catch (promptError) { } catch (promptError) {
if (toastManager) { if (toastManager) {
toastManager.removeTask(taskId) toastManager.removeTask(taskId)
} }
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}` return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
} }
const pollError = await pollSyncSession(ctx, client, { try {
sessionID: args.session_id!, const pollError = await pollSyncSession(ctx, client, {
agentToUse: resumeAgent ?? "continue", sessionID: args.session_id!,
toastManager, agentToUse: resumeAgent ?? "continue",
taskId, toastManager,
}) taskId,
if (pollError) { anchorMessageCount,
return pollError })
} if (pollError) {
return pollError
}
const result = await fetchSyncResult(client, args.session_id!) const result = await fetchSyncResult(client, args.session_id!, anchorMessageCount)
if (!result.ok) { if (!result.ok) {
if (toastManager) { return result.error
toastManager.removeTask(taskId) }
}
return result.error
}
if (toastManager) { const duration = formatDuration(startTime)
toastManager.removeTask(taskId)
}
const duration = formatDuration(startTime) return `Task continued and completed in ${duration}.
return `Task continued and completed in ${duration}.
--- ---
@ -132,4 +129,9 @@ ${result.textContent || "(No text output)"}
<task_metadata> <task_metadata>
session_id: ${args.session_id} session_id: ${args.session_id}
</task_metadata>` </task_metadata>`
} finally {
if (toastManager) {
toastManager.removeTask(taskId)
}
}
} }

View File

@ -3,7 +3,8 @@ import type { SessionMessage } from "./executor-types"
export async function fetchSyncResult( export async function fetchSyncResult(
client: OpencodeClient, client: OpencodeClient,
sessionID: string sessionID: string,
anchorMessageCount?: number
): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> { ): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> {
const messagesResult = await client.session.messages({ const messagesResult = await client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
@ -15,11 +16,27 @@ export async function fetchSyncResult(
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages const messagesAfterAnchor = anchorMessageCount !== undefined ? messages.slice(anchorMessageCount) : messages
if (anchorMessageCount !== undefined && messagesAfterAnchor.length === 0) {
return {
ok: false,
error: `Session completed but no new response was generated. The model may have failed silently.\n\nSession ID: ${sessionID}`,
}
}
const assistantMessages = messagesAfterAnchor
.filter((m) => m.info?.role === "assistant") .filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0] const lastMessage = assistantMessages[0]
if (anchorMessageCount !== undefined && !lastMessage) {
return {
ok: false,
error: `Session completed but no new response was generated. The model may have failed silently.\n\nSession ID: ${sessionID}`,
}
}
if (!lastMessage) { if (!lastMessage) {
return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` }
} }

View File

@ -30,6 +30,7 @@ export async function pollSyncSession(
agentToUse: string agentToUse: string
toastManager: { removeTask: (id: string) => void } | null | undefined toastManager: { removeTask: (id: string) => void } | null | undefined
taskId: string | undefined taskId: string | undefined
anchorMessageCount?: number
} }
): Promise<string | null> { ): Promise<string | null> {
const syncTiming = getTimingConfig() const syncTiming = getTimingConfig()
@ -48,7 +49,13 @@ export async function pollSyncSession(
await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS)) await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS))
pollCount++ pollCount++
const statusResult = await client.session.status() let statusResult: { data?: Record<string, { type: string }> }
try {
statusResult = await client.session.status()
} catch (error) {
log("[task] Poll status fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue
}
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[input.sessionID] const sessionStatus = allStatuses[input.sessionID]
@ -65,8 +72,19 @@ export async function pollSyncSession(
continue continue
} }
const messagesResult = await client.session.messages({ path: { id: input.sessionID } }) let messagesResult: { data?: unknown } | SessionMessage[]
const msgs = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] try {
messagesResult = await client.session.messages({ path: { id: input.sessionID } })
} catch (error) {
log("[task] Poll messages fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue
}
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
const msgs = Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
if (input.anchorMessageCount !== undefined && msgs.length <= input.anchorMessageCount) {
continue
}
if (isSessionComplete(msgs)) { if (isSessionComplete(msgs)) {
log("[task] Poll complete - terminal finish detected", { sessionID: input.sessionID, pollCount }) log("[task] Poll complete - terminal finish detected", { sessionID: input.sessionID, pollCount })