fbec112bc2
Root cause: `formatTaskResult` and `formatFullSession` both call
`client.session.messages({ path: { id: task.sessionId } })` with no
timeout. The OpenCode SDK explicitly disables fetch timeout
(`req.timeout = false` in `packages/sdk/js/src/client.ts:37`), so when
`session.processor` enters an "Aborted process" loop (same condition
patched in #4086 for the Read hot path) every subsequent
`session.messages` call hangs forever.
User-visible impact: the parent agent running a heavy slash command
such as `/init-deep` fires many background `task` agents in parallel
and then calls `background_output(task_id, block=true)` for each. The
existing `timeoutMs` (max 10min) caps only the polling loop that waits
for the child task to transition out of `running`. Once the child is
`completed`, the loop exits and the code falls through to
`formatTaskResult` / `formatFullSession`. The 10min cap does not apply
to that post-completion fetch, so a wedged `session.messages` leaves
the tool call hanging indefinitely. The parent session appears stuck
to the user (the symptom they report on `/init-deep ultrafucking
deep`).
Fix: race the underlying `session.messages` call against a 5s timeout
through a new `withSdkCallTimeout` helper local to
`src/tools/background-task/` (mirrors `withFetchTimeout` in
`src/shared/dynamic-truncator.ts` and `withDispatchTimeout` in
`src/shared/prompt-async-gate.ts`). On timeout the caller returns a
clearly-marked `"Error fetching messages: ... timed out after 5000ms"`
fallback so the agent can recognise the failure and continue instead
of waiting forever.
Tests:
- New `sdk-call-timeout.test.ts` pins the fix with three BDD cases
using a never-settling mock and the new
`_setBackgroundOutputFetchTimeoutMsForTesting(50)` override:
(1) `formatTaskResult` resolves to the timeout fallback under the
fetch budget, (2) same for `formatFullSession`, (3) two parallel
callers against the wedged client both resolve cleanly.
- All three failed (timed out) before the fix and pass in 51ms each
after.
Verification:
- `bun test src/tools/background-task/` -- 33 pass.
- `bun test` (full suite) -- 7014 pass, 1 skip, 0 fail across 723
files.
- `bun run typecheck` -- clean (tsgo --noEmit).
- LSP diagnostics on the four changed files -- 0 errors.
- Manual QA harness `.local-ignore/init-deep-hang-qa.ts` (gitignored)
drove production default 5s timeout against a wedged client:
serial `formatTaskResult` resolved in 5001ms, serial
`formatFullSession` in 5002ms, 10 parallel `formatTaskResult` calls
all resolved within 5002ms with the timeout fallback. Without the
fix every call hangs indefinitely.
Refs: builds on #4086 (dynamic-truncator) which patched the Read hot
path of the same SDK hang.
139 lines
4.2 KiB
TypeScript
139 lines
4.2 KiB
TypeScript
import type { BackgroundTask } from "../../features/background-agent"
|
|
import { extractErrorMessage } from "../../features/background-agent/error-classifier"
|
|
import { consumeNewMessages } from "../../shared/session-cursor"
|
|
import type { BackgroundOutputClient, BackgroundOutputMessagesResult } from "./clients"
|
|
import { extractMessages, getErrorMessage } from "./session-messages"
|
|
import { formatDuration } from "./time-format"
|
|
import { getBackgroundOutputFetchTimeoutMs, withSdkCallTimeout } from "./with-sdk-call-timeout"
|
|
|
|
function getTimeString(value: unknown): string {
|
|
return typeof value === "string" ? value : ""
|
|
}
|
|
|
|
export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise<string> {
|
|
if (!task.sessionId) {
|
|
return `Error: Task has no sessionID`
|
|
}
|
|
|
|
let messagesResult: BackgroundOutputMessagesResult
|
|
try {
|
|
messagesResult = await withSdkCallTimeout(
|
|
client.session.messages({ path: { id: task.sessionId } }),
|
|
getBackgroundOutputFetchTimeoutMs(),
|
|
)
|
|
} catch (error) {
|
|
return `Error fetching messages: ${error instanceof Error ? error.message : String(error)}`
|
|
}
|
|
|
|
const errorMessage = getErrorMessage(messagesResult)
|
|
if (errorMessage) {
|
|
return `Error fetching messages: ${errorMessage}`
|
|
}
|
|
|
|
const messages = extractMessages(messagesResult)
|
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
return `Task Result
|
|
|
|
Task ID: ${task.id}
|
|
Description: ${task.description}
|
|
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
|
Session ID: ${task.sessionId}
|
|
|
|
---
|
|
|
|
(No messages found)`
|
|
}
|
|
|
|
const relevantMessages = messages.filter((m) => m.info?.role === "assistant" || m.info?.role === "tool")
|
|
if (relevantMessages.length === 0) {
|
|
return `Task Result
|
|
|
|
Task ID: ${task.id}
|
|
Description: ${task.description}
|
|
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
|
Session ID: ${task.sessionId}
|
|
|
|
---
|
|
|
|
(No assistant or tool response found)`
|
|
}
|
|
|
|
const sortedMessages = [...relevantMessages].sort((a, b) => {
|
|
const timeA = getTimeString(a.info?.time)
|
|
const timeB = getTimeString(b.info?.time)
|
|
return timeA.localeCompare(timeB)
|
|
})
|
|
|
|
const sessionError = sortedMessages
|
|
.filter((message) => message.info?.role === "assistant" && message.info?.error)
|
|
.map((message) => extractErrorMessage(message.info?.error))
|
|
.find((message): message is string => typeof message === "string" && message.length > 0)
|
|
if (sessionError) {
|
|
return `Task Result
|
|
|
|
Task ID: ${task.id}
|
|
Description: ${task.description}
|
|
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
|
Session ID: ${task.sessionId}
|
|
|
|
---
|
|
|
|
Session error: ${sessionError}`
|
|
}
|
|
|
|
const newMessages = consumeNewMessages(task.sessionId, sortedMessages)
|
|
if (newMessages.length === 0) {
|
|
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
|
return `Task Result
|
|
|
|
Task ID: ${task.id}
|
|
Description: ${task.description}
|
|
Duration: ${duration}
|
|
Session ID: ${task.sessionId}
|
|
|
|
---
|
|
|
|
(No new output since last check)`
|
|
}
|
|
|
|
const extractedContent: string[] = []
|
|
for (const message of newMessages) {
|
|
for (const part of message.parts ?? []) {
|
|
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
|
extractedContent.push(part.text)
|
|
continue
|
|
}
|
|
|
|
if (part.type === "tool_result") {
|
|
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
|
if (typeof toolResult.content === "string" && toolResult.content) {
|
|
extractedContent.push(toolResult.content)
|
|
continue
|
|
}
|
|
|
|
if (Array.isArray(toolResult.content)) {
|
|
for (const block of toolResult.content) {
|
|
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
|
extractedContent.push(block.text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const textContent = extractedContent.filter((text) => text.length > 0).join("\n\n")
|
|
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
|
|
|
return `Task Result
|
|
|
|
Task ID: ${task.id}
|
|
Description: ${task.description}
|
|
Duration: ${duration}
|
|
Session ID: ${task.sessionId}
|
|
|
|
---
|
|
|
|
${textContent || "(No text output)"}`
|
|
}
|