diff --git a/src/triage/failure.ts b/src/triage/failure.ts index bff55da..4ef6125 100644 --- a/src/triage/failure.ts +++ b/src/triage/failure.ts @@ -10,6 +10,42 @@ import type { IssueDetails } from '../github/issues.ts'; export const MAX_TRIAGE_FAILURES = 3; export const TRIAGE_FAILURE_MARKER = ''; +/** + * Render an error before it crosses a Workflow step boundary. Cloudflare + * serializes step failures down to a name and message, so an Error.cause left + * attached to AgentRunError would otherwise disappear before triage records + * the failure on GitHub. + */ +export function formatErrorWithCauses(error: unknown): string { + const parts: string[] = []; + const seen = new Set(); + let current: unknown = error; + + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof Error) { + parts.push(`${current.name}: ${current.message}`); + current = current.cause; + continue; + } + if (typeof current === 'object') { + const record = current as Record; + const name = typeof record.name === 'string' ? record.name : 'Error'; + const message = + typeof record.message === 'string' + ? record.message + : JSON.stringify(record); + parts.push(`${name}: ${message}`); + current = record.cause; + continue; + } + parts.push(String(current)); + break; + } + + return parts.join('\nCaused by: '); +} + export function countTriageFailures(issue: IssueDetails): number { return issue.comments.filter( (comment) => diff --git a/src/triage/workflow.ts b/src/triage/workflow.ts index 37ee0df..63da8ad 100644 --- a/src/triage/workflow.ts +++ b/src/triage/workflow.ts @@ -58,6 +58,7 @@ import { import { defaultTriageSkill } from './default-skill.ts'; import { countTriageFailures, + formatErrorWithCauses, formatFailureComment, MAX_TRIAGE_FAILURES, } from './failure.ts'; @@ -349,7 +350,7 @@ export class TriageWorkflow extends WorkflowEntrypoint< progressComment, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = formatErrorWithCauses(error); await step.do('record triage failure', STEP_RETRIES, async () => { const api = await client(); const attempt = Math.min(issue.failureCount + 1, MAX_TRIAGE_FAILURES); @@ -1624,13 +1625,23 @@ async function runPipelineStep( timeout: readTimeout, }, async () => { - const reply = await agent.read(receipt); - // Step results must be JSON-serializable; every pipeline schema is a - // plain object, so the cast is safe. - return extractLastWrite(channel, reply.data, schema) as unknown as Record< - string, - string - >; + try { + const reply = await agent.read(receipt); + // Step results must be JSON-serializable; every pipeline schema is a + // plain object, so the cast is safe. + return extractLastWrite( + channel, + reply.data, + schema, + ) as unknown as Record; + } catch (error) { + // Preserve Flue's settlement cause in the message before Workflows + // serializes the failed attempt and drops Error.cause. + throw new Error( + `Pipeline stage "${name}" failed (submission ${receipt.submissionId}).\n${formatErrorWithCauses(error)}`, + { cause: error }, + ); + } }, ); return value as unknown as v.InferOutput; diff --git a/tests/triage-failure.test.ts b/tests/triage-failure.test.ts index d7226e4..5343c6e 100644 --- a/tests/triage-failure.test.ts +++ b/tests/triage-failure.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { IssueDetails } from '../src/github/issues.ts'; import { countTriageFailures, + formatErrorWithCauses, formatFailureComment, MAX_TRIAGE_FAILURES, TRIAGE_FAILURE_MARKER, @@ -51,6 +52,25 @@ describe('triage failure bookkeeping', () => { ).toBe(0); }); + it('preserves nested error causes in a readable message', () => { + const settlement = { + name: 'SubmissionTimeoutError', + message: 'The agent exceeded its 45-minute deadline.', + }; + const run = new Error('Agent run failed.', { cause: settlement }); + run.name = 'AgentRunError'; + + expect(formatErrorWithCauses(run)).toBe( + 'AgentRunError: Agent run failed.\nCaused by: SubmissionTimeoutError: The agent exceeded its 45-minute deadline.', + ); + }); + + it('formats non-Error failures', () => { + expect(formatErrorWithCauses('sandbox disconnected')).toBe( + 'sandbox disconnected', + ); + }); + it('embeds the marker and the retry policy in failure comments', () => { const retryable = formatFailureComment('boom', 1); expect(retryable).toContain(TRIAGE_FAILURE_MARKER);