From a064e1367622cf2429e98de6a3f1087000bb46de Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:55:06 +0900 Subject: [PATCH 1/6] fix(delegate-task): recover sync results on abort race --- .../delegate-task/sync-continuation.test.ts | 118 +++++++++++++++++- src/tools/delegate-task/sync-continuation.ts | 20 ++- .../delegate-task/sync-session-poller.test.ts | 31 +++++ .../delegate-task/sync-session-poller.ts | 31 +++-- src/tools/delegate-task/sync-task.test.ts | 57 ++++++++- src/tools/delegate-task/sync-task.ts | 30 +++++ 6 files changed, 272 insertions(+), 15 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 9f2a690ef..b33b7f721 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -186,6 +186,121 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("recovers from pollSyncSession error when result already exists", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Recovered result") + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + + test("returns poll error when recovery fetch has no result", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + test("removes toast on successful completion", async () => { //#given - mock successful completion with messages growing after anchor const mockClient = { @@ -306,7 +421,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then - removeTask should be called at least once (poller and finally may both call it) expect(removeTaskCalls.length).toBeGreaterThanOrEqual(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") - expect(result).toContain("Task aborted") + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Result") }) test("no crash when toastManager is null", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index add3afd0d..36679981f 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -162,7 +162,25 @@ export async function executeSyncContinuation( anchorMessageCount, }, syncPollTimeoutMs) if (pollError) { - return pollError + const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) + if (!recoveredResult.ok) { + return pollError + } + + const duration = formatDuration(startTime) + + return `Task continued and completed in ${duration}. + +--- + +${recoveredResult.textContent || "(No text output)"} + +${buildTaskMetadataBlock({ + sessionId: continuationID, + taskId: continuationID, + agent: resumeAgent, + category: args.category, + })}` } const result = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index 004fa0cb8..552b543bc 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -421,6 +421,37 @@ describe("pollSyncSession", () => { expect(result).toContain("ses_abort") expect(abortCount).toBe(1) }) + + test("retries final message fetch on abort before returning aborted", async () => { + // given: abort signal set and message fetch keeps failing + const { pollSyncSession } = require("./sync-session-poller") + let abortCount = 0 + let messageCallCount = 0 + const mockClient = { + session: { + abort: async () => { + abortCount++ + }, + messages: async () => { + messageCallCount++ + throw new Error("temporary fetch failure") + }, + status: async () => ({ data: {} }), + }, + } + + const result = await pollSyncSession(createMockCtx(true), mockClient, { + sessionID: "ses_abort_retry", + agentToUse: "test-agent", + toastManager: { removeTask: () => {} }, + taskId: "task_123", + }) + + // then + expect(result).toContain("Task aborted") + expect(messageCallCount).toBe(3) + expect(abortCount).toBe(1) + }) }) describe("timeout handling", () => { diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 9d69e4157..5c3d5e5b9 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -105,19 +105,32 @@ export async function pollSyncSession( } if (ctx.abort?.aborted) { - try { - const messages = await fetchSessionMessages(client, input.sessionID) + let finalMessages: SessionMessage[] | null = null + const abortFetchAttempts = 3 + for (let attempt = 1; attempt <= abortFetchAttempts; attempt++) { + try { + finalMessages = await fetchSessionMessages(client, input.sessionID) + break + } catch (error) { + log("[task] Final messages fetch failed after abort, retrying", { + sessionID: input.sessionID, + attempt, + maxAttempts: abortFetchAttempts, + error: String(error), + }) + if (attempt < abortFetchAttempts) { + await wait(syncTiming.POLL_INTERVAL_MS) + } + } + } + + if (finalMessages) { const hasNewMessages = - input.anchorMessageCount === undefined || messages.length > input.anchorMessageCount - if (hasNewMessages && isSessionComplete(messages)) { + input.anchorMessageCount === undefined || finalMessages.length > input.anchorMessageCount + if (hasNewMessages && isSessionComplete(finalMessages)) { log("[task] Abort detected after session already completed", { sessionID: input.sessionID }) return null } - } catch (error) { - log("[task] Final messages fetch failed after abort, continuing with abort", { - sessionID: input.sessionID, - error: String(error), - }) } log("[task] Aborted by user", { sessionID: input.sessionID }) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index bd346b8f0..11d2c4177 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -173,7 +173,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(rollback).toHaveBeenCalledTimes(1) }) - test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => { + test("recovers from pollSyncSession error when result already exists", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -185,7 +185,7 @@ describe("executeSyncTask - cleanup on error paths", () => { const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Poll error", + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), } @@ -215,14 +215,63 @@ describe("executeSyncTask - cleanup on error paths", () => { sessionID: "parent-session", }, "test-agent", undefined, undefined, undefined, undefined, deps) - //#then - should return error and cleanup resources - expect(result).toBe("Poll error") + //#then - should recover via fetchSyncResult and cleanup resources + expect(result).toContain("Task completed in") + expect(result).toContain("Result") expect(removeTaskCalls.length).toBe(1) expect(removeTaskCalls[0]).toBe("sync_ses_test") expect(deleteCalls.length).toBe(1) expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("returns poll error when recovery fetch has no result", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "Poll error", + fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toBe("Poll error") + expect(removeTaskCalls.length).toBe(1) + expect(deleteCalls.length).toBe(1) + }) + test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => { //#given const mockClient = { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 5601247b7..762146442 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -15,6 +15,11 @@ import { resolveMetadataModel } from "./resolve-metadata-model" import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" +function shouldAttemptPollErrorRecovery(pollError: string): boolean { + const normalized = pollError.toLowerCase() + return normalized.includes("aborted") || normalized.includes("abort") +} + export async function executeSyncTask( args: DelegateTaskArgs, ctx: ToolContextWithMetadata, @@ -213,6 +218,31 @@ export async function executeSyncTask( taskId, }, syncPollTimeoutMs) if (pollError) { + if (shouldAttemptPollErrorRecovery(pollError)) { + const recoveredResult = await deps.fetchSyncResult(client, activeSessionID) + if (recoveredResult.ok) { + const duration = formatDuration(startTime) + + const actualModelStr = effectiveCategoryModel + ? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}` + : undefined + const parentModelStr = parentContext.model + ? `${parentContext.model.providerID}/${parentContext.model.modelID}` + : undefined + let modelRoutingNote = "" + if (actualModelStr && parentModelStr && actualModelStr !== parentModelStr) { + modelRoutingNote = `\n⚠️ Model fallback used: requested ${parentModelStr}, executed ${actualModelStr}` + } + + return `Task completed in ${duration}.\n\n---\n\n${recoveredResult.textContent || "(No text output)"}${modelRoutingNote}\n\n${buildTaskMetadataBlock({ + sessionId: activeSessionID, + taskId: activeSessionID, + agent: agentToUse, + category: args.category, + })}` + } + } + const nextFallbackModel = shouldRetryError({ message: pollError }) ? getNextSyncFallbackModel(activeSessionID, fallbackState) : null From 65913023c53d9f888718d0ac014ab8ff068c551d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:46:14 +0900 Subject: [PATCH 2/6] fix(delegate-task): narrow abort recovery to canonical abort errors --- src/tools/delegate-task/sync-task.test.ts | 57 +++++++++++++++++++++-- src/tools/delegate-task/sync-task.ts | 21 ++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 11d2c4177..03e2d500a 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -173,7 +173,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(rollback).toHaveBeenCalledTimes(1) }) - test("recovers from pollSyncSession error when result already exists", async () => { + test("recovers from MessageAbortedError poll error when result already exists", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -185,7 +185,7 @@ describe("executeSyncTask - cleanup on error paths", () => { const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), } @@ -210,7 +210,7 @@ describe("executeSyncTask - cleanup on error paths", () => { command: null, } - //#when - executeSyncTask with pollSyncSession failing + //#when - executeSyncTask with MessageAbortedError poll error const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { sessionID: "parent-session", }, "test-agent", undefined, undefined, undefined, undefined, deps) @@ -224,6 +224,57 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("does not recover from non-abort poll error containing abort-like words", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + let fetchSyncResultCalled = false + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "Task aborted: subagent exceeded 5 assistant turns without completing", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: true as const, textContent: "unexpected" } + }, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toBe("Task aborted: subagent exceeded 5 assistant turns without completing") + expect(fetchSyncResultCalled).toBe(false) + }) + test("returns poll error when recovery fetch has no result", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 762146442..58b52a07b 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -16,8 +16,25 @@ import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" function shouldAttemptPollErrorRecovery(pollError: string): boolean { - const normalized = pollError.toLowerCase() - return normalized.includes("aborted") || normalized.includes("abort") + const trimmed = pollError.trim() + + if (trimmed.length === 0) { + return false + } + + if (/\bMessageAbortedError\b/u.test(trimmed)) { + return true + } + + if (/\bDOMException\b/u.test(trimmed) && /\bAbortError\b/u.test(trimmed)) { + return true + } + + if (/\bAbortError\b/u.test(trimmed) && !/\bTask aborted\b/u.test(trimmed)) { + return true + } + + return false } export async function executeSyncTask( From 468bf25dcbfcfa6500b3a58a13bfcc4cf98e0097 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:53:21 +0900 Subject: [PATCH 3/6] fix(delegate-task): gate continuation recovery to canonical abort errors --- .../delegate-task/sync-continuation.test.ts | 13 +++++----- src/tools/delegate-task/sync-continuation.ts | 26 ++++++++++++++++++- src/tools/delegate-task/sync-task.test.ts | 14 +++++++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index b33b7f721..0ddf5e7ed 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -186,7 +186,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("recovers from pollSyncSession error when result already exists", async () => { + test("recovers from MessageAbortedError poll error when result already exists", async () => { const mockClient = { session: { messages: async () => ({ @@ -208,7 +208,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), } @@ -244,7 +244,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns MessageAbortedError poll error when recovery fetch has no result", async () => { const mockClient = { session: { messages: async () => ({ @@ -266,7 +266,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), } @@ -296,7 +296,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { }, deps) //#then - expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") + expect(result).toBe("MessageAbortedError: aborted by user") expect(removeTaskCalls.length).toBe(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) @@ -421,8 +421,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then - removeTask should be called at least once (poller and finally may both call it) expect(removeTaskCalls.length).toBeGreaterThanOrEqual(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") - expect(result).toContain("Task continued and completed in") - expect(result).toContain("Result") + expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") }) test("no crash when toastManager is null", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 36679981f..6e600616d 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -24,6 +24,28 @@ type ResumeContext = { anchorMessageCount?: number } +function shouldAttemptPollErrorRecovery(pollError: string): boolean { + const trimmed = pollError.trim() + + if (trimmed.length === 0) { + return false + } + + if (/\bMessageAbortedError\b/u.test(trimmed)) { + return true + } + + if (/\bDOMException\b/u.test(trimmed) && /\bAbortError\b/u.test(trimmed)) { + return true + } + + if (/\bAbortError\b/u.test(trimmed) && !/\bTask aborted\b/u.test(trimmed)) { + return true + } + + return false +} + async function resolveResumeContext( client: ExecutorContext["client"], continuationID: string @@ -161,7 +183,7 @@ export async function executeSyncContinuation( taskId, anchorMessageCount, }, syncPollTimeoutMs) - if (pollError) { + if (pollError && shouldAttemptPollErrorRecovery(pollError)) { const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) if (!recoveredResult.ok) { return pollError @@ -181,6 +203,8 @@ ${buildTaskMetadataBlock({ agent: resumeAgent, category: args.category, })}` + } else if (pollError) { + return pollError } const result = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 03e2d500a..0143885dd 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -275,7 +275,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(fetchSyncResultCalled).toBe(false) }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns abort poll error when recovery fetch has no result", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -284,11 +284,16 @@ describe("executeSyncTask - cleanup on error paths", () => { const { executeSyncTask } = require("./sync-task") + let fetchSyncResultCalled = false + const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Poll error", - fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + pollSyncSession: async () => "MessageAbortedError: aborted by user", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: false as const, error: "No assistant response found" } + }, } const mockCtx = { @@ -318,7 +323,8 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "test-agent", undefined, undefined, undefined, undefined, deps) //#then - expect(result).toBe("Poll error") + expect(result).toBe("MessageAbortedError: aborted by user") + expect(fetchSyncResultCalled).toBe(true) expect(removeTaskCalls.length).toBe(1) expect(deleteCalls.length).toBe(1) }) From e018739d2cc5af2dd841935b0a3b2dd6a2eef152 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:01:17 +0900 Subject: [PATCH 4/6] fix(delegate-task): recover aborted-operation messages in sync flows --- .../delegate-task/sync-continuation.test.ts | 56 +++++++++++++++++++ src/tools/delegate-task/sync-continuation.ts | 4 ++ src/tools/delegate-task/sync-task.test.ts | 51 ++++++++++++++++- src/tools/delegate-task/sync-task.ts | 4 ++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 0ddf5e7ed..7d0015503 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -244,6 +244,62 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("recovers from canonical aborted-operation message", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Recovered result") + }) + test("returns MessageAbortedError poll error when recovery fetch has no result", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 6e600616d..17c85f026 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -43,6 +43,10 @@ function shouldAttemptPollErrorRecovery(pollError: string): boolean { return true } + if (/^the operation was aborted\.?$/iu.test(trimmed)) { + return true + } + return false } diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 0143885dd..e1d792cfe 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -224,6 +224,53 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("recovers from canonical aborted-operation message", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toContain("Task completed in") + expect(result).toContain("Recovered result") + }) + test("does not recover from non-abort poll error containing abort-like words", async () => { const mockClient = { session: { @@ -607,7 +654,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(result).toContain("Result from ses_second") expect(deleteCalls).toContain("ses_first") - const finalMetadata = metadataCalls.at(-1) + const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") expect(finalMetadata.metadata.taskId).toBe("ses_second") expect(finalMetadata.metadata.model).toEqual({ @@ -758,7 +805,7 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "sisyphus-junior", initialModel, undefined, undefined, fallbackChain, deps) expect(result).toBe("Final retry failed") - const finalMetadata = metadataCalls.at(-1) + const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") expect(finalMetadata.metadata.taskId).toBe("ses_second") expect(finalMetadata.metadata.model).toEqual({ diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 58b52a07b..556ca8225 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -34,6 +34,10 @@ function shouldAttemptPollErrorRecovery(pollError: string): boolean { return true } + if (/^the operation was aborted\.?$/iu.test(trimmed)) { + return true + } + return false } From 8e47d3a16630eb913a3b6306c9476eb560a2eaaf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:10:47 +0900 Subject: [PATCH 5/6] fix(delegate-task): require anchored text for abort recovery --- .../delegate-task/sync-continuation.test.ts | 54 +++++++++++++++++++ src/tools/delegate-task/sync-continuation.ts | 3 ++ .../delegate-task/sync-result-fetcher.ts | 7 +++ 3 files changed, 64 insertions(+) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 7d0015503..967b30fb4 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -357,6 +357,60 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("does not recover abort poll error when anchor cannot be established", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("messages unavailable") + }, + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + let fetchSyncResultCalled = false + + const deps = { + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: true as const, textContent: "Recovered result" } + }, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toBe("The operation was aborted.") + expect(fetchSyncResultCalled).toBe(false) + }) + test("removes toast on successful completion", async () => { //#given - mock successful completion with messages growing after anchor const mockClient = { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 17c85f026..817a7e95d 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -188,6 +188,9 @@ export async function executeSyncContinuation( anchorMessageCount, }, syncPollTimeoutMs) if (pollError && shouldAttemptPollErrorRecovery(pollError)) { + if (anchorMessageCount === undefined) { + return pollError + } const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) if (!recoveredResult.ok) { return pollError diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index f2274eae6..9631d3637 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -56,5 +56,12 @@ export async function fetchSyncResult( } } + if (!textContent) { + return { + ok: false, + error: `No assistant text output found in completed response.\n\nSession ID: ${sessionID}`, + } + } + return { ok: true, textContent } } From 8b1696f5e57471455cf9fcfe7a995e3fff18e012 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:20:14 +0900 Subject: [PATCH 6/6] fix(delegate-task): prevent stale-text abort recovery --- src/tools/delegate-task/sync-continuation.ts | 4 +- .../delegate-task/sync-result-fetcher.test.ts | 61 +++++++++++++++++++ .../delegate-task/sync-result-fetcher.ts | 23 ++++++- src/tools/delegate-task/sync-task.ts | 4 +- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 817a7e95d..d2dd3b678 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -191,7 +191,9 @@ export async function executeSyncContinuation( if (anchorMessageCount === undefined) { return pollError } - const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) + const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount, { + strictAbortRecovery: true, + }) if (!recoveredResult.ok) { return pollError } diff --git a/src/tools/delegate-task/sync-result-fetcher.test.ts b/src/tools/delegate-task/sync-result-fetcher.test.ts index 82a41f3f6..400c066ad 100644 --- a/src/tools/delegate-task/sync-result-fetcher.test.ts +++ b/src/tools/delegate-task/sync-result-fetcher.test.ts @@ -141,4 +141,65 @@ describe("fetchSyncResult", () => { expect(result.ok).toBe(false) expect(result.error).toContain("No assistant response found") }) + + test("strict abort recovery: does not fall back to older text when latest assistant is error", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "Older text" }], + }, + { + info: { + id: "msg_003", + role: "assistant", + time: { created: 3000 }, + error: { name: "MessageAbortedError", message: "The operation was aborted." }, + }, + parts: [], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 1, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("Latest assistant message is an error") + }) + + test("strict abort recovery: requires latest assistant text output", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "tool", toolCallId: "t1", toolName: "x", state: "output-available", input: {}, output: {} }], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 0, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("No assistant text output found in latest response") + }) }) diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 9631d3637..f236d6724 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -5,7 +5,8 @@ import { normalizeSDKResponse } from "../../shared" export async function fetchSyncResult( client: OpencodeClient, sessionID: string, - anchorMessageCount?: number + anchorMessageCount?: number, + options?: { strictAbortRecovery?: boolean } ): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> { const messagesResult = await client.session.messages({ path: { id: sessionID }, @@ -44,6 +45,26 @@ export async function fetchSyncResult( return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } } + if (options?.strictAbortRecovery) { + if (lastMessage.info && "error" in lastMessage.info) { + return { + ok: false, + error: `Latest assistant message is an error; refusing abort recovery.\n\nSession ID: ${sessionID}`, + } + } + + const lastTextParts = lastMessage.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const lastContent = lastTextParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + if (!lastContent) { + return { + ok: false, + error: `No assistant text output found in latest response.\n\nSession ID: ${sessionID}`, + } + } + + return { ok: true, textContent: lastContent } + } + // Search assistant messages (newest first) for one with text/reasoning content. // The last assistant message may only contain tool calls with no text. let textContent = "" diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 556ca8225..dcc4b31e8 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -240,7 +240,9 @@ export async function executeSyncTask( }, syncPollTimeoutMs) if (pollError) { if (shouldAttemptPollErrorRecovery(pollError)) { - const recoveredResult = await deps.fetchSyncResult(client, activeSessionID) + const recoveredResult = await deps.fetchSyncResult(client, activeSessionID, undefined, { + strictAbortRecovery: true, + }) if (recoveredResult.ok) { const duration = formatDuration(startTime)