From 9aeaa52bdb33b63a5ae3f687337d42d7237236a1 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 08:12:55 +0900 Subject: [PATCH] fix: retain worker run errors in console history --- crates/tui/src/app.rs | 86 +++++++++++++++++ .../src/lib/workspace/console/model.test.ts | 94 ++++++++++++++++++- .../src/lib/workspace/console/model.ts | 17 ++++ .../workers/[workerId]/console/+page.svelte | 17 ++-- 4 files changed, 204 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a765ea71..615e29c4 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2081,6 +2081,13 @@ impl App { } if domain == "yoi.compaction" => { self.apply_compaction_extension(&payload); } + session_store::LogEntry::RunErrored { message, .. } => { + self.blocks.push(Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + }); + } // Non-history-bearing variants don't affect the block view. _ => {} } @@ -3261,6 +3268,85 @@ mod completion_flow_tests { )); } + #[test] + fn snapshot_and_segment_rotation_retain_one_durable_run_error_block() { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::Error { + code: ErrorCode::ProviderError, + message: "provider unavailable".into(), + }); + app.handle_worker_event(Event::Status { + status: WorkerStatus::Idle, + }); + + let live_errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(live_errors, ["[ProviderError] provider unavailable"]); + + let run_errored = session_store::LogEntry::RunErrored { + ts: 3, + interrupted: false, + message: "provider unavailable".into(), + }; + app.handle_worker_event(Event::Snapshot { + greeting: test_greeting(), + entries: vec![serde_json::to_value(run_errored).unwrap()], + status: WorkerStatus::Idle, + in_flight: Default::default(), + }); + + let errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(errors, ["provider unavailable"]); + + app.handle_worker_event(Event::Error { + code: ErrorCode::ProviderError, + message: "retry unavailable".into(), + }); + let rotated_run_error = session_store::LogEntry::RunErrored { + ts: 5, + interrupted: false, + message: "retry unavailable".into(), + }; + app.handle_worker_event(Event::SegmentRotated { + entry: serde_json::to_value(rotated_run_error).unwrap(), + }); + + let rotated_errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(rotated_errors, ["retry unavailable"]); + } + #[test] fn snapshot_in_flight_blocks_continue_with_live_deltas() { let mut app = App::new("test".into()); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 6572c26c..fd0d5547 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -2,6 +2,7 @@ import type { Event } from "$lib/generated/protocol"; import { type ConsoleLine, createConsoleProjector, + isConsoleProjectionEvent, projectConsole, segmentsToText, selectConsoleTimelineLines, @@ -36,11 +37,11 @@ function consoleLine(id: string, kind: ConsoleLine["kind"]): ConsoleLine { }; } -function snapshotEvent(cwd: string): Event { +function snapshotEvent(cwd: string, entries: unknown[] = []): Event { return { event: "snapshot", data: { - entries: [], + entries, greeting: { worker_name: "Worker", cwd, @@ -57,6 +58,95 @@ function snapshotEvent(cwd: string): Event { }; } +Deno.test("console routing projects live errors but not completion replies", () => { + const errorEvent = { + event: "error", + data: { code: "provider_error", message: "provider unavailable" }, + } satisfies Event; + const completionEvent = { + event: "completions", + data: { kind: "file", entries: [] }, + } satisfies Event; + + assert( + isConsoleProjectionEvent(errorEvent), + "live errors must reach the timeline projector", + ); + assert( + !isConsoleProjectionEvent(completionEvent), + "completion replies should remain control-only events", + ); +}); + +Deno.test("snapshot and segment rotation retain one durable run_errored row", () => { + const projector = createConsoleProjector(); + let projection = projector.append([ + { + eventId: "live-error", + event: { + event: "error", + data: { code: "provider_error", message: "provider unavailable" }, + } satisfies Event, + }, + { + eventId: "idle-after-error", + event: { event: "status", data: { status: "idle" } } satisfies Event, + }, + ]); + + assertEquals(projection.status, "idle"); + const liveErrors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(liveErrors.length, 1); + assertEquals(liveErrors[0].title, "error ยท provider_error"); + assertEquals(liveErrors[0].body, "provider unavailable"); + + projection = projector.append([{ + eventId: "reconnected-snapshot", + event: snapshotEvent("/repo", [{ + kind: "run_errored", + ts: 3, + interrupted: false, + message: "provider unavailable", + }]), + }]); + + const errors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(errors.length, 1); + assertEquals(errors[0].title, "Run error"); + assertEquals(errors[0].body, "provider unavailable"); + assertEquals(errors[0].error, true); + + projection = projector.append([ + { + eventId: "second-live-error", + event: { + event: "error", + data: { code: "provider_error", message: "retry unavailable" }, + } satisfies Event, + }, + { + eventId: "segment-rotated", + event: { + event: "segment_rotated", + data: { + entry: { + kind: "run_errored", + ts: 5, + interrupted: false, + message: "retry unavailable", + }, + }, + } satisfies Event, + }, + ]); + + const rotatedErrors = projection.lines.filter((line) => + line.kind === "error" + ); + assertEquals(rotatedErrors.length, 1); + assertEquals(rotatedErrors[0].body, "retry unavailable"); +}); + Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { assert( workerConsoleHref({ diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 289905fc..a9a90300 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -142,6 +142,10 @@ export type ConsoleEventInput = { observedAtMs?: number; }; +export function isConsoleProjectionEvent(event: ProtocolEvent): boolean { + return event.event !== "completions"; +} + export function emptyConsoleProjection(): ConsoleProjection { return { lines: [], @@ -1216,6 +1220,19 @@ function applyLogEntry( case "tool_result": applyLoggedItem(projection, eventId, entry["item"]); break; + case "run_errored": + projection.lines.push( + line( + eventId, + "error", + "Run error", + stringField(entry, "message") ?? "Worker run failed.", + undefined, + false, + true, + ), + ); + break; case "extension": applyExtensionEntry(projection, eventId, entry); break; diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index ae4def3e..f1b4b28d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -18,6 +18,7 @@ import { fitTextarea } from "$lib/workspace/console/textarea-fit"; import { createConsoleProjector, + isConsoleProjectionEvent, selectConsoleTimelineLines, type ConsoleEventInput, type ConsoleLine, @@ -266,14 +267,14 @@ function handleIncomingProtocolEvent(payload: ProtocolEvent) { handleProtocolCommandEvent(payload); - if (payload.event === "completions" || payload.event === "error") { - if (payload.event === "error") { - queueObservationDiagnostic({ - code: payload.data.code, - severity: "error", - message: payload.data.message, - }); - } + if (payload.event === "error") { + queueObservationDiagnostic({ + code: payload.data.code, + severity: "error", + message: payload.data.message, + }); + } + if (!isConsoleProjectionEvent(payload)) { return; }