From d535e2627d145d38e59c2361d7cade52f4b85c53 Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Tue, 21 Jul 2026 11:09:11 -0600
Subject: [PATCH 1/6] feat(acp): classify turn failures with an error_class
payload field
Add a stable, machine-readable error_class discriminant (timeout,
transport, agent_error, protocol, exited, cancelled, panic, error) to
the turn_error and agent_panic observer payloads, additive alongside
the existing outcome/error/code fields.
Extracted into a pure classify_turn_failure(&PromptOutcome) so the
mapping is unit-testable independent of the emit call sites, and it
mirrors the existing outcome_label/is_transport_error groupings rather
than inventing a new taxonomy.
Signed-off-by: Orlando Lopez
---
crates/buzz-acp/src/lib.rs | 223 +++++++++++++++++++++++++++++++++++++
1 file changed, 223 insertions(+)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 923e132167..5e85e71c66 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2842,6 +2842,137 @@ fn spawn_failure_notice(
}
}
+/// Stable, machine-readable discriminant for a failed turn, derived from the
+/// same data that already drives `outcome_label` and `is_transport_error`.
+///
+/// This is intentionally coarser than `outcome_label`: consumers (the
+/// desktop UI, external tooling) should be able to group failures into a
+/// handful of buckets without parsing prose. New variants should only be
+/// added for a genuinely new failure shape — do not multiply classes for
+/// wording differences.
+fn classify_turn_failure(outcome: &PromptOutcome) -> &'static str {
+ match outcome {
+ PromptOutcome::Ok(_) => "error",
+ PromptOutcome::Timeout(_) => "timeout",
+ PromptOutcome::AgentExited => "exited",
+ PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) => "cancelled",
+ PromptOutcome::Error(e) => match e {
+ acp::AcpError::Io(_)
+ | acp::AcpError::WriteTimeout(_)
+ | acp::AcpError::Timeout(_)
+ | acp::AcpError::Protocol(_) => "transport",
+ acp::AcpError::Json(_) => "protocol",
+ acp::AcpError::AgentExited => "exited",
+ acp::AcpError::IdleTimeout(_) | acp::AcpError::HardTimeout { .. } => "timeout",
+ acp::AcpError::CancelDrainTimeout(_) => "cancelled",
+ acp::AcpError::AgentError { .. } => "agent_error",
+ },
+ }
+}
+
+#[cfg(test)]
+mod classify_turn_failure_tests {
+ use super::*;
+ use std::time::Duration;
+
+ #[test]
+ fn timeout_outcome_classifies_as_timeout() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Timeout(TimeoutKind::Idle)),
+ "timeout"
+ );
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Timeout(TimeoutKind::Hard {
+ recently_active: false
+ })),
+ "timeout"
+ );
+ }
+
+ #[test]
+ fn agent_exited_outcome_classifies_as_exited() {
+ assert_eq!(classify_turn_failure(&PromptOutcome::AgentExited), "exited");
+ }
+
+ #[test]
+ fn cancelled_outcomes_classify_as_cancelled() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Cancelled),
+ "cancelled"
+ );
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::CancelDrainTimeout(Duration::from_secs(5))),
+ "cancelled"
+ );
+ }
+
+ #[test]
+ fn transport_class_errors_classify_as_transport() {
+ for e in [
+ acp::AcpError::Io(std::io::Error::other("boom")),
+ acp::AcpError::WriteTimeout(Duration::from_secs(1)),
+ acp::AcpError::Timeout(Duration::from_secs(1)),
+ acp::AcpError::Protocol("bad frame".to_string()),
+ ] {
+ assert_eq!(classify_turn_failure(&PromptOutcome::Error(e)), "transport");
+ }
+ }
+
+ #[test]
+ fn json_error_classifies_as_protocol() {
+ let json_err = serde_json::from_str::("{").unwrap_err();
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::Json(json_err))),
+ "protocol"
+ );
+ }
+
+ #[test]
+ fn acp_agent_exited_error_classifies_as_exited() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::AgentExited)),
+ "exited"
+ );
+ }
+
+ #[test]
+ fn acp_idle_and_hard_timeout_errors_classify_as_timeout() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::IdleTimeout(
+ Duration::from_secs(30)
+ ))),
+ "timeout"
+ );
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::HardTimeout {
+ silence: Duration::from_secs(30)
+ })),
+ "timeout"
+ );
+ }
+
+ #[test]
+ fn acp_cancel_drain_timeout_error_classifies_as_cancelled() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::CancelDrainTimeout(
+ Duration::from_secs(5)
+ ))),
+ "cancelled"
+ );
+ }
+
+ #[test]
+ fn agent_error_classifies_as_agent_error() {
+ assert_eq!(
+ classify_turn_failure(&PromptOutcome::Error(acp::AcpError::AgentError {
+ code: -32000,
+ message: "boom".to_string(),
+ })),
+ "agent_error"
+ );
+ }
+}
+
#[allow(clippy::too_many_arguments)]
fn handle_prompt_result(
pool: &mut AgentPool,
@@ -2985,6 +3116,7 @@ fn handle_prompt_result(
PromptOutcome::Cancelled => "cancelled",
PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout",
};
+ let error_class = classify_turn_failure(&result.outcome);
let agent_index = result.agent.index;
// Capture the spawn-time configured model and our PID before the agent is
// moved into match arms below. `desired_model` reflects the config/persona
@@ -3010,6 +3142,7 @@ fn handle_prompt_result(
let mut payload = serde_json::json!({
"outcome": outcome_label,
"error": error_msg,
+ "error_class": error_class,
});
if let Some(code) = error_code {
payload["code"] = serde_json::json!(code);
@@ -3250,6 +3383,7 @@ fn recover_panicked_agent(
serde_json::json!({
"outcome": "panic",
"error": format!("Agent task panicked: {join_error}"),
+ "error_class": "panic",
}),
);
}
@@ -4743,6 +4877,7 @@ mod error_outcome_emission_tests {
Some(channel_id.to_string().as_str())
);
assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id"));
+ assert_eq!(panic.payload["error_class"].as_str(), Some("panic"));
}
#[tokio::test]
@@ -4847,6 +4982,94 @@ mod error_outcome_emission_tests {
.await;
}
+ /// The additive `error_class` field mirrors `classify_turn_failure` for
+ /// each outcome shape, independent of the (unstable, prose) `outcome`/
+ /// `error` fields.
+ #[tokio::test]
+ async fn turn_error_payload_includes_error_class() {
+ let check_class = |outcome: PromptOutcome, expected_class: &'static str| async move {
+ let agent = dummy_agent(0).await;
+ let mut pool = AgentPool::from_slots(vec![None]);
+ let task_id = pool.join_set.spawn(async {}).id();
+ pool.task_map_mut().insert(
+ task_id,
+ crate::pool::TaskMeta {
+ agent_index: 0,
+ channel_id: None,
+ turn_id: "test-turn-id".to_string(),
+ recoverable_batch: None,
+ control_tx: None,
+ steer_tx: None,
+ },
+ );
+ let mut queue = EventQueue::new(config::DedupMode::Queue);
+ let config = test_config();
+ let mut heartbeat_in_flight = false;
+ let removed_channels = HashSet::new();
+ let mut crash_history = vec![SlotCircuit {
+ crash_times: Vec::new(),
+ open_until: None,
+ respawn_in_flight: false,
+ }];
+ let (respawn_tx, _respawn_rx) = mpsc::channel(8);
+ let mut respawn_tasks = tokio::task::JoinSet::new();
+ let observer = ObserverHandle::in_process();
+ let result = PromptResult {
+ agent,
+ source: PromptSource::Channel(Uuid::new_v4()),
+ turn_id: "test-turn-id".to_string(),
+ outcome,
+ batch: None,
+ };
+ handle_prompt_result(
+ &mut pool,
+ &mut queue,
+ &config,
+ result,
+ &mut heartbeat_in_flight,
+ &removed_channels,
+ &mut crash_history,
+ &respawn_tx,
+ &mut respawn_tasks,
+ Some(observer.clone()),
+ None,
+ );
+ let events = observer.snapshot();
+ let turn_error = events.iter().find(|e| e.kind == "turn_error").unwrap();
+ assert_eq!(
+ turn_error.payload["error_class"].as_str().unwrap(),
+ expected_class
+ );
+ };
+ check_class(PromptOutcome::AgentExited, "exited").await;
+ check_class(PromptOutcome::Timeout(TimeoutKind::Idle), "timeout").await;
+ check_class(
+ PromptOutcome::Timeout(TimeoutKind::Hard {
+ recently_active: false,
+ }),
+ "timeout",
+ )
+ .await;
+ check_class(
+ PromptOutcome::CancelDrainTimeout(std::time::Duration::from_secs(5)),
+ "cancelled",
+ )
+ .await;
+ check_class(
+ PromptOutcome::Error(AcpError::Protocol("bad frame".into())),
+ "transport",
+ )
+ .await;
+ check_class(
+ PromptOutcome::Error(AcpError::AgentError {
+ code: -32000,
+ message: "boom".into(),
+ }),
+ "agent_error",
+ )
+ .await;
+ }
+
/// hard-cap timeout dead-letters immediately (no requeue); idle timeout is requeued.
#[tokio::test]
async fn hard_timeout_not_requeued_idle_timeout_is_requeued() {
From dc4869b0049717385445617e2001b8329d8c4eac Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Tue, 21 Jul 2026 11:17:38 -0600
Subject: [PATCH 2/6] feat(desktop): persist classified turn failures per agent
instead of discarding them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
activeAgentTurnsStore previously treated turn_error/agent_panic the same as
turn_completed: endTurn() ran and the outcome/error/code payload was
discarded, so the "Working in #channel" badge just vanished with no trace
of the failure.
Add a per-agent TurnFailure record (outcome, error, code, errorClass,
timestamp) captured from the turn_error/agent_panic payload — including
the new additive error_class field from the backend commit — and exposed
via getLastTurnFailureForAgent / useLastTurnFailure. It is cleared only on
a subsequent turn_completed (a successful completion is the "agent is
healthy again" signal; a mere turn_started is not, so the badge doesn't
flicker off and back on). Both new branches sit behind the same per-agent
watermark gate as before, so the existing stale-replay guarantees (a
replayed turn_error/agent_panic with a null turnId must not resurrect or
re-trigger effects on a live/completed turn) are unchanged — verified with
a new regression test. The failure map is also folded into the
community-switch save/restore snapshot alongside the other four maps.
Wired into ManagedAgentRow's existing StatusBlock, next to the
process-exit-sourced `agent.lastError` surface, rendered through
friendlyTurnErrorCopy so JSON-RPC codes still get friendly copy.
Signed-off-by: Orlando Lopez
---
.../agents/activeAgentTurnsStore.test.mjs | 208 ++++++++++++++++++
.../features/agents/activeAgentTurnsStore.ts | 140 +++++++++++-
.../features/agents/ui/ManagedAgentRow.tsx | 28 ++-
3 files changed, 372 insertions(+), 4 deletions(-)
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index c0e0b5c01d..d6f3c4f59e 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -6,6 +6,7 @@ import {
syncActiveAgentTurnsFromObserver,
getActiveTurnsForAgent,
getActiveTurnsByChannel,
+ getLastTurnFailureForAgent,
resetActiveAgentTurnsStore,
subscribeActiveAgentTurns,
saveActiveAgentTurnsForCommunity,
@@ -566,6 +567,188 @@ describe("activeAgentTurnsStore", () => {
});
});
+ describe("last turn failure (error_class persistence, #1659)", () => {
+ it("returns null when the agent has no recorded failure", () => {
+ assert.equal(getLastTurnFailureForAgent(AGENT), null);
+ });
+
+ it("persists outcome/error/code/errorClass/timestamp after a turn_error", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ timestamp: "2024-01-01T00:00:05Z",
+ payload: {
+ outcome: "idle_timeout",
+ error: "Idle timeout — no agent activity for 30s",
+ code: null,
+ error_class: "timeout",
+ },
+ }),
+ ]);
+
+ const failure = getLastTurnFailureForAgent(AGENT);
+ assert.ok(failure, "turn_error must persist a last-failure record");
+ assert.equal(failure.outcome, "idle_timeout");
+ assert.equal(
+ failure.error,
+ "Idle timeout — no agent activity for 30s",
+ );
+ assert.equal(failure.code, null);
+ assert.equal(failure.errorClass, "timeout");
+ assert.equal(failure.timestamp, Date.parse("2024-01-01T00:00:05Z"));
+ });
+
+ it("persists after an agent_panic with error_class 'panic'", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "agent_panic",
+ turnId: "t1",
+ channelId: "c1",
+ payload: {
+ outcome: "panic",
+ error: "Agent task panicked: boom",
+ error_class: "panic",
+ },
+ }),
+ ]);
+
+ const failure = getLastTurnFailureForAgent(AGENT);
+ assert.ok(failure);
+ assert.equal(failure.outcome, "panic");
+ assert.equal(failure.errorClass, "panic");
+ assert.equal(failure.code, null);
+ });
+
+ it("coerces a numeric code from the payload", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ payload: {
+ outcome: "error",
+ error: "Agent reported error (code -32001): llm auth: denied",
+ code: -32001,
+ error_class: "agent_error",
+ },
+ }),
+ ]);
+
+ assert.equal(getLastTurnFailureForAgent(AGENT).code, -32001);
+ });
+
+ it("clears the failure once the agent completes a subsequent turn successfully", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ payload: { outcome: "error", error: "boom", error_class: "transport" },
+ }),
+ ]);
+ assert.ok(getLastTurnFailureForAgent(AGENT), "failure must be recorded");
+
+ // A fresh turn starting is NOT proof of health — the failure must
+ // survive a mere turn_started.
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 3, turnId: "t2", channelId: "c1" }),
+ ]);
+ assert.ok(
+ getLastTurnFailureForAgent(AGENT),
+ "turn_started must not clear a persisted failure",
+ );
+
+ // Only a successful completion clears it.
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({
+ seq: 4,
+ kind: "turn_completed",
+ turnId: "t2",
+ channelId: "c1",
+ }),
+ ]);
+ assert.equal(
+ getLastTurnFailureForAgent(AGENT),
+ null,
+ "a successful turn_completed must clear the persisted failure",
+ );
+ });
+
+ it("is scoped per agent", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ payload: { outcome: "error", error: "boom" },
+ }),
+ ]);
+ assert.ok(getLastTurnFailureForAgent(AGENT));
+ assert.equal(getLastTurnFailureForAgent(AGENT_2), null);
+ });
+
+ it("a replayed stale turn_error does not overwrite a newer cleared state", () => {
+ // A turn fails, then a later turn completes successfully — clearing it.
+ const buffer = [
+ makeEvent({
+ seq: 1,
+ turnId: "t1",
+ channelId: "c1",
+ timestamp: "2024-01-01T00:00:00Z",
+ }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ timestamp: "2024-01-01T00:00:01Z",
+ payload: { outcome: "error", error: "boom" },
+ }),
+ makeEvent({
+ seq: 3,
+ turnId: "t2",
+ channelId: "c1",
+ timestamp: "2024-01-01T00:00:02Z",
+ }),
+ makeEvent({
+ seq: 4,
+ kind: "turn_completed",
+ turnId: "t2",
+ channelId: "c1",
+ timestamp: "2024-01-01T00:00:03Z",
+ }),
+ ];
+ syncAgentTurnsFromEvents(AGENT, buffer);
+ assert.equal(
+ getLastTurnFailureForAgent(AGENT),
+ null,
+ "completed turn clears the earlier failure",
+ );
+
+ // Replaying the identical (now fully stale) buffer must be a no-op —
+ // the watermark gate must block every event, including the turn_error,
+ // from re-running its effects.
+ syncAgentTurnsFromEvents(AGENT, buffer);
+ assert.equal(
+ getLastTurnFailureForAgent(AGENT),
+ null,
+ "replayed stale turn_error must not resurrect a cleared failure",
+ );
+ });
+ });
+
describe("getActiveTurnsForAgent", () => {
it("returns empty array for null/undefined pubkey", () => {
assert.equal(getActiveTurnsForAgent(null).length, 0);
@@ -1719,6 +1902,31 @@ describe("community-switch save / restore", () => {
assert.ok(bChannels.has("c2"), "ws-b must restore c2");
});
+ it("last turn failure survives a save/restore round-trip", () => {
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ payload: { outcome: "error", error: "boom", error_class: "transport" },
+ }),
+ ]);
+ saveActiveAgentTurnsForCommunity("ws-a");
+ resetActiveAgentTurnsStore();
+ assert.equal(
+ getLastTurnFailureForAgent(AGENT),
+ null,
+ "reset must clear the live failure map",
+ );
+
+ restoreActiveAgentTurnsForCommunity("ws-a");
+ const failure = getLastTurnFailureForAgent(AGENT);
+ assert.ok(failure, "restore must bring back the saved failure");
+ assert.equal(failure.errorClass, "transport");
+ });
+
it("clearSavedCommunitySnapshot discards the snapshot so restore is a no-op", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index 07ad4fa6b8..16d46c8d06 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -7,6 +7,7 @@ import {
} from "@/features/agents/observerRelayStore";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ObserverEvent } from "./ui/agentSessionTypes";
+import { asRecord, asString } from "./ui/agentSessionUtils";
/** Harness emits turn_liveness every ~10s (BUZZ_ACP_TURN_LIVENESS_SECS). */
const LIVENESS_INTERVAL_MS = 10_000;
@@ -40,6 +41,27 @@ type ActiveTurn = {
lastActivityAt: number;
};
+/**
+ * A classified turn failure, persisted per agent so the UI can show *why*
+ * the working badge disappeared instead of it just vanishing (issue #1659).
+ * Sourced from a `turn_error`/`agent_panic` observer payload — see
+ * `emit_turn_error`/`recover_panicked_agent` in `crates/buzz-acp/src/lib.rs`.
+ */
+export type TurnFailure = {
+ /** Coarse outcome string, e.g. "error" | "idle_timeout" | "hard_timeout" | "panic". */
+ outcome: string;
+ /** Raw error text (harness Display string), not yet passed through `friendlyTurnErrorCopy`. */
+ error: string;
+ /** JSON-RPC error code when the harness reported one (`AcpError::AgentError`). */
+ code: number | null;
+ /** Stable machine discriminant from `classify_turn_failure` (timeout/transport/
+ * agent_error/protocol/exited/cancelled/panic/error), or null for payloads
+ * from a harness build that predates the field. */
+ errorClass: string | null;
+ /** Agent-host clock ms when the failure was recorded (parsed from the event). */
+ timestamp: number;
+};
+
/** One working channel surfaced to the UI, anchored to the desktop clock. */
export type ActiveTurnSummary = {
channelId: string;
@@ -95,6 +117,13 @@ const lastProcessed = new Map();
// than its recorded terminal timestamp.
const terminalAtByAgent = new Map>();
+// Per-agent last classified turn failure (normalized pubkey → TurnFailure).
+// Set on turn_error/agent_panic, cleared on turn_completed (see processEvent).
+// Absent entries mean "no known failure" — never store a placeholder/empty
+// TurnFailure, so `getLastTurnFailureForAgent` can use presence-in-map as the
+// sole signal.
+const lastFailureByAgent = new Map();
+
let pruneInterval: ReturnType | null = null;
function invalidateCache(agentKey: string) {
@@ -270,6 +299,47 @@ function endTurn(
invalidateCache(key);
}
+/** Coerce a `code` payload field (untyped JSON) to a finite number, mirroring
+ * `friendlyTurnErrorCopy`'s own coercion so the two agree on what "no code"
+ * means. */
+function asFiniteNumber(value: unknown): number | null {
+ if (value == null) return null;
+ const n = typeof value === "number" ? value : Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+/** Build a `TurnFailure` from a `turn_error`/`agent_panic` observer event. */
+function extractTurnFailure(event: ObserverEvent): TurnFailure {
+ const payload = asRecord(event.payload);
+ return {
+ outcome: asString(payload.outcome) ?? "error",
+ error: asString(payload.error) ?? "Unknown error",
+ code: asFiniteNumber(payload.code),
+ errorClass: asString(payload.error_class),
+ timestamp: parseTimestamp(event.timestamp) ?? Date.now(),
+ };
+}
+
+/**
+ * Record the agent's last classified turn failure. Called only from the
+ * turn_error/agent_panic branch of processEvent, which already sits behind
+ * the per-agent watermark gate — a replayed/stale failure event never
+ * reaches here (see the gating note above processEvent).
+ */
+function setLastTurnFailure(agentKey: string, failure: TurnFailure) {
+ lastFailureByAgent.set(agentKey, failure);
+}
+
+/**
+ * Clear the agent's last turn failure. Called on `turn_completed` — a
+ * successful completion is the "demonstrably healthy again" signal (unlike
+ * `turn_started`, which proves nothing: a turn that starts and immediately
+ * fails the same way would otherwise make the badge flicker off and back on).
+ */
+function clearLastTurnFailure(agentKey: string) {
+ lastFailureByAgent.delete(agentKey);
+}
+
/** True when every tracked turn for one agent is stale, but only until the
* bounded backstop expires. Other agents' activity intentionally has no effect. */
function shouldPausePrune(
@@ -355,6 +425,17 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
}
break;
case "turn_completed":
+ endTurn(
+ agentPubkey,
+ event.turnId ?? null,
+ event.channelId ?? null,
+ Date.parse(event.timestamp),
+ );
+ // A successful completion is the "agent is healthy again" signal —
+ // clear any previously-persisted failure so its badge disappears.
+ clearLastTurnFailure(key);
+ notifyListeners();
+ return;
case "turn_error":
case "agent_panic":
endTurn(
@@ -363,6 +444,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
event.channelId ?? null,
Date.parse(event.timestamp),
);
+ setLastTurnFailure(key, extractTurnFailure(event));
notifyListeners();
return;
case "acp_read":
@@ -452,6 +534,19 @@ export function getActiveTurnsForAgent(
return result;
}
+/**
+ * Returns the agent's last classified turn failure, or null when there is
+ * none (either it never failed, or it completed a turn successfully since).
+ * Reference-stable across snapshots that don't touch this agent's entry —
+ * required for `useSyncExternalStore`.
+ */
+export function getLastTurnFailureForAgent(
+ agentPubkey: string | null | undefined,
+): TurnFailure | null {
+ if (!agentPubkey) return null;
+ return lastFailureByAgent.get(normalizePubkey(agentPubkey)) ?? null;
+}
+
const EMPTY_TURNS: ActiveTurnSummary[] = [];
const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = [];
@@ -531,6 +626,23 @@ export function useActiveAgentTurns(
return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot);
}
+/**
+ * Hook: returns the agent's last classified turn failure (or null), so a UI
+ * badge can survive past the transient "Working in #channel" indicator that
+ * `endTurn` removes. Re-renders when the failure is set or cleared for this
+ * agent.
+ */
+export function useLastTurnFailure(
+ agentPubkey: string | null | undefined,
+): TurnFailure | null {
+ const getSnapshot = React.useCallback(
+ () => getLastTurnFailureForAgent(agentPubkey),
+ [agentPubkey],
+ );
+
+ return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot);
+}
+
/**
* Hook: returns channels with active agent work across all tracked agents.
* Re-renders when the channel set changes — not when the clock ticks.
@@ -586,6 +698,7 @@ export function resetActiveAgentTurnsStore() {
cachedTurnSummaries.clear();
cachedChannelTurnSummaries = null;
terminalAtByAgent.clear();
+ lastFailureByAgent.clear();
notifyListeners();
}
@@ -598,6 +711,7 @@ type TurnsStoreSnapshot = {
offsets: Map;
watermarks: Map;
terminals: Map>;
+ failures: Map;
};
/** Per-community snapshots. Keyed by community ID. */
@@ -609,11 +723,15 @@ const savedByCommunity = new Map();
* tombstone map are empty there is nothing worth restoring — discard any
* previously-saved snapshot instead.
*
- * Deep-clones all four maps so subsequent mutations on the live maps do not
+ * Deep-clones all five maps so subsequent mutations on the live maps do not
* corrupt the snapshot.
*/
export function saveActiveAgentTurnsForCommunity(communityId: string): void {
- if (activeTurnsByAgent.size === 0 && terminalAtByAgent.size === 0) {
+ if (
+ activeTurnsByAgent.size === 0 &&
+ terminalAtByAgent.size === 0 &&
+ lastFailureByAgent.size === 0
+ ) {
savedByCommunity.delete(communityId);
return;
}
@@ -639,7 +757,18 @@ export function saveActiveAgentTurnsForCommunity(communityId: string): void {
terminals.set(agentKey, new Map(tombstones));
}
- savedByCommunity.set(communityId, { turns, offsets, watermarks, terminals });
+ // Shallow-clone lastFailureByAgent — TurnFailure values are plain structs
+ // and are never mutated in place (setLastTurnFailure always writes a fresh
+ // object), so a shallow copy of the map is enough to isolate the snapshot.
+ const failures = new Map(lastFailureByAgent);
+
+ savedByCommunity.set(communityId, {
+ turns,
+ offsets,
+ watermarks,
+ terminals,
+ failures,
+ });
}
/**
@@ -670,6 +799,7 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void {
clockOffsetByAgent.clear();
lastProcessed.clear();
terminalAtByAgent.clear();
+ lastFailureByAgent.clear();
const now = Date.now();
@@ -693,6 +823,10 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void {
terminalAtByAgent.set(agentKey, new Map(tombstones));
}
+ for (const [agentKey, failure] of snap.failures) {
+ lastFailureByAgent.set(agentKey, failure);
+ }
+
cachedTurnSummaries.clear();
cachedChannelTurnSummaries = null;
notifyListeners();
diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
index f39d96fd26..06f327a8a2 100644
--- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
@@ -14,6 +14,8 @@ import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
+import { useLastTurnFailure } from "@/features/agents/activeAgentTurnsStore";
+import type { TurnFailure } from "@/features/agents/activeAgentTurnsStore";
import { useNow } from "@/shared/lib/useNow";
import type {
ManagedAgent,
@@ -23,7 +25,10 @@ import type {
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { AgentConfigPanel } from "./AgentConfigPanel";
-import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
+import {
+ friendlyAgentLastError,
+ friendlyTurnErrorCopy,
+} from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { PubKey } from "@/shared/ui/PubKey";
import { SubsectionLabel } from "@/shared/ui/PageHeader";
@@ -93,6 +98,13 @@ export function ManagedAgentRow({
agent.lastError,
agent.lastErrorCode,
);
+ // Classified turn failure from the observer stream (turn_error/agent_panic,
+ // see activeAgentTurnsStore) — distinct pipeline from `agent.lastError`
+ // above (recovered from the process log tail on exit): this one persists
+ // past a mid-session turn failure that never crashed the process, so the
+ // "Working in #channel" badge above doesn't just silently vanish (#1659).
+ // Cleared once the agent completes a turn successfully.
+ const lastTurnFailure = useLastTurnFailure(agent.pubkey);
return (
;
isWorking: boolean;
+ lastTurnFailure: TurnFailure | null;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
@@ -380,6 +396,16 @@ function StatusBlock({
{friendlyError.copy}
) : null}
+ {lastTurnFailure ? (
+
+ Last turn error (
+ {lastTurnFailure.errorClass ?? lastTurnFailure.outcome}):{" "}
+ {friendlyTurnErrorCopy(lastTurnFailure.error, lastTurnFailure.code)}
+
+ ) : null}
);
}
From e8cfa16878660f93a4ec8a77da19d7e6a3a5023d Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Tue, 21 Jul 2026 13:05:24 -0600
Subject: [PATCH 3/6] style(desktop): apply biome formatting to
activeAgentTurnsStore.test.mjs
Signed-off-by: Orlando Lopez
---
.../features/agents/activeAgentTurnsStore.test.mjs | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index d6f3c4f59e..d279500e82 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -593,10 +593,7 @@ describe("activeAgentTurnsStore", () => {
const failure = getLastTurnFailureForAgent(AGENT);
assert.ok(failure, "turn_error must persist a last-failure record");
assert.equal(failure.outcome, "idle_timeout");
- assert.equal(
- failure.error,
- "Idle timeout — no agent activity for 30s",
- );
+ assert.equal(failure.error, "Idle timeout — no agent activity for 30s");
assert.equal(failure.code, null);
assert.equal(failure.errorClass, "timeout");
assert.equal(failure.timestamp, Date.parse("2024-01-01T00:00:05Z"));
@@ -653,7 +650,11 @@ describe("activeAgentTurnsStore", () => {
kind: "turn_error",
turnId: "t1",
channelId: "c1",
- payload: { outcome: "error", error: "boom", error_class: "transport" },
+ payload: {
+ outcome: "error",
+ error: "boom",
+ error_class: "transport",
+ },
}),
]);
assert.ok(getLastTurnFailureForAgent(AGENT), "failure must be recorded");
From ce7f12284010af438b15a193080eab9e079f08fa Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Tue, 21 Jul 2026 13:27:29 -0600
Subject: [PATCH 4/6] docs: clarify error_class doc comment and fix stale
map-count comment
Signed-off-by: Orlando Lopez
---
crates/buzz-acp/src/lib.rs | 15 ++++++++++-----
.../src/features/agents/activeAgentTurnsStore.ts | 2 +-
2 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 5e85e71c66..c267aecaf2 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2845,11 +2845,16 @@ fn spawn_failure_notice(
/// Stable, machine-readable discriminant for a failed turn, derived from the
/// same data that already drives `outcome_label` and `is_transport_error`.
///
-/// This is intentionally coarser than `outcome_label`: consumers (the
-/// desktop UI, external tooling) should be able to group failures into a
-/// handful of buckets without parsing prose. New variants should only be
-/// added for a genuinely new failure shape — do not multiply classes for
-/// wording differences.
+/// Unlike `outcome_label` (which mirrors the `PromptOutcome` variant), this
+/// groups failures into a handful of actionable buckets so consumers (the
+/// desktop UI, external tooling) never have to parse prose. Naming note:
+/// `AcpError::Protocol` means a broken frame on the wire, so it lands in
+/// `"transport"` (matching the `is_transport_error` respawn grouping), while
+/// `"protocol"` is reserved for `AcpError::Json` — the agent spoke, but not
+/// valid JSON-RPC. The `agent_panic` emit site labels itself `"panic"`
+/// directly (there is no `PromptOutcome` for a panicked task). New classes
+/// should only be added for a genuinely new failure shape — do not multiply
+/// classes for wording differences.
fn classify_turn_failure(outcome: &PromptOutcome) -> &'static str {
match outcome {
PromptOutcome::Ok(_) => "error",
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index 16d46c8d06..d7ba093076 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -775,7 +775,7 @@ export function saveActiveAgentTurnsForCommunity(communityId: string): void {
* Restore a previously saved active-turns snapshot for `communityId` into the
* module maps. No-op when no snapshot exists.
*
- * Clears all four module maps before writing so the function is
+ * Clears all five module maps before writing so the function is
* self-contained — it replaces rather than merging, regardless of whether the
* caller pre-cleared. At the primary call site (`useCommunityInit`) the maps
* are already empty after `resetCommunityState()`, but this guard makes the
From 3cc3dae47348336d081baf2ddf499dd831f099fd Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Mon, 27 Jul 2026 07:02:03 -0600
Subject: [PATCH 5/6] fix(desktop): clear a persisted failure only on a
successful turn_completed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-up for #1659: TurnCompletionGuard emits `turn_completed` on every
exit path (success, error, timeout, cancel, panic), so the desktop store cleared
a persisted failure badge on a *cancelled* turn too — erasing a prior genuine
failure that a cancel proves nothing about.
- buzz-acp: tag the `turn_completed` payload with a coarse `outcome`, set to
"ok" only on the `PromptOutcome::Ok` send paths (via a shared success flag on
the completion guard) and "incomplete" on every other exit.
- desktop store: clear the failure only when `outcome` is "ok" or absent
(absent = a harness build predating the field, so mixed-version behavior is
unchanged). A cancelled/failed completion no longer clears the badge.
- add a regression test: failure -> cancelled turn_completed -> failure remains;
an "ok" completion still clears it.
- classify_turn_failure: document the unreachable `Ok(_)` defensive default.
Co-Authored-By: Claude Opus 4.8 (1M context)
Signed-off-by: Orlando Lopez
---
crates/buzz-acp/src/lib.rs | 4 ++
crates/buzz-acp/src/pool.rs | 30 +++++++++-
.../agents/activeAgentTurnsStore.test.mjs | 55 +++++++++++++++++++
.../features/agents/activeAgentTurnsStore.ts | 27 ++++++---
4 files changed, 107 insertions(+), 9 deletions(-)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index c267aecaf2..154cf9c0fd 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2857,6 +2857,10 @@ fn spawn_failure_notice(
/// classes for wording differences.
fn classify_turn_failure(outcome: &PromptOutcome) -> &'static str {
match outcome {
+ // Defensive, unreachable default: both emit sites call this only on a
+ // failing outcome, never on `Ok`. Bucketing it as "error" keeps the match
+ // total instead of panicking; a future caller must not treat this arm as a
+ // real classification of a successful turn.
PromptOutcome::Ok(_) => "error",
PromptOutcome::Timeout(_) => "timeout",
PromptOutcome::AgentExited => "exited",
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 61ecbff6c6..673dbf98e9 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -20,6 +20,7 @@
//! `AcpClient` is NOT Clone — ownership moves out on claim and back on return.
use std::collections::{HashMap, HashSet};
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -1247,11 +1248,17 @@ pub async fn run_prompt_task(
// metadata now, before the agent is moved into PromptResult. It must be
// declared before `liveness_guard`: Rust drops locals in reverse order, so
// liveness is aborted before completion makes the turn terminal.
+ // Shared success flag for the completion guard above. It stays false unless a
+ // `PromptOutcome::Ok` send path flips it, so the `turn_completed` emitted on
+ // any other exit (error/timeout/cancel/panic) reports a non-success outcome —
+ // stopping a cancelled turn from clearing a prior failure badge (#1659).
+ let turn_succeeded = Arc::new(AtomicBool::new(false));
let _turn_guard = TurnCompletionGuard::new(
agent.acp.observer_handle(),
agent.acp.observer_agent_index(),
observer_channel_id,
turn_id.clone(),
+ Arc::clone(&turn_succeeded),
);
// Start liveness with `turn_started`, not the final session/prompt call:
@@ -1920,6 +1927,7 @@ pub async fn run_prompt_task(
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
)
.await;
+ turn_succeeded.store(true, Ordering::Relaxed);
send_prompt_result(
&result_tx,
&turn_id,
@@ -1983,6 +1991,7 @@ pub async fn run_prompt_task(
)
.await;
+ turn_succeeded.store(true, Ordering::Relaxed);
send_prompt_result(
&result_tx,
&turn_id,
@@ -3225,6 +3234,11 @@ struct TurnCompletionGuard {
agent_index: Option,
channel_id: Option,
turn_id: String,
+ /// Whether the turn reached a successful `PromptOutcome::Ok` send. Defaults
+ /// to false and is flipped true only on the success paths, so every other
+ /// exit (error/timeout/cancel/panic) reports a non-success `outcome` on the
+ /// `turn_completed` event emitted at drop.
+ succeeded: Arc,
}
impl TurnCompletionGuard {
@@ -3233,12 +3247,14 @@ impl TurnCompletionGuard {
agent_index: Option,
channel_id: Option,
turn_id: String,
+ succeeded: Arc,
) -> Self {
Self {
observer,
agent_index,
channel_id,
turn_id,
+ succeeded,
}
}
}
@@ -3247,11 +3263,23 @@ impl Drop for TurnCompletionGuard {
fn drop(&mut self) {
if let Some(observer) = self.observer.take() {
let context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone()));
+ // Tag the completion with a coarse outcome. `turn_completed` fires on
+ // EVERY exit path, so a cancelled/failed turn must not read as a
+ // healthy completion in the desktop store: only the success paths set
+ // `succeeded`. A genuine failure additionally carries its detail on the
+ // separate `turn_error`/`agent_panic` event; this field just gates the
+ // store's "clear the failure badge" decision. Harnesses that predate
+ // this field emit no `outcome`, which the store treats as success.
+ let outcome = if self.succeeded.load(Ordering::Relaxed) {
+ "ok"
+ } else {
+ "incomplete"
+ };
observer.emit(
"turn_completed",
self.agent_index,
&context,
- serde_json::json!({}),
+ serde_json::json!({ "outcome": outcome }),
);
}
}
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index d279500e82..2143348a05 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -685,6 +685,61 @@ describe("activeAgentTurnsStore", () => {
);
});
+ it("keeps a persisted failure across a cancelled (non-'ok') turn_completed", () => {
+ // Regression (#2240 review): turn_completed fires on EVERY exit path
+ // (TurnCompletionGuard), so a cancelled turn used to clear a prior genuine
+ // failure. It must survive a completion whose outcome is not "ok".
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
+ makeEvent({
+ seq: 2,
+ kind: "turn_error",
+ turnId: "t1",
+ channelId: "c1",
+ payload: {
+ outcome: "error",
+ error: "boom",
+ error_class: "transport",
+ },
+ }),
+ ]);
+ assert.ok(getLastTurnFailureForAgent(AGENT), "failure must be recorded");
+
+ // A later turn starts and is cancelled — its turn_completed reports a
+ // non-success outcome, which must NOT clear the failure.
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 3, turnId: "t2", channelId: "c1" }),
+ makeEvent({
+ seq: 4,
+ kind: "turn_completed",
+ turnId: "t2",
+ channelId: "c1",
+ payload: { outcome: "incomplete" },
+ }),
+ ]);
+ assert.ok(
+ getLastTurnFailureForAgent(AGENT),
+ "a cancelled turn_completed must not clear the persisted failure",
+ );
+
+ // Only a genuinely successful completion (outcome: "ok") clears it.
+ syncAgentTurnsFromEvents(AGENT, [
+ makeEvent({ seq: 5, turnId: "t3", channelId: "c1" }),
+ makeEvent({
+ seq: 6,
+ kind: "turn_completed",
+ turnId: "t3",
+ channelId: "c1",
+ payload: { outcome: "ok" },
+ }),
+ ]);
+ assert.equal(
+ getLastTurnFailureForAgent(AGENT),
+ null,
+ "an 'ok' turn_completed must clear the persisted failure",
+ );
+ });
+
it("is scoped per agent", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index d7ba093076..02c17df7db 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -331,10 +331,12 @@ function setLastTurnFailure(agentKey: string, failure: TurnFailure) {
}
/**
- * Clear the agent's last turn failure. Called on `turn_completed` — a
- * successful completion is the "demonstrably healthy again" signal (unlike
- * `turn_started`, which proves nothing: a turn that starts and immediately
- * fails the same way would otherwise make the badge flicker off and back on).
+ * Clear the agent's last turn failure. Called from the `turn_completed` branch
+ * only when the event's `outcome` marks a genuine success — a successful
+ * completion is the "demonstrably healthy again" signal (unlike `turn_started`,
+ * which proves nothing: a turn that starts and immediately fails the same way
+ * would otherwise make the badge flicker off and back on). A cancelled turn is
+ * NOT a success and must not clear the badge, so the gate lives at the call site.
*/
function clearLastTurnFailure(agentKey: string) {
lastFailureByAgent.delete(agentKey);
@@ -424,18 +426,27 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
return;
}
break;
- case "turn_completed":
+ case "turn_completed": {
endTurn(
agentPubkey,
event.turnId ?? null,
event.channelId ?? null,
Date.parse(event.timestamp),
);
- // A successful completion is the "agent is healthy again" signal —
- // clear any previously-persisted failure so its badge disappears.
- clearLastTurnFailure(key);
+ // `turn_completed` fires on EVERY turn exit path — success, error, timeout,
+ // cancel, panic (TurnCompletionGuard in crates/buzz-acp/src/pool.rs). Only a
+ // genuine success proves the agent is healthy again, so clear a persisted
+ // failure ONLY then. The backend tags the payload with `outcome`; treat "ok"
+ // — or an absent field, from a harness build that predates it — as success.
+ // A cancelled/failed turn now reports a non-"ok" outcome and no longer
+ // erases the badge from a prior genuinely-failed turn.
+ const completionOutcome = asString(asRecord(event.payload).outcome);
+ if (completionOutcome == null || completionOutcome === "ok") {
+ clearLastTurnFailure(key);
+ }
notifyListeners();
return;
+ }
case "turn_error":
case "agent_panic":
endTurn(
From 819c29f3488f7b2c58caca644bccefaa5906fd44 Mon Sep 17 00:00:00 2001
From: Orlando Lopez
Date: Wed, 29 Jul 2026 12:09:15 -0600
Subject: [PATCH 6/6] fix(desktop): humanize turn-failure class, show its age,
drop the duplicate exit line
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the three review nits on #2240, all in the turn-failure line:
- The raw `error_class` token no longer reaches the UI. `friendlyTurnErrorClass`
maps the closed set from `classify_turn_failure` to human copy ("Timed out",
"Agent crashed"), falls back to the coarse `outcome` for pre-`error_class`
harnesses, and title-cases anything unmapped so a class added to the Rust enum
degrades to "Some New Class" instead of leaking `some_new_class`.
- `TurnFailure.timestamp` was stored and asserted but never rendered. It is now
`failedAt` — the agent-host timestamp translated through that agent's skew
offset, so it is a desktop-clock instant the UI can age against — and it
renders as a relative label ("Timed out, 3m ago"). Unlike a live turn's
`anchorAt` it is fixed at write time: a failure is a terminal past moment shown
at minute granularity, so sub-second retroactive drift is invisible, and a
stable value keeps `useSyncExternalStore` correct without a snapshot cache.
The 1-minute tick lives in a leaf component, so only rows actually showing a
failure own an interval.
- An `exited` turn failure no longer renders beside the process-level
`friendlyError`, where one crash read as two independent errors. Every other
class describes a failure the process line does not explain, so it still shows.
Gates: desktop `tsc --noEmit` clean, `biome check .` clean, file-size/px-text/
pubkey checks clean, test suite 3351/0 (up 12).
Co-Authored-By: Claude Opus 5 (1M context)
Signed-off-by: Orlando Lopez
---
.../agents/activeAgentTurnsStore.test.mjs | 44 ++++++++++++++-
.../features/agents/activeAgentTurnsStore.ts | 35 ++++++++++--
.../lib/friendlyAgentLastError.test.mjs | 50 +++++++++++++++++
.../agents/lib/friendlyAgentLastError.ts | 55 +++++++++++++++++++
.../features/agents/ui/ManagedAgentRow.tsx | 40 ++++++++++----
.../features/agents/ui/agentSessionUtils.ts | 18 ++++++
6 files changed, 223 insertions(+), 19 deletions(-)
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index 2143348a05..f2fa86c2c4 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -18,7 +18,7 @@ import {
getAgentObserverSnapshot,
resetAgentObserverStore,
} from "./observerRelayStore.ts";
-import { formatElapsed } from "./ui/agentSessionUtils.ts";
+import { formatAgo, formatElapsed } from "./ui/agentSessionUtils.ts";
const AGENT =
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
@@ -572,7 +572,7 @@ describe("activeAgentTurnsStore", () => {
assert.equal(getLastTurnFailureForAgent(AGENT), null);
});
- it("persists outcome/error/code/errorClass/timestamp after a turn_error", () => {
+ it("persists outcome/error/code/errorClass/failedAt after a turn_error", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({
@@ -596,7 +596,16 @@ describe("activeAgentTurnsStore", () => {
assert.equal(failure.error, "Idle timeout — no agent activity for 30s");
assert.equal(failure.code, null);
assert.equal(failure.errorClass, "timeout");
- assert.equal(failure.timestamp, Date.parse("2024-01-01T00:00:05Z"));
+ // `failedAt` is desktop-clock anchored: the agent-host timestamp
+ // translated through the skew offset sampled from these very events.
+ // Both arrive "now" from the desktop's perspective — despite the 2024
+ // host clock — so the failure must land at ~Date.now(), not at the raw
+ // host epoch. That's what makes `now - failedAt` a usable age.
+ const drift = Math.abs(failure.failedAt - Date.now());
+ assert.ok(
+ drift < 1_000,
+ `failedAt must anchor to the desktop clock (off by ${drift}ms)`,
+ );
});
it("persists after an agent_panic with error_class 'panic'", () => {
@@ -1737,6 +1746,35 @@ describe("formatElapsed", () => {
});
});
+describe("formatAgo", () => {
+ it("renders sub-minute deltas as 'just now'", () => {
+ assert.equal(formatAgo(0), "just now");
+ assert.equal(formatAgo(59_000), "just now");
+ });
+
+ it("rolls into minutes at exactly 60s", () => {
+ assert.equal(formatAgo(60_000), "1m ago");
+ assert.equal(formatAgo(119_000), "1m ago");
+ });
+
+ it("rolls into hours at exactly 60m", () => {
+ assert.equal(formatAgo(3_599_000), "59m ago");
+ assert.equal(formatAgo(3_600_000), "1h ago");
+ });
+
+ it("rolls into days at exactly 24h", () => {
+ assert.equal(formatAgo(86_399_000), "23h ago");
+ assert.equal(formatAgo(86_400_000), "1d ago");
+ assert.equal(formatAgo(200_000_000), "2d ago");
+ });
+
+ it("clamps a negative delta to 'just now' rather than '-1m ago'", () => {
+ // Reachable when a skew correction lands a recorded moment slightly ahead
+ // of the desktop clock.
+ assert.equal(formatAgo(-5_000), "just now");
+ });
+});
+
describe("community-switch save / restore", () => {
beforeEach(() => {
resetActiveAgentTurnsStore();
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index 02c17df7db..6f0ff95f39 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -58,8 +58,19 @@ export type TurnFailure = {
* agent_error/protocol/exited/cancelled/panic/error), or null for payloads
* from a harness build that predates the field. */
errorClass: string | null;
- /** Agent-host clock ms when the failure was recorded (parsed from the event). */
- timestamp: number;
+ /**
+ * When the failure happened, in DESKTOP-clock ms — the event's agent-host
+ * timestamp translated through that agent's skew offset, so the UI can render
+ * an age against `Date.now()` the same way a working badge does.
+ *
+ * Unlike a live turn's `anchorAt` (derived at read time so a later, tighter
+ * offset retroactively corrects it), this is fixed when the failure is
+ * recorded. A failure is a terminal point in the past rendered at
+ * minute granularity, so sub-second retroactive drift is invisible — and
+ * fixing it at write time keeps the value reference-stable for
+ * `useSyncExternalStore` without a derived-snapshot cache.
+ */
+ failedAt: number;
};
/** One working channel surfaced to the UI, anchored to the desktop clock. */
@@ -308,15 +319,27 @@ function asFiniteNumber(value: unknown): number | null {
return Number.isFinite(n) ? n : null;
}
-/** Build a `TurnFailure` from a `turn_error`/`agent_panic` observer event. */
-function extractTurnFailure(event: ObserverEvent): TurnFailure {
+/**
+ * Build a `TurnFailure` from a `turn_error`/`agent_panic` observer event.
+ *
+ * `failedAt` is translated into desktop-clock terms with the agent's skew
+ * offset, which `processEvent` has already refined from this very event before
+ * calling here. An unparseable timestamp falls back to the desktop clock
+ * directly — already in the target frame, so no offset applies.
+ */
+function extractTurnFailure(
+ agentKey: string,
+ event: ObserverEvent,
+): TurnFailure {
const payload = asRecord(event.payload);
+ const hostMs = parseTimestamp(event.timestamp);
+ const offset = clockOffsetByAgent.get(agentKey) ?? 0;
return {
outcome: asString(payload.outcome) ?? "error",
error: asString(payload.error) ?? "Unknown error",
code: asFiniteNumber(payload.code),
errorClass: asString(payload.error_class),
- timestamp: parseTimestamp(event.timestamp) ?? Date.now(),
+ failedAt: hostMs == null ? Date.now() : hostMs + offset,
};
}
@@ -455,7 +478,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
event.channelId ?? null,
Date.parse(event.timestamp),
);
- setLastTurnFailure(key, extractTurnFailure(event));
+ setLastTurnFailure(key, extractTurnFailure(key, event));
notifyListeners();
return;
case "acp_read":
diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
index 597b1b9323..56c6bc437c 100644
--- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
+++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
@@ -3,7 +3,9 @@ import test from "node:test";
import {
friendlyAgentLastError,
+ friendlyTurnErrorClass,
friendlyTurnErrorCopy,
+ shouldShowTurnFailure,
CLI_ACP_INTERNAL_ERROR_COPY,
MODEL_NOT_FOUND_COPY,
RELAY_MESH_DENIED_COPY,
@@ -316,3 +318,51 @@ test("-32603 does not affect -32001/-32002 classification (regression)", () => {
copy: MODEL_NOT_FOUND_COPY,
});
});
+
+test("friendlyTurnErrorClass: every error_class discriminant renders human copy", () => {
+ // The closed set emitted by `classify_turn_failure` — none may leak raw.
+ const expected = {
+ timeout: "Timed out",
+ transport: "Connection lost",
+ agent_error: "Agent error",
+ protocol: "Protocol error",
+ exited: "Agent exited",
+ cancelled: "Cancelled",
+ panic: "Agent crashed",
+ error: "Error",
+ };
+ for (const [errorClass, label] of Object.entries(expected)) {
+ assert.equal(friendlyTurnErrorClass(errorClass, "error"), label);
+ }
+});
+
+test("friendlyTurnErrorClass: falls back to outcome when error_class is absent", () => {
+ // Payload from a harness build that predates the `error_class` field.
+ assert.equal(friendlyTurnErrorClass(null, "idle_timeout"), "Idle timeout");
+ assert.equal(friendlyTurnErrorClass(null, "hard_timeout"), "Hard timeout");
+ assert.equal(friendlyTurnErrorClass(null, "panic"), "Agent crashed");
+});
+
+test("friendlyTurnErrorClass: title-cases an unmapped token instead of leaking it", () => {
+ // A class added to the Rust enum before this map catches up.
+ assert.equal(
+ friendlyTurnErrorClass("some_new_class", "error"),
+ "Some New Class",
+ );
+});
+
+test("shouldShowTurnFailure: hides an 'exited' failure behind a process error", () => {
+ // One crash must not read as two independent errors.
+ assert.equal(shouldShowTurnFailure({ errorClass: "exited" }, true), false);
+});
+
+test("shouldShowTurnFailure: shows an 'exited' failure with no process error", () => {
+ assert.equal(shouldShowTurnFailure({ errorClass: "exited" }, false), true);
+});
+
+test("shouldShowTurnFailure: shows non-'exited' classes even with a process error", () => {
+ // A timeout or protocol fault is not explained by the process-exit line.
+ for (const errorClass of ["timeout", "protocol", "agent_error", null]) {
+ assert.equal(shouldShowTurnFailure({ errorClass }, true), true);
+ }
+});
diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts
index 60c77bb04c..f6ea629958 100644
--- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts
+++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts
@@ -1,3 +1,5 @@
+import { titleCase } from "@/features/agents/ui/agentSessionUtils";
+
/**
* Promote certain machine-readable `lastError` strings to user-facing copy.
*
@@ -126,3 +128,56 @@ export function friendlyTurnErrorCopy(raw: string, code: unknown): string {
const safe = Number.isFinite(numeric) ? (numeric as number) : null;
return friendlyAgentLastError(raw, safe)?.copy ?? raw;
}
+
+/**
+ * User-facing labels for the closed set of `error_class` discriminants from
+ * `classify_turn_failure` (`crates/buzz-acp/src/lib.rs`), plus the `outcome`
+ * strings used as a fallback when a pre-`error_class` harness omits the field.
+ * Living here, beside the rest of the user-facing error copy, keeps the raw
+ * machine token from reaching the UI.
+ */
+const TURN_ERROR_CLASS_LABELS: Record = {
+ // error_class discriminants
+ agent_error: "Agent error",
+ cancelled: "Cancelled",
+ error: "Error",
+ exited: "Agent exited",
+ panic: "Agent crashed",
+ protocol: "Protocol error",
+ timeout: "Timed out",
+ transport: "Connection lost",
+ // outcome-only fallbacks
+ hard_timeout: "Hard timeout",
+ idle_timeout: "Idle timeout",
+};
+
+/**
+ * Display label for a turn failure's class. Falls back to the coarse `outcome`
+ * when the harness predates `error_class`, and to title-casing for any token
+ * added to the Rust enum before this map catches up — so a new class degrades
+ * to "Some New Class" rather than leaking `some_new_class`.
+ */
+export function friendlyTurnErrorClass(
+ errorClass: string | null,
+ outcome: string,
+): string {
+ const raw = errorClass ?? outcome;
+ return TURN_ERROR_CLASS_LABELS[raw] ?? titleCase(raw);
+}
+
+/**
+ * Whether the turn-failure line should render alongside a process-level error.
+ *
+ * A nonzero process exit is already surfaced by `friendlyAgentLastError`, and
+ * the turn killed by that same exit reports `error_class: "exited"` — rendering
+ * both makes one crash read as two independent errors. The turn line yields,
+ * since the process line is the more actionable of the two. Every other class
+ * describes a failure the process error does NOT explain (a timeout, a protocol
+ * fault), so it still shows.
+ */
+export function shouldShowTurnFailure(
+ failure: { errorClass: string | null },
+ hasProcessError: boolean,
+): boolean {
+ return !(hasProcessError && failure.errorClass === "exited");
+}
diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
index 06f327a8a2..e490f9d0b0 100644
--- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
@@ -13,7 +13,10 @@ import { Badge } from "@/shared/ui/badge";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
-import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
+import {
+ formatAgo,
+ formatElapsed,
+} from "@/features/agents/ui/agentSessionUtils";
import { useLastTurnFailure } from "@/features/agents/activeAgentTurnsStore";
import type { TurnFailure } from "@/features/agents/activeAgentTurnsStore";
import { useNow } from "@/shared/lib/useNow";
@@ -27,7 +30,9 @@ import { Button } from "@/shared/ui/button";
import { AgentConfigPanel } from "./AgentConfigPanel";
import {
friendlyAgentLastError,
+ friendlyTurnErrorClass,
friendlyTurnErrorCopy,
+ shouldShowTurnFailure,
} from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { PubKey } from "@/shared/ui/PubKey";
@@ -396,20 +401,35 @@ function StatusBlock({
{friendlyError.copy}
) : null}
- {lastTurnFailure ? (
-
- Last turn error (
- {lastTurnFailure.errorClass ?? lastTurnFailure.outcome}):{" "}
- {friendlyTurnErrorCopy(lastTurnFailure.error, lastTurnFailure.code)}
-
+ {lastTurnFailure &&
+ shouldShowTurnFailure(lastTurnFailure, friendlyError != null) ? (
+
) : null}
);
}
+/** The age label's finest unit is a minute, so a minute tick is the coarsest
+ * interval that keeps it truthful. Mounted at the leaf so only rows that
+ * actually show a failure own an interval — healthy rows never tick. */
+const FAILURE_AGE_TICK_MS = 60_000;
+
+function TurnFailureLine({ failure }: { failure: TurnFailure }) {
+ const now = useNow(FAILURE_AGE_TICK_MS);
+
+ return (
+
+ Last turn error (
+ {friendlyTurnErrorClass(failure.errorClass, failure.outcome)},{" "}
+ {formatAgo(now - failure.failedAt)}):{" "}
+ {friendlyTurnErrorCopy(failure.error, failure.code)}
+
+ );
+}
+
function RuntimeBlock({
agent,
runtimeSource,
diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts
index 346454dee2..92b8e3adf6 100644
--- a/desktop/src/features/agents/ui/agentSessionUtils.ts
+++ b/desktop/src/features/agents/ui/agentSessionUtils.ts
@@ -306,3 +306,21 @@ export function formatElapsed(ms: number): string {
const hours = Math.floor(totalMinutes / 60);
return `${hours}h ${minutes}m ${seconds}s`;
}
+
+/**
+ * Format how long ago a past moment was (epoch-ms delta), for a coarse label
+ * that doesn't tick every second.
+ * Tiers: `<60s → "just now"` · `<60m → "Nm ago"` · `<24h → "Nh ago"` · `≥24h → "Nd ago"`.
+ * Non-positive deltas clamp to "just now": a small negative is reachable when a
+ * clock-skew correction lands a recorded moment slightly ahead of the desktop
+ * clock, and must never render as "-1m ago".
+ */
+export function formatAgo(ms: number): string {
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+ if (totalSeconds < 60) return "just now";
+ const totalMinutes = Math.floor(totalSeconds / 60);
+ if (totalMinutes < 60) return `${totalMinutes}m ago`;
+ const totalHours = Math.floor(totalMinutes / 60);
+ if (totalHours < 24) return `${totalHours}h ago`;
+ return `${Math.floor(totalHours / 24)}d ago`;
+}