Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/triage/failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ import type { IssueDetails } from '../github/issues.ts';
export const MAX_TRIAGE_FAILURES = 3;
export const TRIAGE_FAILURE_MARKER = '<!-- factory:triage-failed -->';

/**
* 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<unknown>();
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<string, unknown>;
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) =>
Expand Down
27 changes: 19 additions & 8 deletions src/triage/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
import { defaultTriageSkill } from './default-skill.ts';
import {
countTriageFailures,
formatErrorWithCauses,
formatFailureComment,
MAX_TRIAGE_FAILURES,
} from './failure.ts';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1624,13 +1625,23 @@ async function runPipelineStep<S extends v.GenericSchema>(
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<string, string>;
} 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<S>;
Expand Down
20 changes: 20 additions & 0 deletions tests/triage-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading