Skip to content
Merged
72 changes: 72 additions & 0 deletions apps/daemon/src/runtimes/chat-run-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ type ChatRunMessageState = {
createdAt?: number;
sessionMode?: string | null;
context?: Record<string, unknown> | null;
error?: string | null;
errorCode?: string | null;
failureCategory?: string | null;
failureDetail?: string | null;
};

function isRecord(value: unknown): value is Record<string, unknown> {
Expand All @@ -38,6 +42,74 @@ export function persistRunEventToAssistantMessage(
}
}

/**
* Stamp the daemon's finalize-time failure classification onto the persisted
* assistant message so a reload — or any consumer that reads the stored
* message instead of the live SSE stream — still sees the fine-grained cause.
*
* The `error` SSE frame is emitted from the child-close handler BEFORE the run
* is finalized, so `failureCategory` / `failureDetail` (computed at finalize)
* aren't known when that frame is first persisted. This enriches the last
* persisted `status:error` event in place once the classification exists, and
* appends one only if a failed run somehow never persisted an error frame.
* Without this, a daemon-persisted failure (no live web error handler saving
* the message, or a conversation reloaded before that save) falls back to the
* coarse `errorCode` UI and loses the specific fix guidance.
*/
export function persistRunFailureClassification(
db: SqliteDb,
run: ChatRunMessageState,
): void {
if (!run.assistantMessageId) return;
const failureCategory = run.failureCategory ?? null;
const failureDetail = run.failureDetail ?? null;
if (!failureCategory && !failureDetail) return;
try {
const row = db
.prepare(`SELECT events_json AS eventsJson FROM messages WHERE id = ?`)
.get(run.assistantMessageId) as { eventsJson?: string } | undefined;
if (!row) return;
let events: unknown[] = [];
try {
const parsed = JSON.parse(row.eventsJson ?? '[]');
if (Array.isArray(parsed)) events = parsed;
} catch {
events = [];
}
let idx = -1;
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
if (isRecord(event) && event.kind === 'status' && event.label === 'error') {
idx = i;
break;
}
}
const existing = idx >= 0 ? events[idx] : null;
const base: Record<string, unknown> = isRecord(existing)
? existing
: { kind: 'status', label: 'error' };
const enriched: Record<string, unknown> = {
...base,
...(failureCategory ? { failureCategory } : {}),
...(failureDetail ? { failureDetail } : {}),
};
if (run.errorCode && typeof enriched.code !== 'string') enriched.code = run.errorCode;
if (idx >= 0) {
if (JSON.stringify(enriched) === JSON.stringify(events[idx])) return;
events[idx] = enriched;
} else {
if (run.error && typeof enriched.detail !== 'string') enriched.detail = run.error;
events.push(enriched);
}
db.prepare(`UPDATE messages SET events_json = ? WHERE id = ?`).run(
JSON.stringify(events),
run.assistantMessageId,
);
} catch (err) {
console.warn('[runs] failure classification persistence failed', err);
}
}

export function runSseEventToPersistedAgentEvent(
event: string,
data: unknown,
Expand Down
11 changes: 10 additions & 1 deletion apps/daemon/src/runtimes/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export function createChatRunService({
signal: run.signal,
error: run.error ?? null,
errorCode: run.errorCode ?? null,
failureCategory: run.failureCategory ?? null,
failureDetail: run.failureDetail ?? null,
resumable: run.resumable ?? false,
eventsLogPath: run.eventsLogPath ?? null,
workspace: projectWorkspaceProvenance(run.projectMetadata),
Expand All @@ -220,7 +222,14 @@ export function createChatRunService({
run.onFinalize = null;
try { finalize(); } catch { /* best-effort */ }
}
emit(run, 'end', { code, signal, status, resumable: run.resumable ?? false });
emit(run, 'end', {
code,
signal,
status,
resumable: run.resumable ?? false,
failureCategory: run.failureCategory ?? null,
failureDetail: run.failureDetail ?? null,
});
for (const sse of run.clients) sse.end();
run.clients.clear();
for (const waiter of run.waiters) waiter(statusBody(run));
Expand Down
12 changes: 12 additions & 0 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
import {
daemonAgentPayloadToPersistedAgentEvent,
persistRunEventToAssistantMessage,
persistRunFailureClassification,
pinAssistantMessageOnRunCreate,
} from './runtimes/chat-run-messages.js';
import {
Expand Down Expand Up @@ -5131,6 +5132,17 @@ export async function startServer({
committedWorkSeen &&
isResumableFailure(failure);
run.resumable = resumableFailure;
// Surface the daemon's failure classification (already computed for
// retry-policy + telemetry) on the run so statusBody / the SSE `end` frame
// carry it to the chat, which maps failureDetail -> a specific named
// failure type + fix. Only meaningful on a failed result.
run.failureCategory = result === 'failed' ? failure?.failure_category ?? null : null;
run.failureDetail = result === 'failed' ? failure?.failure_detail ?? null : null;
Comment thread
This conversation was marked as resolved.
Comment thread
This conversation was marked as resolved.
Comment thread
This conversation was marked as resolved.
// Stamp the classification onto the persisted assistant message too, so a
// reload (or any daemon-side persistence without the live web error
// handler) keeps the specific failure guidance instead of the coarse
// errorCode UI. Mirrors what statusBody / the SSE `end` frame carry live.
if (result === 'failed') persistRunFailureClassification(db, run);
if (resumableFailure) {
upsertAgentSession(db, {
conversationId: run.conversationId,
Expand Down
239 changes: 239 additions & 0 deletions apps/daemon/tests/run-failure-detail-persisted-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';

import { startServer } from '../src/server.js';

// #895 regression: the daemon classifies a failure's fine-grained cause
// (failure_category / failure_detail) at finalize. The live web error handler
// stamps that onto the assistant message, but a failure persisted purely on the
// daemon side — or a conversation reloaded before the web save lands — reads the
// stored message instead of the live SSE stream. Historically that stored
// `status:error` event only carried `{ detail, code }`, so a reload fell back to
// the coarse errorCode UI and lost the specific fix guidance.
//
// This asserts the STORED assistant message (not just the run-status DTO)
// carries `failureCategory` / `failureDetail` after a failed hard-quota run.

type StartedServer = {
url: string;
server: Server;
shutdown?: () => Promise<void> | void;
};

type RunStatus = { id: string; status: string };

type PersistedEvent = {
kind?: string;
label?: string;
detail?: string;
code?: string;
failureCategory?: string;
failureDetail?: string;
};

type StoredMessage = {
id: string;
role: string;
events?: PersistedEvent[];
};

type RunHandles = {
projectId: string;
conversationId: string;
assistantMessageId: string;
status: RunStatus;
};

describe('run failure classification persisted to assistant message', () => {
const originalEnv = snapshotEnv();
let started: StartedServer | null = null;
let binDir: string | null = null;

afterEach(async () => {
await Promise.resolve(started?.shutdown?.());
if (started?.server) {
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
}
started = null;
if (binDir) await removeTempDir(binDir);
binDir = null;
restoreEnv(originalEnv);
});

it('stamps failureCategory/failureDetail onto the stored error event on a hard-quota failure', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-failure-detail-msg-bin-'));
const fakeClaude = await writeHardQuotaClaude(binDir, 'claude-hard-quota');

delete process.env.POSTHOG_KEY;
delete process.env.POSTHOG_HOST;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_BASE_URL;
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;

started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'claude',
agentCliEnv: { claude: { CLAUDE_BIN: fakeClaude } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});

const { projectId, conversationId } = await createConversation(started.url);
const run = await sendRunAndWait(started.url, projectId, conversationId);
expect(run.status.status).toBe('failed');

// Read the STORED message the same way a reload does (daemon HTTP API),
// not the live stream, so this proves the daemon-owned persistence path.
const stored = await fetchAssistantMessage(
started.url,
projectId,
conversationId,
run.assistantMessageId,
);
expect(stored).not.toBeNull();

const errorEvent = [...(stored?.events ?? [])]
.reverse()
.find((event) => event.kind === 'status' && event.label === 'error');
expect(errorEvent, 'persisted assistant message should carry a status:error event').toBeTruthy();
expect(errorEvent?.failureCategory).toBe('rate_limit');
expect(errorEvent?.failureDetail).toBe('hard_quota');
});
});

function snapshotEnv(): Record<string, string | undefined> {
return {
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL,
OPEN_DESIGN_TELEMETRY_RELAY_URL: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL,
POSTHOG_KEY: process.env.POSTHOG_KEY,
POSTHOG_HOST: process.env.POSTHOG_HOST,
};
}

function restoreEnv(env: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}

// Fake Claude CLI: emits the init frame, then dies with a hard-quota billing
// message on stderr (matches isHardQuotaText -> detail 'hard_quota', which is
// non-retryable so the run fails on the first attempt).
async function writeHardQuotaClaude(dir: string, name: string): Promise<string> {
const bin = path.join(dir, name);
await writeFile(
bin,
`#!/usr/bin/env node
if (process.argv.includes('--version')) { console.log('claude-code 1.0.0-hard-quota'); process.exit(0); }
if (process.argv.includes('--help')) { console.log('Usage: claude -p [--include-partial-messages]'); process.exit(0); }
console.log(JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-quota-test' }));
process.stderr.write('You have exceeded your current quota. Please upgrade your plan to continue.\\n');
setTimeout(() => process.exit(1), 20);
`,
'utf8',
);
await chmod(bin, 0o755);
return bin;
}

async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
const response = await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
});
expect(response.status).toBe(200);
}

async function createConversation(
url: string,
): Promise<{ projectId: string; conversationId: string }> {
const projectId = `failure_detail_msg_${randomUUID()}`;
const projectResponse = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Failure detail persisted message smoke',
metadata: { kind: 'prototype' },
skipDiscoveryBrief: true,
}),
});
expect(projectResponse.status).toBe(200);
const projectBody = (await projectResponse.json()) as { conversationId: string; id: string };
return { projectId, conversationId: projectBody.conversationId };
}

async function sendRunAndWait(
url: string,
projectId: string,
conversationId: string,
): Promise<RunHandles> {
const assistantMessageId = `assistant_failure_detail_msg_${randomUUID()}`;
const runResponse = await fetch(`${url}/api/runs`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-analytics-device-id': 'failure-detail-msg-test',
'x-od-analytics-session-id': 'failure-detail-msg-session',
'x-od-analytics-client-type': 'web',
},
body: JSON.stringify({
projectId,
conversationId,
assistantMessageId,
clientRequestId: `client_failure_detail_msg_${randomUUID()}`,
agentId: 'claude',
message: 'please do the task',
currentPrompt: 'please do the task',
}),
});
expect(runResponse.status).toBe(202);
const body = (await runResponse.json()) as { runId: string };
const status = await waitForRun(url, body.runId);
return { projectId, conversationId, assistantMessageId, status };
}

async function waitForRun(url: string, runId: string): Promise<RunStatus> {
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
expect(response.status).toBe(200);
const run = (await response.json()) as RunStatus;
if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') {
return run;
}
await delay(100);
}
throw new Error(`run ${runId} did not finish`);
}

async function fetchAssistantMessage(
url: string,
projectId: string,
conversationId: string,
assistantMessageId: string,
): Promise<StoredMessage | null> {
const response = await fetch(
`${url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
);
expect(response.status).toBe(200);
const body = (await response.json()) as { messages?: StoredMessage[] };
return body.messages?.find((message) => message.id === assistantMessageId) ?? null;
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function removeTempDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
Loading