mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
739 lines
22 KiB
TypeScript
739 lines
22 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../src/integrations/vela-errors.js', () => ({
|
|
classifyAmrAccountFailure(text: string) {
|
|
const value = String(text || '').toLowerCase();
|
|
if (value.includes('insufficient balance')) {
|
|
return { code: 'AMR_INSUFFICIENT_BALANCE' as const };
|
|
}
|
|
if (value.includes('authentication required') || value.includes('not authenticated') || value.includes('unauthorized')) {
|
|
return { code: 'AMR_AUTH_REQUIRED' as const };
|
|
}
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../src/runtimes/auth.js', () => ({
|
|
classifyAgentServiceFailure(text: string) {
|
|
const value = String(text || '').toLowerCase();
|
|
if (value.includes('authentication required') || value.includes('not authenticated')) {
|
|
return 'AGENT_AUTH_REQUIRED' as const;
|
|
}
|
|
if (value.includes('http 429') || value.includes('too many requests') || value.includes('session limit')) {
|
|
return 'RATE_LIMITED' as const;
|
|
}
|
|
if (value.includes('503 upstream unavailable') || value.includes('upstream unavailable')) {
|
|
return 'UPSTREAM_UNAVAILABLE' as const;
|
|
}
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
import {
|
|
classifyRunFailure,
|
|
type RunEventForFailureClassification,
|
|
} from '../src/run-failure-classification.js';
|
|
|
|
function errorEvent(
|
|
code: string,
|
|
message: string,
|
|
retryable?: boolean,
|
|
): RunEventForFailureClassification {
|
|
return {
|
|
event: 'error',
|
|
data: {
|
|
message,
|
|
error: {
|
|
code,
|
|
message,
|
|
...(retryable !== undefined ? { retryable } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function classify(
|
|
code: string | null,
|
|
message = '',
|
|
events: RunEventForFailureClassification[] = code
|
|
? [errorEvent(code, message)]
|
|
: [],
|
|
) {
|
|
return classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: message || null,
|
|
errorCode: code,
|
|
exitCode: 1,
|
|
signal: null,
|
|
},
|
|
...(code ? { errorCode: code } : {}),
|
|
agentId: 'claude',
|
|
events,
|
|
});
|
|
}
|
|
|
|
describe('classifyRunFailure', () => {
|
|
it('does not classify successful runs as failures', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'success',
|
|
status: { status: 'succeeded' },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('classifies user cancellation separately from failures', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'cancelled',
|
|
status: { status: 'canceled' },
|
|
}),
|
|
).toEqual({
|
|
failure_category: 'user_cancel',
|
|
failure_detail: 'user_cancelled',
|
|
failure_stage: 'finalize',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
|
|
it('prefers user cancellation over timeout-flavored status text when the run result is cancelled', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'cancelled',
|
|
status: {
|
|
status: 'canceled',
|
|
error: 'Agent stalled without emitting any new output for 120s.',
|
|
signal: 'SIGTERM',
|
|
exitCode: null,
|
|
errorCode: 'AGENT_SIGNAL_SIGTERM',
|
|
},
|
|
errorCode: 'AGENT_SIGNAL_SIGTERM',
|
|
events: [
|
|
errorEvent(
|
|
'AGENT_SIGNAL_SIGTERM',
|
|
'Agent stalled without emitting any new output for 120s.',
|
|
true,
|
|
),
|
|
],
|
|
}),
|
|
).toEqual({
|
|
failure_category: 'user_cancel',
|
|
failure_detail: 'user_cancelled',
|
|
failure_stage: 'finalize',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('prefers structured model-unavailable codes over timeout-like free text', () => {
|
|
expect(
|
|
classify(
|
|
'AMR_MODEL_UNAVAILABLE',
|
|
'Model selection timed out while the provider reported the model was unavailable.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'model_unavailable',
|
|
failure_stage: 'model_select',
|
|
retryable: false,
|
|
user_action: 'switch_model',
|
|
});
|
|
});
|
|
|
|
it('prefers prompt-too-large codes over empty-output fallback text', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_PROMPT_TOO_LARGE',
|
|
'The agent completed without producing any output because the context window exceeded the limit.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'prompt_too_large',
|
|
failure_stage: 'prompt_send',
|
|
retryable: false,
|
|
user_action: 'reduce_context',
|
|
});
|
|
});
|
|
|
|
it('maps auth-required failures to login guidance', () => {
|
|
expect(classify('AGENT_AUTH_REQUIRED')).toMatchObject({
|
|
failure_category: 'auth',
|
|
failure_detail: 'auth_required',
|
|
failure_stage: 'session_init',
|
|
retryable: false,
|
|
user_action: 'login',
|
|
});
|
|
});
|
|
|
|
|
|
it('lets explicit auth classification win over a generic execution-failed code', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Authentication required before starting the session.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'auth',
|
|
failure_detail: 'auth_required',
|
|
failure_stage: 'session_init',
|
|
retryable: false,
|
|
user_action: 'login',
|
|
});
|
|
});
|
|
|
|
it('maps auth subtypes from profile and token text', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Claude Code may be using a different or stale local profile than your terminal.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'auth',
|
|
failure_detail: 'stale_profile',
|
|
user_action: 'login',
|
|
});
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Your access token could not be refreshed because your refresh token was already used.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'auth',
|
|
failure_detail: 'refresh_token_reused',
|
|
user_action: 'login',
|
|
});
|
|
});
|
|
|
|
it('recognizes model-not-found text even when the outer error code is generic', () => {
|
|
expect(
|
|
classify('AGENT_EXECUTION_FAILED', 'Model not found: vela/deepseek-v3-2'),
|
|
).toMatchObject({
|
|
failure_category: 'model_unavailable',
|
|
failure_detail: 'model_not_found',
|
|
failure_stage: 'model_select',
|
|
retryable: false,
|
|
user_action: 'switch_model',
|
|
});
|
|
});
|
|
|
|
it('recovers rate-limit and session-limit signals from generic error codes', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
"You've hit your session limit; resets at 3:10am.",
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'rate_limit',
|
|
failure_detail: 'hard_quota',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('treats ordinary 429 rate limits as retryable', () => {
|
|
expect(classify('RATE_LIMITED', 'HTTP 429: too many requests')).toMatchObject({
|
|
failure_category: 'rate_limit',
|
|
failure_detail: 'rate_limit_429',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps upstream failures to retry guidance', () => {
|
|
expect(classify('UPSTREAM_UNAVAILABLE', 'HTTP 503 upstream unavailable')).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'upstream_5xx',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps stream disconnects to upstream detail', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Reconnecting... 1/5 (stream disconnected before completion: tls handshake eof)',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'stream_disconnected',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'json-rpc id 4: opencode event stream: opencode session error: {"sessionID":"ses_17838b40effecRNQTUFyauY0zL","error":{"name":"UnknownError","data":{"message":"\\"[code=upstream_error] Error reading stream: http2: response body closed\\""}}}',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'stream_disconnected',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'json-rpc id 4: Cannot connect to API: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'stream_disconnected',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('promotes AMR exit 130 connection resets into upstream stream disconnects', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXIT_130',
|
|
'json-rpc id 4: Connection reset by server',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'stream_disconnected',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('promotes opencode API 4xx session errors out of process-exit fallback', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXIT_130',
|
|
'json-rpc id 4: opencode event stream: opencode session error: {"sessionID":"ses_16a081173ffeQy9mUJTmYowj5p","error":{"name":"APIError","data":{"message":"Not Found","statusCode":404,"isRetryable":false,"responseBody":"<html><head><title>404 Not Found</title></head>"}}}',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'upstream_client_error',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
|
|
expect(
|
|
classify(
|
|
'AGENT_EXIT_130',
|
|
'json-rpc id 4: opencode event stream: opencode session error: {"error":{"name":"APIError","data":{"message":"Bad Request","statusCode":400,"isRetryable":false,"responseBody":"<html><head><title>400 Bad Request</title></head>"}}}',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'upstream_client_error',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('uses structured opencode API error data when raw error text is sparse', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: null,
|
|
errorCode: 'AGENT_EXIT_130',
|
|
exitCode: 130,
|
|
signal: null,
|
|
},
|
|
errorCode: 'AGENT_EXIT_130',
|
|
agentId: 'opencode',
|
|
events: [
|
|
{
|
|
event: 'error',
|
|
data: {
|
|
error: {
|
|
name: 'APIError',
|
|
data: {
|
|
message: 'Not Found',
|
|
statusCode: 404,
|
|
isRetryable: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'upstream_client_error',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('maps AMR model catalog outages to provider routing failures', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXIT_130',
|
|
'json-rpc id 2: AMR model catalog is unavailable.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'provider_routing_error',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps AMR insufficient balance to recharge guidance', () => {
|
|
expect(
|
|
classify('AMR_INSUFFICIENT_BALANCE', 'insufficient wallet balance'),
|
|
).toMatchObject({
|
|
failure_category: 'insufficient_balance',
|
|
failure_detail: 'amr_insufficient_balance',
|
|
retryable: false,
|
|
user_action: 'recharge',
|
|
});
|
|
});
|
|
|
|
it('maps unavailable model errors to switch-model guidance', () => {
|
|
expect(classify('AMR_MODEL_UNAVAILABLE', 'model is not available')).toMatchObject({
|
|
failure_category: 'model_unavailable',
|
|
failure_detail: 'model_not_found',
|
|
failure_stage: 'model_select',
|
|
retryable: false,
|
|
user_action: 'switch_model',
|
|
});
|
|
});
|
|
|
|
it('maps prompt-size failures to reduce-context guidance', () => {
|
|
expect(classify('AGENT_PROMPT_TOO_LARGE', 'context window exceeded')).toMatchObject({
|
|
failure_category: 'prompt_too_large',
|
|
failure_detail: 'prompt_too_large',
|
|
failure_stage: 'prompt_send',
|
|
retryable: false,
|
|
user_action: 'reduce_context',
|
|
});
|
|
});
|
|
|
|
it('maps empty output to an explicit retryable category', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Agent completed without producing any output.',
|
|
[errorEvent('AGENT_EXECUTION_FAILED', 'Agent completed without producing any output.', true)],
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'empty_output',
|
|
failure_detail: 'empty_output',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps signal exits and stall text to timeout', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: 'Agent stalled without emitting any new output for 120s.',
|
|
signal: 'SIGTERM',
|
|
exitCode: null,
|
|
errorCode: null,
|
|
},
|
|
errorCode: 'AGENT_SIGNAL_SIGTERM',
|
|
events: [],
|
|
}),
|
|
).toMatchObject({
|
|
failure_category: 'timeout',
|
|
failure_detail: 'inactivity_timeout',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
|
|
it('honors the latest explicit non-retryable hint for timeout failures', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: 'Agent stalled without emitting any new output for 120s.',
|
|
signal: 'SIGTERM',
|
|
exitCode: null,
|
|
errorCode: null,
|
|
},
|
|
errorCode: 'AGENT_SIGNAL_SIGTERM',
|
|
agentId: 'claude',
|
|
events: [
|
|
errorEvent('AGENT_EXECUTION_FAILED', 'transient upstream hiccup', true),
|
|
errorEvent(
|
|
'AGENT_SIGNAL_SIGTERM',
|
|
'Agent stalled without emitting any new output for 120s.',
|
|
false,
|
|
),
|
|
],
|
|
}),
|
|
).toMatchObject({
|
|
failure_category: 'timeout',
|
|
failure_detail: 'inactivity_timeout',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('uses the latest retryable hint for tool execution failures', () => {
|
|
expect(
|
|
classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: 'Tool error: MCP connector failed while listing files.',
|
|
exitCode: 1,
|
|
signal: null,
|
|
errorCode: 'AGENT_EXECUTION_FAILED',
|
|
},
|
|
errorCode: 'AGENT_EXECUTION_FAILED',
|
|
agentId: 'claude',
|
|
events: [
|
|
errorEvent('AGENT_EXECUTION_FAILED', 'tool bootstrap failed', false),
|
|
errorEvent(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Tool error: MCP connector failed while listing files.',
|
|
true,
|
|
),
|
|
],
|
|
}),
|
|
).toMatchObject({
|
|
failure_category: 'tool_error',
|
|
failure_detail: 'tool_error',
|
|
failure_stage: 'tool_execution',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps invalid agent config to fix-config guidance', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Error loading config.toml: unknown variant `priority`, expected `fast` or `flex`\nin `service_tier`',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'agent_config_invalid',
|
|
failure_stage: 'session_init',
|
|
retryable: false,
|
|
user_action: 'fix_config',
|
|
});
|
|
});
|
|
|
|
it('maps fabricated role marker termination to a retryable protocol guard detail', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Run terminated: model emitted fabricated role marker (`## user`). No further tokens or tool calls accepted from this turn.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'fabricated_role_marker',
|
|
failure_stage: 'child_close',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('maps missing generated plugin artifacts to artifact-write tool failures', () => {
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'Plugin authoring ended before generating the required generated-plugin artifacts.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'tool_error',
|
|
failure_detail: 'plugin_artifact_missing',
|
|
failure_stage: 'artifact_write',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('keeps process exits as an explicit fallback category', () => {
|
|
expect(classify('AGENT_EXIT_1', 'process exited with code 1')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'exit_code',
|
|
failure_stage: 'child_close',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('adds process-exit details for spawn and protocol failures', () => {
|
|
expect(classify('AGENT_EXECUTION_FAILED', 'spawn failed: spawn ENOEXEC')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'spawn_enoexec',
|
|
});
|
|
expect(classify('AGENT_EXECUTION_FAILED', 'json-rpc id 2: Internal error')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'agent_protocol_error',
|
|
});
|
|
expect(
|
|
classify(
|
|
'AGENT_EXECUTION_FAILED',
|
|
'json-rpc id 4: opencode event stream: reply opencode permission: opencode POST /session/ses_17891e641ffe507UiYkoj7Qb5w/permissions/per_e876f835100166WeTqK11P7ZvV returned HTTP 404: {"_tag":"PermissionNotFoundError","requestID":"per_e876f835100166WeTqK11P7ZvV","message":"Permission request not found: per_e876f835100166WeTqK11P7ZvV"}',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'permission_request_not_found',
|
|
failure_stage: 'child_close',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
expect(classify('AGENT_EXECUTION_FAILED', 'stdin: write EOF')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'stdin_write_eof',
|
|
});
|
|
});
|
|
|
|
it('falls back to unknown when no meaningful signal is available', () => {
|
|
expect(classify('SOMETHING_NEW', '')).toMatchObject({
|
|
failure_category: 'unknown',
|
|
failure_detail: 'unknown',
|
|
failure_stage: 'finalize',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
});
|
|
|
|
// Regression coverage for the signal/interrupt mis-attribution bug: process
|
|
// crashes (SIGKILL/SIGSEGV/...), daemon shutdowns (SIGTERM), and cancellations
|
|
// (SIGINT / exit 130) must NOT be laundered into a retryable `timeout`. Each
|
|
// has a non-retryable home so the safe-retry policy never re-spawns an
|
|
// OOM/crash that is near-certain to reproduce.
|
|
describe('classifyRunFailure — signal and interrupt attribution', () => {
|
|
function classifySignal(
|
|
signal: string,
|
|
message = '',
|
|
exitCode: number | null = null,
|
|
) {
|
|
const errorCode = `AGENT_SIGNAL_${signal}`;
|
|
return classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: message || null,
|
|
signal,
|
|
exitCode,
|
|
errorCode: null,
|
|
},
|
|
errorCode,
|
|
agentId: 'claude',
|
|
events: [],
|
|
});
|
|
}
|
|
|
|
function classifyExit(
|
|
exitCode: number,
|
|
message = '',
|
|
) {
|
|
const errorCode = `AGENT_EXIT_${exitCode}`;
|
|
return classifyRunFailure({
|
|
result: 'failed',
|
|
status: {
|
|
status: 'failed',
|
|
error: message || null,
|
|
signal: null,
|
|
exitCode,
|
|
errorCode: null,
|
|
},
|
|
errorCode,
|
|
agentId: 'claude',
|
|
events: [],
|
|
});
|
|
}
|
|
|
|
it('classifies SIGKILL as a non-retryable process kill, not a timeout', () => {
|
|
expect(classifySignal('SIGKILL', 'Killed')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'signal_killed',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('classifies hard crash signals as non-retryable process crashes', () => {
|
|
for (const signal of ['SIGSEGV', 'SIGABRT', 'SIGILL', 'SIGTRAP', 'SIGBUS']) {
|
|
expect(classifySignal(signal, 'core dumped')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'process_crashed',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
}
|
|
});
|
|
|
|
it('classifies SIGINT as a non-retryable interruption', () => {
|
|
expect(classifySignal('SIGINT', 'Interrupted')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'interrupted',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('classifies exit code 130 (128+SIGINT) as a non-retryable interruption', () => {
|
|
expect(classifyExit(130, 'Request was cancelled')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'interrupted',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('routes an interrupted run whose text names a stream disconnect to upstream', () => {
|
|
expect(
|
|
classifyExit(130, 'stream disconnected before completion'),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
failure_detail: 'stream_disconnected',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
expect(
|
|
classifySignal('SIGINT', 'Upstream request failed; aborting stream'),
|
|
).toMatchObject({
|
|
failure_category: 'upstream_unavailable',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
|
|
it('classifies a plain SIGTERM (not inactivity) as a non-retryable termination', () => {
|
|
expect(classifySignal('SIGTERM', 'Terminated')).toMatchObject({
|
|
failure_category: 'process_exit',
|
|
failure_detail: 'terminated_unknown',
|
|
retryable: false,
|
|
user_action: 'none',
|
|
});
|
|
});
|
|
|
|
it('still classifies an inactivity-driven SIGTERM as a retryable timeout', () => {
|
|
expect(
|
|
classifySignal(
|
|
'SIGTERM',
|
|
'Agent stalled without emitting any new output for 120s.',
|
|
),
|
|
).toMatchObject({
|
|
failure_category: 'timeout',
|
|
failure_detail: 'inactivity_timeout',
|
|
failure_stage: 'first_token_wait',
|
|
retryable: true,
|
|
user_action: 'retry',
|
|
});
|
|
});
|
|
});
|