From fd68da06cfaf67094c5f8440b926fc992523e97e Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Sun, 26 Jul 2026 19:37:36 -0700 Subject: [PATCH 1/3] feat(code-review): classify failures from the structured callback payload --- .../code-review-status/[reviewId]/route.ts | 9 ++ .../terminal-reason-from-failure.test.ts | 107 +++++++++++++++ .../terminal-reason-from-failure.ts | 121 +++++++++++++++++ .../src/routers/admin-code-reviews-router.ts | 125 +++++++++++++----- packages/db/src/schema-types.ts | 29 ++++ .../src/cloud-agent-next-client.ts | 18 +++ 6 files changed, 374 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts create mode 100644 apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts index 3759b995046..b4e1a82d44b 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts @@ -61,6 +61,7 @@ import { CALLBACK_TOKEN_SECRET } from '@/lib/config.server'; import { verifyCallbackToken } from '@kilocode/worker-utils/callback-token'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { appendPreviousReviewSummaryHistory } from '@/lib/code-reviews/summary/history'; +import { terminalReasonFromCloudAgentFailure } from '@/lib/code-reviews/terminal-reason-from-failure'; import { appendReviewSummaryFooter, buildReviewSummaryFooter, @@ -369,6 +370,14 @@ function normalizePayload(raw: StatusUpdatePayload): { terminalReason = 'model_not_found'; } + // Consume the structured failure cloud-agent-next already sends. This runs + // after the specific inferences above so they keep priority, and before the + // generic 'interrupted' fallback so an interrupt with a known cause is still + // attributed to that cause. + if (!terminalReason) { + terminalReason = terminalReasonFromCloudAgentFailure(failure, raw.errorMessage); + } + if (!terminalReason && raw.status === 'interrupted') { terminalReason = 'interrupted'; } diff --git a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts new file mode 100644 index 00000000000..08a3c2e11d3 --- /dev/null +++ b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts @@ -0,0 +1,107 @@ +import { + CLOUD_AGENT_FAILURE_CODES, + WORKSPACE_FAILURE_SUBTYPES, +} from '@kilocode/worker-utils/cloud-agent-failure'; +import { CODE_REVIEW_TERMINAL_REASONS } from '@kilocode/db/schema-types'; +import { terminalReasonFromCloudAgentFailure } from './terminal-reason-from-failure'; + +describe('terminalReasonFromCloudAgentFailure', () => { + it('returns undefined without a structured failure', () => { + expect(terminalReasonFromCloudAgentFailure(undefined)).toBeUndefined(); + expect(terminalReasonFromCloudAgentFailure({})).toBeUndefined(); + }); + + it('prefers the workspace subtype over the generic code', () => { + expect( + terminalReasonFromCloudAgentFailure({ + code: 'workspace_setup_failed', + subtype: 'sandbox_storage_full', + }) + ).toBe('workspace_capacity'); + + expect( + terminalReasonFromCloudAgentFailure({ + code: 'workspace_setup_failed', + subtype: 'git_authentication_failed', + }) + ).toBe('repository_auth_failed'); + }); + + it('falls back to the generic workspace reason without a subtype', () => { + expect(terminalReasonFromCloudAgentFailure({ code: 'workspace_setup_failed' })).toBe( + 'workspace_setup_failed' + ); + }); + + it('maps the codes behind the largest uncategorized buckets', () => { + expect(terminalReasonFromCloudAgentFailure({ code: 'assistant_error' })).toBe( + 'assistant_failed' + ); + expect(terminalReasonFromCloudAgentFailure({ code: 'wrapper_error_after_activity' })).toBe( + 'wrapper_failed' + ); + }); + + it('splits assistant failures by their safe message', () => { + expect( + terminalReasonFromCloudAgentFailure({ + code: 'assistant_error', + message: 'Assistant request was rate limited', + }) + ).toBe('assistant_rate_limited'); + + expect( + terminalReasonFromCloudAgentFailure({ + code: 'assistant_error', + message: 'Assistant service is unavailable', + }) + ).toBe('assistant_unavailable'); + }); + + it('reads the assistant message from the callback errorMessage when absent on the failure', () => { + expect( + terminalReasonFromCloudAgentFailure( + { code: 'assistant_error' }, + 'Assistant request was rate limited' + ) + ).toBe('assistant_rate_limited'); + }); + + it('degrades to the generic assistant reason on an unrecognized message', () => { + expect( + terminalReasonFromCloudAgentFailure({ + code: 'assistant_error', + message: 'Assistant request failed in some new way', + }) + ).toBe('assistant_failed'); + }); + + it('leaves unclassified failures to the callers message-based inference', () => { + expect(terminalReasonFromCloudAgentFailure({ code: 'unclassified' })).toBeUndefined(); + }); + + it('resolves every failure code to a valid terminal reason', () => { + const valid = new Set(CODE_REVIEW_TERMINAL_REASONS); + const resolved = CLOUD_AGENT_FAILURE_CODES.map(code => [ + code, + terminalReasonFromCloudAgentFailure({ code }), + ]); + + // Reported as a table so a future code addition names itself in the failure. + expect( + resolved.filter(([code, reason]) => + code === 'unclassified' ? reason !== undefined : !valid.has(reason as string) + ) + ).toEqual([]); + }); + + it('resolves every workspace subtype to a valid terminal reason', () => { + const valid = new Set(CODE_REVIEW_TERMINAL_REASONS); + const resolved = WORKSPACE_FAILURE_SUBTYPES.map(subtype => [ + subtype, + terminalReasonFromCloudAgentFailure({ code: 'workspace_setup_failed', subtype }), + ]); + + expect(resolved.filter(([, reason]) => !valid.has(reason as string))).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts new file mode 100644 index 00000000000..7982124fd2b --- /dev/null +++ b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts @@ -0,0 +1,121 @@ +/** + * Maps the structured failure cloud-agent-next already sends on the code review + * status callback onto a `CodeReviewTerminalReason`. + * + * cloud-agent-next classifies every terminal failure into a + * `CloudAgentSafeFailure` ({ stage, code, subtype, attempts, message }) and puts + * it on the callback payload. Historically the receiver read `failure.code` for + * exactly one case (workspace_setup_failed + sandbox_storage_full) and ignored + * the rest, so ~26% of failures were stored with a NULL terminal_reason and a + * human-readable sentence in error_message. The admin error analysis then tried + * to recover the category by pattern-matching that sentence, and everything it + * missed collapsed into "Other". + * + * This module consumes the structured value instead, so classification no longer + * round-trips through English. + */ + +import type { + CloudAgentFailureCode, + CloudAgentSafeFailure, + WorkspaceFailureSubtype, +} from '@kilocode/worker-utils/cloud-agent-failure'; +import type { CodeReviewTerminalReason } from '@kilocode/db/schema-types'; + +/** + * Workspace failures carry a subtype that is strictly more specific than the + * `workspace_setup_failed` code, so it is consulted first. + */ +const WORKSPACE_SUBTYPE_REASONS = { + sandbox_storage_full: 'workspace_capacity', + git_clone_timeout: 'repository_clone_failed', + git_checkout_timeout: 'repository_clone_failed', + git_network_failed: 'repository_clone_failed', + git_authentication_failed: 'repository_auth_failed', + git_pack_corrupt: 'repository_checkout_failed', + git_checkout_conflict: 'repository_checkout_failed', + git_branch_missing: 'repository_checkout_failed', + kilo_import_timeout: 'session_import_failed', + kilo_import_failed: 'session_import_failed', + setup_command_timeout: 'setup_command_failed', + setup_command_failed: 'setup_command_failed', + workspace_setup_unknown: 'workspace_setup_failed', +} as const satisfies Record; + +/** + * Every `CloudAgentFailureCode` maps to a reason. `satisfies Record<...>` makes + * this exhaustive: adding a code upstream without deciding its category here is + * a type error rather than a silent slide into "Other". + */ +const FAILURE_CODE_REASONS = { + sandbox_connect_failed: 'sandbox_connection', + workspace_setup_failed: 'workspace_setup_failed', + kilo_server_failed: 'runtime_startup_failed', + wrapper_start_failed: 'runtime_startup_failed', + invalid_delivery_request: 'delivery_failed', + session_metadata_missing: 'delivery_failed', + delivery_failure_unknown: 'delivery_failed', + model_missing: 'model_not_found', + wrapper_disconnected: 'wrapper_failed', + wrapper_no_output: 'wrapper_failed', + wrapper_ping_timeout: 'wrapper_failed', + wrapper_error_before_activity: 'wrapper_failed', + wrapper_error_after_activity: 'wrapper_failed', + assistant_error: 'assistant_failed', + missing_assistant_reply: 'assistant_no_reply', + payment_required: 'billing', + user_interrupt: 'user_cancelled', + container_shutdown: 'container_shutdown', + system_interrupt: 'interrupted', + unclassified: 'unknown', +} as const satisfies Record; + +/** + * `assistant_error` is a single code covering every provider-side failure, so on + * its own it would collapse rate limiting (by far the largest bucket) into one + * undifferentiated category. The finer reason lives only in the safe message. + * + * These are exact, whole-string matches against the constants produced by + * `classifyAssistantFailure` in + * services/cloud-agent-next/src/session/safe-failure-projection.ts — not + * substring heuristics. If a message there is reworded this map stops matching + * and callers fall back to the generic `assistant_failed`, which is no worse + * than the current behaviour. + * + * KEEP IN SYNC with classifyAssistantFailure's safeMessage values. + */ +const ASSISTANT_MESSAGE_REASONS: Record = { + 'Assistant request was rate limited': 'assistant_rate_limited', + 'Assistant service is unavailable': 'assistant_unavailable', + 'Assistant request timed out': 'assistant_timeout', + 'Assistant request was not authorized': 'assistant_unauthorized', + 'Assistant request was invalid': 'assistant_invalid_request', +}; + +/** + * Resolve a terminal reason from the structured callback failure. + * + * Returns undefined when there is no structured failure, or when the code is + * `unclassified` — in that case the caller's existing message-based inference + * still gets a chance rather than being pre-empted by a useless 'unknown'. + */ +export function terminalReasonFromCloudAgentFailure( + failure: CloudAgentSafeFailure | undefined, + errorMessage?: string +): CodeReviewTerminalReason | undefined { + if (!failure?.code) return undefined; + + if (failure.code === 'workspace_setup_failed' && failure.subtype) { + return WORKSPACE_SUBTYPE_REASONS[failure.subtype]; + } + + if (failure.code === 'assistant_error') { + // The projection puts the safe message on the failure; fall back to the + // callback's errorMessage for payloads that only carry the flattened text. + const message = (failure.message ?? errorMessage)?.trim(); + return (message && ASSISTANT_MESSAGE_REASONS[message]) || 'assistant_failed'; + } + + const reason = FAILURE_CODE_REASONS[failure.code]; + return reason === 'unknown' ? undefined : reason; +} diff --git a/apps/web/src/routers/admin-code-reviews-router.ts b/apps/web/src/routers/admin-code-reviews-router.ts index 01d5b4f8a5d..33769468273 100644 --- a/apps/web/src/routers/admin-code-reviews-router.ts +++ b/apps/web/src/routers/admin-code-reviews-router.ts @@ -78,46 +78,101 @@ const excludeModelNotFoundAttempt = sql`COALESCE(${cloud_agent_code_review_attem AND COALESCE(${cloud_agent_code_review_attempts.error_message}, '') NOT ILIKE '%model not found%'`; /** - * Categorize error messages into high-level buckets via SQL CASE WHEN. + * Display label for each structured terminal reason. + * + * cloud-agent-next sends a structured `failure` ({ stage, code, subtype }) on + * the status callback, and the callback now maps it onto `terminal_reason` + * (see apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts). Reading + * that column is exact, so it is consulted before any message matching. + * + * Reasons deliberately absent (user_cancelled, superseded) are not failures and + * never reach this expression. + */ +const TERMINAL_REASON_LABELS: Record = { + github_installation_required: 'Action Required', + github_ip_allow_list: 'Action Required', + gitlab_project_access_required: 'Action Required', + byok_invalid_key: 'Action Required', + selected_model_unavailable: 'Action Required', + billing: 'Billing', + model_not_found: 'Model Not Found', + workspace_capacity: 'Sandbox Capacity', + sandbox_connection: 'Sandbox Connection', + container_shutdown: 'Container Shutdown', + assistant_rate_limited: 'Rate Limited', + assistant_unavailable: 'Assistant Unavailable', + assistant_timeout: 'Assistant Timeout', + assistant_unauthorized: 'Assistant Unauthorized', + assistant_invalid_request: 'Assistant Invalid Request', + assistant_failed: 'Assistant Error', + assistant_no_reply: 'Assistant No Reply', + wrapper_failed: 'Agent Wrapper Failure', + runtime_startup_failed: 'Runtime Startup Failure', + delivery_failed: 'Delivery Failure', + workspace_setup_failed: 'Workspace Setup Failure', + repository_clone_failed: 'Repository Clone Failure', + repository_auth_failed: 'Repository Auth Failure', + repository_checkout_failed: 'Repository Checkout Failure', + repeated_repository_clone_timeout: 'Repository Clone Failure', + session_import_failed: 'Session Import Failure', + setup_command_failed: 'Setup Command Failure', + timeout: 'Timeout', + upstream_error: 'Upstream Server Error', + interrupted: 'Interrupted', +}; + +/** + * Categorize failures into high-level buckets. + * + * Structured `terminal_reason` wins. The message patterns below are a fallback + * for rows written before the callback consumed `failure.code` — they are + * intentionally not being extended, since new rows no longer need them. * Pattern matching is ordered from most-specific to least-specific. */ -const errorCategoryExpr = sql`CASE - WHEN ${cloud_agent_code_reviews.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'gitlab_project_access_required', 'byok_invalid_key', 'selected_model_unavailable') THEN 'Action Required' - WHEN ${cloud_agent_code_reviews.terminal_reason} = 'workspace_capacity' THEN 'Sandbox Capacity' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%sandbox storage full%' OR ${cloud_agent_code_reviews.error_message} LIKE '%admission rejected%' OR ${cloud_agent_code_reviews.error_message} LIKE '%storage full%' THEN 'Sandbox Capacity' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%connect to the sandbox%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Sandbox connection failed%' OR ${cloud_agent_code_reviews.error_message} LIKE '%container shut down%' THEN 'Sandbox Connection' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%429%' THEN 'Rate Limited' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_reviews.error_message} LIKE '%timed out%' THEN 'Timeout' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%context window%' OR ${cloud_agent_code_reviews.error_message} LIKE '%token limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%too large%' OR ${cloud_agent_code_reviews.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%authentication%' OR ${cloud_agent_code_reviews.error_message} LIKE '%401%' OR ${cloud_agent_code_reviews.error_message} LIKE '%403%' OR ${cloud_agent_code_reviews.error_message} LIKE '%permission%' THEN 'Auth / Permission Error' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%not found%' OR ${cloud_agent_code_reviews.error_message} LIKE '%404%' THEN 'Not Found' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%500%' OR ${cloud_agent_code_reviews.error_message} LIKE '%502%' OR ${cloud_agent_code_reviews.error_message} LIKE '%503%' OR ${cloud_agent_code_reviews.error_message} LIKE '%internal server%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Internal Server%' THEN 'Upstream Server Error' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%ECONNREFUSED%' OR ${cloud_agent_code_reviews.error_message} LIKE '%ECONNRESET%' OR ${cloud_agent_code_reviews.error_message} LIKE '%socket hang up%' OR ${cloud_agent_code_reviews.error_message} LIKE '%network%' THEN 'Network Error' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%parse%' OR ${cloud_agent_code_reviews.error_message} LIKE '%JSON%' OR ${cloud_agent_code_reviews.error_message} LIKE '%unexpected token%' THEN 'Parse Error' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%could not be delivered%' THEN 'Delivery Failure' - WHEN ${cloud_agent_code_reviews.error_message} LIKE '%repository_not_installed%' OR ${cloud_agent_code_reviews.error_message} LIKE '%app installation required%' THEN 'Action Required' - WHEN ${cloud_agent_code_reviews.error_message} IS NULL THEN 'Unknown Error' - ELSE 'Other' -END`; +function buildErrorCategoryExpr(terminalReasonColumn: PgColumn, errorMessageColumn: PgColumn) { + // Inlined as literals rather than bound parameters: this expression appears in + // both SELECT and GROUP BY, and parameter placeholders are renumbered per + // position, so Postgres would no longer recognize the two as the same + // expression. Both sides are compile-time constants from TERMINAL_REASON_LABELS, + // and quotes are doubled so a label containing an apostrophe stays safe. + const literal = (value: string) => sql.raw(`'${value.replaceAll("'", "''")}'`); + const reasonCases = Object.entries(TERMINAL_REASON_LABELS).map( + ([reason, label]) => + sql`WHEN ${terminalReasonColumn} = ${literal(reason)} THEN ${literal(label)}` + ); -const attemptErrorCategoryExpr = sql`CASE - WHEN ${cloud_agent_code_review_attempts.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'gitlab_project_access_required', 'byok_invalid_key', 'selected_model_unavailable') THEN 'Action Required' - WHEN ${cloud_agent_code_review_attempts.terminal_reason} = 'workspace_capacity' THEN 'Sandbox Capacity' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%sandbox storage full%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%admission rejected%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%storage full%' THEN 'Sandbox Capacity' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%connect to the sandbox%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Sandbox connection failed%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%container shut down%' THEN 'Sandbox Connection' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%429%' THEN 'Rate Limited' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%timed out%' THEN 'Timeout' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%context window%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%token limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%too large%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%authentication%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%401%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%403%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%permission%' THEN 'Auth / Permission Error' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%not found%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%404%' THEN 'Not Found' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%500%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%502%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%503%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%internal server%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Internal Server%' THEN 'Upstream Server Error' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%ECONNREFUSED%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%ECONNRESET%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%socket hang up%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%network%' THEN 'Network Error' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%parse%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%JSON%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%unexpected token%' THEN 'Parse Error' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%could not be delivered%' THEN 'Delivery Failure' - WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%repository_not_installed%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%app installation required%' THEN 'Action Required' - WHEN ${cloud_agent_code_review_attempts.error_message} IS NULL THEN 'Unknown Error' + return sql`CASE + ${sql.join(reasonCases, sql.raw(' '))} + WHEN ${errorMessageColumn} LIKE '%sandbox storage full%' OR ${errorMessageColumn} LIKE '%admission rejected%' OR ${errorMessageColumn} LIKE '%storage full%' THEN 'Sandbox Capacity' + WHEN ${errorMessageColumn} LIKE '%connect to the sandbox%' OR ${errorMessageColumn} LIKE '%Sandbox connection failed%' OR ${errorMessageColumn} LIKE '%container shut down%' THEN 'Sandbox Connection' + WHEN ${errorMessageColumn} LIKE '%rate limit%' OR ${errorMessageColumn} LIKE '%Rate limit%' OR ${errorMessageColumn} LIKE '%429%' THEN 'Rate Limited' + WHEN ${errorMessageColumn} LIKE '%timeout%' OR ${errorMessageColumn} LIKE '%Timeout%' OR ${errorMessageColumn} LIKE '%ETIMEDOUT%' OR ${errorMessageColumn} LIKE '%timed out%' THEN 'Timeout' + WHEN ${errorMessageColumn} LIKE '%context window%' OR ${errorMessageColumn} LIKE '%token limit%' OR ${errorMessageColumn} LIKE '%too large%' OR ${errorMessageColumn} LIKE '%maximum context length%' THEN 'Context Window Exceeded' + WHEN ${errorMessageColumn} LIKE '%authentication%' OR ${errorMessageColumn} LIKE '%401%' OR ${errorMessageColumn} LIKE '%403%' OR ${errorMessageColumn} LIKE '%permission%' THEN 'Auth / Permission Error' + WHEN ${errorMessageColumn} LIKE '%not found%' OR ${errorMessageColumn} LIKE '%404%' THEN 'Not Found' + WHEN ${errorMessageColumn} LIKE '%500%' OR ${errorMessageColumn} LIKE '%502%' OR ${errorMessageColumn} LIKE '%503%' OR ${errorMessageColumn} LIKE '%internal server%' OR ${errorMessageColumn} LIKE '%Internal Server%' THEN 'Upstream Server Error' + WHEN ${errorMessageColumn} LIKE '%ECONNREFUSED%' OR ${errorMessageColumn} LIKE '%ECONNRESET%' OR ${errorMessageColumn} LIKE '%socket hang up%' OR ${errorMessageColumn} LIKE '%network%' THEN 'Network Error' + WHEN ${errorMessageColumn} LIKE '%parse%' OR ${errorMessageColumn} LIKE '%JSON%' OR ${errorMessageColumn} LIKE '%unexpected token%' THEN 'Parse Error' + WHEN ${errorMessageColumn} LIKE '%could not be delivered%' THEN 'Delivery Failure' + WHEN ${errorMessageColumn} LIKE '%repository_not_installed%' OR ${errorMessageColumn} LIKE '%app installation required%' THEN 'Action Required' + -- Deliberately last: 'sandbox_error' is the orchestrator's generic bucket for a + -- failed session start, and the patterns above refine it into the specific + -- cause (most often Sandbox Capacity). Matching it earlier would mask that. + WHEN ${terminalReasonColumn} = 'sandbox_error' THEN 'Sandbox Error' + WHEN ${errorMessageColumn} IS NULL THEN 'Unknown Error' ELSE 'Other' END`; +} + +const errorCategoryExpr = buildErrorCategoryExpr( + cloud_agent_code_reviews.terminal_reason, + cloud_agent_code_reviews.error_message +); + +const attemptErrorCategoryExpr = buildErrorCategoryExpr( + cloud_agent_code_review_attempts.terminal_reason, + cloud_agent_code_review_attempts.error_message +); const FilterSchemaShape = { startDate: z.string().datetime(), // ISO datetime string diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index afe1eb3a20e..2ce8940826e 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -2001,6 +2001,30 @@ export const CODE_REVIEW_TERMINAL_REASONS = [ 'upstream_error', 'sandbox_error', 'workspace_capacity', + // Derived from the structured CloudAgentSafeFailure payload cloud-agent-next + // already sends on the status callback. Before these existed the callback + // dropped `failure.code` for every case except sandbox_storage_full, so these + // outcomes landed with a NULL terminal_reason and were only recoverable by + // pattern-matching the human-readable error_message in the admin router. + // See apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts. + 'assistant_failed', + 'assistant_rate_limited', + 'assistant_unavailable', + 'assistant_timeout', + 'assistant_unauthorized', + 'assistant_invalid_request', + 'assistant_no_reply', + 'wrapper_failed', + 'runtime_startup_failed', + 'sandbox_connection', + 'delivery_failed', + 'workspace_setup_failed', + 'repository_clone_failed', + 'repository_auth_failed', + 'repository_checkout_failed', + 'session_import_failed', + 'setup_command_failed', + 'container_shutdown', 'unknown', ] as const; @@ -2025,6 +2049,11 @@ export const CODE_REVIEW_BENIGN_TERMINAL_REASONS = [ 'selected_model_unavailable', 'user_cancelled', 'superseded', + // The repository's own setup command failed. That is the customer's script, + // not our infrastructure, so it must not page us. Every other new reason is + // deliberately left as a system failure: under-alerting is how the Jul 2026 + // publish-rate collapse ran for three days unnoticed. + 'setup_command_failed', ] as const satisfies readonly CodeReviewTerminalReason[]; export type CodeReviewBenignTerminalReason = (typeof CODE_REVIEW_BENIGN_TERMINAL_REASONS)[number]; diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index a53ccb2b305..17a14d2f538 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -165,6 +165,24 @@ export type CloudAgentTerminalReason = | 'upstream_error' | 'sandbox_error' | 'workspace_capacity' + | 'assistant_failed' + | 'assistant_rate_limited' + | 'assistant_unavailable' + | 'assistant_timeout' + | 'assistant_unauthorized' + | 'assistant_invalid_request' + | 'assistant_no_reply' + | 'wrapper_failed' + | 'runtime_startup_failed' + | 'sandbox_connection' + | 'delivery_failed' + | 'workspace_setup_failed' + | 'repository_clone_failed' + | 'repository_auth_failed' + | 'repository_checkout_failed' + | 'session_import_failed' + | 'setup_command_failed' + | 'container_shutdown' | 'unknown'; export class CloudAgentNextError extends Error { From 68034d663d17e0c06f0986ff428895f3d835786c Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Sun, 26 Jul 2026 20:03:54 -0700 Subject: [PATCH 2/3] fix review findings --- .../[reviewId]/route.test.ts | 76 ++++++++++++++++ .../code-review-status/[reviewId]/route.ts | 21 ++++- .../terminal-reason-from-failure.test.ts | 24 +++++ .../terminal-reason-from-failure.ts | 22 +++-- .../src/routers/admin-code-reviews-router.ts | 12 ++- .../src/cloud-agent-next-client.ts | 88 +++++++++++-------- services/code-review-infra/src/types.ts | 29 ++---- 7 files changed, 199 insertions(+), 73 deletions(-) diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index cbe3b4a678e..7b090647df4 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -1308,6 +1308,82 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { expect.objectContaining({ terminalReason: undefined }) ); }); + + it('derives the terminal reason from the structured failure payload', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + await POST( + makeRequest({ + cloudAgentSessionId: 'agent_1', + status: 'failed', + errorMessage: 'Agent wrapper failed while processing the message', + failure: { stage: 'agent_activity', code: 'wrapper_error_after_activity' }, + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', terminalReason: 'wrapper_failed' }) + ); + }); + + it('keeps rate limiting distinct from generic assistant failures', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + await POST( + makeRequest({ + cloudAgentSessionId: 'agent_1', + status: 'failed', + errorMessage: 'Assistant request was rate limited', + failure: { code: 'assistant_error', message: 'Assistant request was rate limited' }, + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', terminalReason: 'assistant_rate_limited' }) + ); + }); + + // model_missing arrives with the generic message 'No model was selected', + // which the message-based model-not-found check cannot match. Without the + // structured path re-applying normalization the row would stay 'failed' and + // the customer would get generic copy instead of the actionable message. + it('normalizes status to cancelled for a structured model_missing failure', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + await POST( + makeRequest({ + cloudAgentSessionId: 'agent_1', + status: 'failed', + errorMessage: 'No model was selected', + failure: { stage: 'pre_dispatch', code: 'model_missing' }, + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith( + expect.objectContaining({ status: 'cancelled', terminalReason: 'model_not_found' }) + ); + }); + + it('normalizes status to failed for a structured payment_required failure', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + await POST( + makeRequest({ + cloudAgentSessionId: 'agent_1', + status: 'interrupted', + errorMessage: 'Assistant request failed: insufficient credits', + failure: { code: 'payment_required' }, + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', terminalReason: 'billing' }) + ); + }); }); describe('attempt tracking and infra retry', () => { diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts index b4e1a82d44b..171acdbbf3a 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts @@ -375,7 +375,26 @@ function normalizePayload(raw: StatusUpdatePayload): { // generic 'interrupted' fallback so an interrupt with a known cause is still // attributed to that cause. if (!terminalReason) { - terminalReason = terminalReasonFromCloudAgentFailure(failure, raw.errorMessage); + const structuredReason = terminalReasonFromCloudAgentFailure(failure, raw.errorMessage); + if (structuredReason) { + terminalReason = structuredReason; + // The billing and model-not-found blocks above own status normalization + // for those reasons, but both key off the error text. A structured-only + // payload carries a generic message ('No model was selected' for + // model_missing), so those checks miss and the status would stay + // unnormalized while terminal_reason is correct — leaving the customer + // with generic failure copy from mapStatusToCheckRun instead of the + // actionable message. Apply the same normalization here. + if (structuredReason === 'billing' && status === 'cancelled') { + status = 'failed'; // billing is not a user cancellation + } + if ( + structuredReason === 'model_not_found' && + (raw.status === 'failed' || raw.status === 'interrupted') + ) { + status = 'cancelled'; + } + } } if (!terminalReason && raw.status === 'interrupted') { diff --git a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts index 08a3c2e11d3..ba56d4ceaa9 100644 --- a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts +++ b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts @@ -2,9 +2,22 @@ import { CLOUD_AGENT_FAILURE_CODES, WORKSPACE_FAILURE_SUBTYPES, } from '@kilocode/worker-utils/cloud-agent-failure'; +import { CLOUD_AGENT_TERMINAL_REASONS } from '@kilocode/worker-utils/cloud-agent-next-client'; import { CODE_REVIEW_TERMINAL_REASONS } from '@kilocode/db/schema-types'; import { terminalReasonFromCloudAgentFailure } from './terminal-reason-from-failure'; +describe('terminal reason list parity', () => { + // These two lists live in separate packages that cannot import each other, so + // drift is only caught here. A mismatch makes the orchestrator send a reason + // the callback allowlist rejects, or makes the worker's zod enum silently + // coerce a valid reason to undefined via its `.catch(undefined)`. + it('keeps the db and worker-utils terminal reason lists identical', () => { + expect([...CLOUD_AGENT_TERMINAL_REASONS].sort()).toEqual( + [...CODE_REVIEW_TERMINAL_REASONS].sort() + ); + }); +}); + describe('terminalReasonFromCloudAgentFailure', () => { it('returns undefined without a structured failure', () => { expect(terminalReasonFromCloudAgentFailure(undefined)).toBeUndefined(); @@ -80,6 +93,17 @@ describe('terminalReasonFromCloudAgentFailure', () => { expect(terminalReasonFromCloudAgentFailure({ code: 'unclassified' })).toBeUndefined(); }); + it.each(['constructor', '__proto__', 'toString', 'valueOf'])( + 'does not resolve inherited object members for message %p', + message => { + // A plain object lookup would return a truthy Object.prototype member and + // write a function reference into the terminal_reason column. + expect(terminalReasonFromCloudAgentFailure({ code: 'assistant_error', message })).toBe( + 'assistant_failed' + ); + } + ); + it('resolves every failure code to a valid terminal reason', () => { const valid = new Set(CODE_REVIEW_TERMINAL_REASONS); const resolved = CLOUD_AGENT_FAILURE_CODES.map(code => [ diff --git a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts index 7982124fd2b..7cb63afa586 100644 --- a/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts +++ b/apps/web/src/lib/code-reviews/terminal-reason-from-failure.ts @@ -83,14 +83,20 @@ const FAILURE_CODE_REASONS = { * than the current behaviour. * * KEEP IN SYNC with classifyAssistantFailure's safeMessage values. + * + * A Map rather than an object literal: the key is caller-supplied free text (the + * schema bounds only its length), and an object lookup for 'constructor' or + * '__proto__' would resolve an inherited Object.prototype member. That value is + * truthy, so it would defeat the fallback and write a function reference into + * the terminal_reason column. Map keys have no prototype chain. */ -const ASSISTANT_MESSAGE_REASONS: Record = { - 'Assistant request was rate limited': 'assistant_rate_limited', - 'Assistant service is unavailable': 'assistant_unavailable', - 'Assistant request timed out': 'assistant_timeout', - 'Assistant request was not authorized': 'assistant_unauthorized', - 'Assistant request was invalid': 'assistant_invalid_request', -}; +const ASSISTANT_MESSAGE_REASONS = new Map([ + ['Assistant request was rate limited', 'assistant_rate_limited'], + ['Assistant service is unavailable', 'assistant_unavailable'], + ['Assistant request timed out', 'assistant_timeout'], + ['Assistant request was not authorized', 'assistant_unauthorized'], + ['Assistant request was invalid', 'assistant_invalid_request'], +]); /** * Resolve a terminal reason from the structured callback failure. @@ -113,7 +119,7 @@ export function terminalReasonFromCloudAgentFailure( // The projection puts the safe message on the failure; fall back to the // callback's errorMessage for payloads that only carry the flattened text. const message = (failure.message ?? errorMessage)?.trim(); - return (message && ASSISTANT_MESSAGE_REASONS[message]) || 'assistant_failed'; + return (message ? ASSISTANT_MESSAGE_REASONS.get(message) : undefined) ?? 'assistant_failed'; } const reason = FAILURE_CODE_REASONS[failure.code]; diff --git a/apps/web/src/routers/admin-code-reviews-router.ts b/apps/web/src/routers/admin-code-reviews-router.ts index 33769468273..228b8a72663 100644 --- a/apps/web/src/routers/admin-code-reviews-router.ts +++ b/apps/web/src/routers/admin-code-reviews-router.ts @@ -118,7 +118,6 @@ const TERMINAL_REASON_LABELS: Record = { setup_command_failed: 'Setup Command Failure', timeout: 'Timeout', upstream_error: 'Upstream Server Error', - interrupted: 'Interrupted', }; /** @@ -155,10 +154,15 @@ function buildErrorCategoryExpr(terminalReasonColumn: PgColumn, errorMessageColu WHEN ${errorMessageColumn} LIKE '%parse%' OR ${errorMessageColumn} LIKE '%JSON%' OR ${errorMessageColumn} LIKE '%unexpected token%' THEN 'Parse Error' WHEN ${errorMessageColumn} LIKE '%could not be delivered%' THEN 'Delivery Failure' WHEN ${errorMessageColumn} LIKE '%repository_not_installed%' OR ${errorMessageColumn} LIKE '%app installation required%' THEN 'Action Required' - -- Deliberately last: 'sandbox_error' is the orchestrator's generic bucket for a - -- failed session start, and the patterns above refine it into the specific - -- cause (most often Sandbox Capacity). Matching it earlier would mask that. + -- Deliberately last: these two are generic buckets, not causes, and the + -- patterns above refine them into the specific cause. 'sandbox_error' is the + -- orchestrator's catch-all for a failed session start (most often Sandbox + -- Capacity); 'interrupted' is assigned purely from status when nothing else + -- classified the row, so those rows carry arbitrary error text (rate limits, + -- timeouts, upstream 5xx from the v1 interrupted-delivery path). Matching + -- either earlier would mask the specific category. WHEN ${terminalReasonColumn} = 'sandbox_error' THEN 'Sandbox Error' + WHEN ${terminalReasonColumn} = 'interrupted' THEN 'Interrupted' WHEN ${errorMessageColumn} IS NULL THEN 'Unknown Error' ELSE 'Other' END`; diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index 17a14d2f538..3db87a8bade 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -144,46 +144,56 @@ export type CloudAgentInterruptOutput = { /** * Valid terminal reasons for code review failures. - * KEEP IN SYNC with CODE_REVIEW_TERMINAL_REASONS / CodeReviewTerminalReason - * in packages/db/src/schema-types.ts — both lists must contain the same - * literal values. A mismatch will cause the orchestrator to send a reason - * that normalizePayload rejects via its allowlist check. + * + * Exported as a runtime array so worker-side consumers can derive their + * validators from it instead of retyping the literals — see + * services/code-review-infra/src/types.ts, where a missing value used to be + * silently coerced to undefined by a `.catch(undefined)` zod enum. + * + * KEEP IN SYNC with CODE_REVIEW_TERMINAL_REASONS in + * packages/db/src/schema-types.ts — both lists must contain the same literal + * values, and a mismatch makes the orchestrator send a reason the callback's + * allowlist rejects. apps/web asserts the two are equal (see + * apps/web/src/lib/code-reviews/terminal-reason-from-failure.test.ts). */ -export type CloudAgentTerminalReason = - | 'billing' - | 'model_not_found' - | 'github_installation_required' - | 'github_ip_allow_list' - | 'gitlab_project_access_required' - | 'byok_invalid_key' - | 'selected_model_unavailable' - | 'repeated_repository_clone_timeout' - | 'user_cancelled' - | 'superseded' - | 'interrupted' - | 'timeout' - | 'upstream_error' - | 'sandbox_error' - | 'workspace_capacity' - | 'assistant_failed' - | 'assistant_rate_limited' - | 'assistant_unavailable' - | 'assistant_timeout' - | 'assistant_unauthorized' - | 'assistant_invalid_request' - | 'assistant_no_reply' - | 'wrapper_failed' - | 'runtime_startup_failed' - | 'sandbox_connection' - | 'delivery_failed' - | 'workspace_setup_failed' - | 'repository_clone_failed' - | 'repository_auth_failed' - | 'repository_checkout_failed' - | 'session_import_failed' - | 'setup_command_failed' - | 'container_shutdown' - | 'unknown'; +export const CLOUD_AGENT_TERMINAL_REASONS = [ + 'billing', + 'model_not_found', + 'github_installation_required', + 'github_ip_allow_list', + 'gitlab_project_access_required', + 'byok_invalid_key', + 'selected_model_unavailable', + 'repeated_repository_clone_timeout', + 'user_cancelled', + 'superseded', + 'interrupted', + 'timeout', + 'upstream_error', + 'sandbox_error', + 'workspace_capacity', + 'assistant_failed', + 'assistant_rate_limited', + 'assistant_unavailable', + 'assistant_timeout', + 'assistant_unauthorized', + 'assistant_invalid_request', + 'assistant_no_reply', + 'wrapper_failed', + 'runtime_startup_failed', + 'sandbox_connection', + 'delivery_failed', + 'workspace_setup_failed', + 'repository_clone_failed', + 'repository_auth_failed', + 'repository_checkout_failed', + 'session_import_failed', + 'setup_command_failed', + 'container_shutdown', + 'unknown', +] as const; + +export type CloudAgentTerminalReason = (typeof CLOUD_AGENT_TERMINAL_REASONS)[number]; export class CloudAgentNextError extends Error { readonly procedure: string; diff --git a/services/code-review-infra/src/types.ts b/services/code-review-infra/src/types.ts index 9f46088efee..17b42630eb5 100644 --- a/services/code-review-infra/src/types.ts +++ b/services/code-review-infra/src/types.ts @@ -9,6 +9,7 @@ import type { ReviewAgentsConfig, } from '@kilocode/worker-utils/review-agents'; import type { RuntimeAgentInput } from '@kilocode/worker-utils/cloud-agent-next-client'; +import { CLOUD_AGENT_TERMINAL_REASONS } from '@kilocode/worker-utils/cloud-agent-next-client'; import * as z from 'zod'; export type { Owner, MCPServerConfig }; @@ -111,28 +112,14 @@ export interface CodeReviewStatusResponse { export type CodeReviewStatusResult = CodeReviewStatusResponse | null; -// KEEP IN SYNC with CODE_REVIEW_TERMINAL_REASONS (packages/db/src/schema-types.ts) -// and CloudAgentTerminalReason (packages/worker-utils/src/cloud-agent-next-client.ts). -// A value missing here is silently coerced to undefined by `.catch(undefined)`. +// Derived from the single runtime list in worker-utils rather than retyped. +// This schema ends in `.catch(undefined)`, so any reason missing from it is +// silently dropped — which previously meant a new terminal reason would be +// coerced to undefined here and lost from the Durable Object's in-memory state +// (setLocalTerminalStateFromDB only assigns when the value is defined), even +// though the DB row recorded it correctly. const InternalStatusTerminalReasonSchema = z - .enum([ - 'billing', - 'model_not_found', - 'github_installation_required', - 'github_ip_allow_list', - 'gitlab_project_access_required', - 'byok_invalid_key', - 'selected_model_unavailable', - 'repeated_repository_clone_timeout', - 'user_cancelled', - 'superseded', - 'interrupted', - 'timeout', - 'upstream_error', - 'sandbox_error', - 'workspace_capacity', - 'unknown', - ]) + .enum(CLOUD_AGENT_TERMINAL_REASONS) .nullable() .optional() .catch(undefined); From 9b4608c3866bac326a5456170f1ce78f8e5014da Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Sun, 26 Jul 2026 20:22:51 -0700 Subject: [PATCH 3/3] test(code-review): make the payment_required case exercise the structured path --- .../internal/code-review-status/[reviewId]/route.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index 7b090647df4..35ceda92c27 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -1367,6 +1367,11 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { ); }); + // errorMessage is deliberately omitted. The real payment_required safe + // message contains 'insufficient credits', which the earlier text-based + // billing heuristic matches, so including it would set terminalReason before + // the structured branch runs and the assertion would pass even if the + // payment_required mapping were deleted. it('normalizes status to failed for a structured payment_required failure', async () => { mockGetCodeReviewById.mockResolvedValue(makeReview()); @@ -1374,7 +1379,6 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { makeRequest({ cloudAgentSessionId: 'agent_1', status: 'interrupted', - errorMessage: 'Assistant request failed: insufficient credits', failure: { code: 'payment_required' }, }), makeParams(REVIEW_ID)