From 6a2e7024d363e2ef60c476b5f0d9f642dd6b75d4 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 14 Aug 2026 13:01:27 -0400 Subject: [PATCH 01/20] fix(buzz-acp): alert when an agent slot's circuit breaker opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot's circuit breaker (3 crashes/60s, or a failed half-open probe re-opening the circuit) previously only logged via tracing::error! — invisible unless someone is watching server logs, so a permanently dark agent slot could go unnoticed indefinitely. Route the alert through the same owner-encrypted observer-frame pipeline already used for agent_panic/turn_error, which the desktop app already surfaces via friendlyAgentLastError — no new transport, no new client-side wiring required. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 2a41ea73420..fe38d2a5e05 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3956,6 +3956,40 @@ fn spawn_failure_notice( } } +/// Emit a user-visible alert when an agent slot's circuit breaker opens. +/// +/// Previously this state transition (3 crashes/60s, or a failed half-open +/// probe re-opening the circuit) only produced a `tracing::error!` log — +/// invisible unless someone is watching server logs, so a permanently dark +/// slot could go unnoticed indefinitely. This routes through the same +/// owner-encrypted observer-frame pipeline already used for `agent_panic` +/// and `turn_error`, which the desktop app already renders via +/// `friendlyAgentLastError` — no new transport, no new client-side wiring. +fn emit_circuit_open_alert( + observer: Option<&observer::ObserverHandle>, + agent_index: usize, + channel_id: Option, + trigger: &str, +) { + let Some(observer) = observer else { + return; + }; + let cooldown_secs = CIRCUIT_BREAKER_COOLDOWN.as_secs(); + observer.emit( + "circuit_open", + Some(agent_index), + &observer::context_for(channel_id, None, None), + serde_json::json!({ + "trigger": trigger, + "cooldown_secs": cooldown_secs, + "error": format!( + "Agent slot {agent_index} {trigger} repeatedly and its circuit breaker is now open \ + — it will not respond until the {cooldown_secs}s cooldown elapses and a health probe succeeds." + ), + }), + ); +} + #[allow(clippy::too_many_arguments)] fn handle_prompt_result( pool: &mut AgentPool, @@ -4412,6 +4446,7 @@ fn recover_panicked_agent( let delay = match slot.record_crash() { CrashVerdict::CircuitOpen => { tracing::error!(agent = i, "circuit open after panic — not respawning"); + emit_circuit_open_alert(observer.as_ref(), i, meta.channel_id, "panicked"); return; } CrashVerdict::HalfOpenProbe => { @@ -4626,6 +4661,7 @@ fn spawn_respawn_task( let delay = match slot.record_crash() { CrashVerdict::CircuitOpen => { tracing::error!(agent = index, "circuit open — not respawning"); + emit_circuit_open_alert(observer.as_ref(), index, None, "crashed"); return false; } CrashVerdict::HalfOpenProbe => { From fb46eec400308f18c0025060863be90ef83abb0c Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 14 Aug 2026 13:12:10 -0400 Subject: [PATCH 02/20] fix(buzz-acp): surface per-slot health via observer alerts, not just presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:20001 presence reflects the whole harness process (one Nostr identity), not the health of individual pool slots — a circuit-broken slot can sit dark indefinitely while presence still reports "online" via the periodic heartbeat, since other slots keep the process alive. Extending the public presence protocol with a new status would require coordinated changes across the harness, the relay's Redis-backed presence store, and desktop's presence type/rendering — out of proportion for this fix. Instead, close the loop on the existing owner-encrypted observer channel: track per-slot alerted_open state on SlotCircuit, and emit a "circuit_recovered" alert (mirroring the "circuit_open" alert from the previous commit) when a previously circuit-broken slot successfully respawns. Owners now see both when a slot went dark and when it came back. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index fe38d2a5e05..4566989dc2f 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1413,6 +1413,10 @@ struct SlotCircuit { /// Prevents duplicate spawns from maintenance ticks that fire before the /// previous spawn_and_init completes. respawn_in_flight: bool, + /// True once a circuit-open alert has been emitted for this slot and + /// not yet followed by a successful respawn. Lets the respawn-complete + /// path emit a matching "recovered" alert exactly once. + alerted_open: bool, } /// Result of [`SlotCircuit::record_crash`]. @@ -1794,6 +1798,7 @@ mod idle_pool_sleep_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight, + alerted_open: false, } } @@ -2373,6 +2378,7 @@ async fn tokio_main() -> Result<()> { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }) .collect(); @@ -2484,6 +2490,10 @@ async fn tokio_main() -> Result<()> { }; pool.return_agent(agent); tracing::info!(agent = rr.index, "respawn complete"); + if crash_history[rr.index].alerted_open { + crash_history[rr.index].alerted_open = false; + emit_circuit_recovered_alert(observer.as_ref(), rr.index); + } respawn_collected = true; } Err(e) => { @@ -3990,6 +4000,27 @@ fn emit_circuit_open_alert( ); } +/// Emit a user-visible alert when a slot that previously tripped its circuit +/// breaker ([`emit_circuit_open_alert`]) has successfully respawned. +/// +/// Closes the loop for an owner watching the observer stream: they see both +/// when a slot went dark and when it came back, rather than only the former. +fn emit_circuit_recovered_alert(observer: Option<&observer::ObserverHandle>, agent_index: usize) { + let Some(observer) = observer else { + return; + }; + observer.emit( + "circuit_recovered", + Some(agent_index), + &observer::context_for(None, None, None), + serde_json::json!({ + "error": format!( + "Agent slot {agent_index} recovered — its circuit breaker probe succeeded and it is responding again." + ), + }), + ); +} + #[allow(clippy::too_many_arguments)] fn handle_prompt_result( pool: &mut AgentPool, @@ -4447,6 +4478,7 @@ fn recover_panicked_agent( CrashVerdict::CircuitOpen => { tracing::error!(agent = i, "circuit open after panic — not respawning"); emit_circuit_open_alert(observer.as_ref(), i, meta.channel_id, "panicked"); + slot.alerted_open = true; return; } CrashVerdict::HalfOpenProbe => { @@ -4662,6 +4694,7 @@ fn spawn_respawn_task( CrashVerdict::CircuitOpen => { tracing::error!(agent = index, "circuit open — not respawning"); emit_circuit_open_alert(observer.as_ref(), index, None, "crashed"); + slot.alerted_open = true; return false; } CrashVerdict::HalfOpenProbe => { @@ -7494,6 +7527,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7566,6 +7600,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7680,6 +7715,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7741,6 +7777,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7822,6 +7859,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7910,6 +7948,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8002,6 +8041,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8108,6 +8148,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8185,6 +8226,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8279,6 +8321,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8407,6 +8450,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8537,6 +8581,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8726,6 +8771,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8812,6 +8858,7 @@ mod error_outcome_emission_tests { crash_times: Vec::new(), open_until: None, respawn_in_flight: false, + alerted_open: false, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); From f5fa06e58472da434f45fa895835b50681f0bef8 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 14 Aug 2026 13:31:30 -0400 Subject: [PATCH 03/20] fix(desktop): stop silently dropping workflows in unjoined open channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkflowsRouteScreen pre-filtered to isMember channels before handing the list to WorkflowsView, and WorkflowsView filtered again — so a workflow living in an open channel the owner hasn't joined (e.g. an agent-only channel) was fully stored and independently retrievable via get_channel_workflows(channelId), but never appeared in the aggregate Workflows page, with no error or indicator. Open channels are readable without membership (fetch_channels already merges an "open directory" alongside membership, and the relay's query fan-out silently omits anything the caller can't read rather than gating the whole batched request) — see channelDescription.ts's existing "Read-only until you join this open channel" copy for the established precedent. Broaden the channel set used for the aggregate workflow list to member + open channels; keep memberChannels (create workflow requires membership, enforced server-side) unchanged for the create/edit dialog's channel picker. Signed-off-by: Michael Feth --- desktop/src/app/routes/WorkflowsRouteScreen.tsx | 11 +++++++++-- .../src/features/workflows/ui/WorkflowsView.tsx | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx index 0a0a4dfb367..cc49089272b 100644 --- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx +++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx @@ -12,11 +12,18 @@ export function WorkflowsRouteScreen({ const { closeWorkflowDetail, goWorkflow } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; - const memberChannels = channels.filter((channel) => channel.isMember); + // Member channels plus open channels the owner hasn't joined yet — open + // channels are readable without membership (see channelDescription.ts), + // so a workflow living there is fully visible and must not be silently + // dropped just because isMember is false. WorkflowsView narrows further + // for membership-gated actions (e.g. the create-workflow channel picker). + const visibleChannels = channels.filter( + (channel) => channel.isMember || channel.visibility === "open", + ); return ( { void goWorkflow(workflowId); diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index e0e5b7e9484..d21a7c448db 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -94,17 +94,24 @@ export function WorkflowsView({ const [deleteTarget, setDeleteTarget] = React.useState(null); const queryClient = useQueryClient(); + // `channels` (from WorkflowsRouteScreen) already includes open channels + // the owner hasn't joined — those are readable without membership, so a + // workflow living there must still surface in this aggregate view rather + // than being silently dropped (see WorkflowsRouteScreen.tsx). Membership + // is still required to create/edit a workflow, so the create-dialog + // channel picker stays scoped to memberChannels below. const memberChannels = channels.filter((c) => c.isMember); - const channelIds = memberChannels.map((c) => c.id).sort(); + const channelIds = channels.map((c) => c.id).sort(); const channelIdKey = channelIds.join(","); const allWorkflowsQuery = useQuery({ queryKey: allWorkflowsQueryKey(channelIdKey), queryFn: async () => { - // Single batched relay query for all member channels, then group by the - // channel_id each workflow carries — replaces the per-channel fanout. + // Single batched relay query for every visible channel (member + open, + // non-member), then group by the channel_id each workflow carries — + // replaces the per-channel fanout. const channelNameById = new Map( - memberChannels.map((channel) => [channel.id, channel.name]), + channels.map((channel) => [channel.id, channel.name]), ); const workflows = await getChannelsWorkflows(channelIds); const results: WorkflowWithChannel[] = []; @@ -118,7 +125,7 @@ export function WorkflowsView({ } return results; }, - enabled: memberChannels.length > 0, + enabled: channelIds.length > 0, ...workflowListFocusRefetchPolicy, }); From 8b3ab52943289e8be0f40a7357104ca2d6524148 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 14 Aug 2026 16:35:12 -0400 Subject: [PATCH 04/20] test(desktop): cover workflow visibility for open vs private non-member channels Adds e2e coverage for the WorkflowsRouteScreen/WorkflowsView fix (previous commit) that surfaces workflows from open channels the owner hasn't joined. Seeds a workflow directly via the mock command bridge in "sales" (open, non-member) and asserts it appears in the aggregate view, and in a new "exec-private" fixture (private, non-member) and asserts it stays hidden. Signed-off-by: Michael Feth --- desktop/src/testing/e2eBridge.ts | 30 ++++++++++ desktop/tests/e2e/workflows.spec.ts | 93 +++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4215d183ac2..d1277b407a0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2892,6 +2892,36 @@ const mockChannels: MockChannel[] = [ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 540), ], }), + // Private channel the mock identity is NOT a member of (unlike every other + // private fixture above, which all include MOCK_IDENTITY_PUBKEY). Exercises + // the workflow-visibility boundary: an open non-member channel's workflows + // must surface in the aggregate Workflows view, but a private non-member + // channel's must stay hidden. See workflows.spec.ts. + createMockChannel({ + id: "5ec4e700-0000-4000-8000-000000000099", + name: "exec-private", + channel_type: "stream", + visibility: "private", + description: "Private channel the mock identity has not joined", + topic: null, + purpose: null, + last_message_at: null, + archived_at: null, + created_by: ALICE_PUBKEY, + topic_set_by: null, + topic_set_at: null, + purpose_set_by: null, + purpose_set_at: null, + topic_required: false, + max_members: null, + nip29_group_id: null, + created_minutes_ago: 500, + updated_minutes_ago: 500, + members: [ + createMockMember(ALICE_PUBKEY, "owner", 500), + createMockMember(BOB_PUBKEY, "member", 480), + ], + }), createMockChannel({ id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8", name: "alice-tyler", diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 0681b22f493..0e3d3d3a9d8 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -1,11 +1,56 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +// Fixture channel ids from src/testing/e2eBridge.ts. "sales" is an OPEN +// channel the mock identity has not joined; "exec-private" is a PRIVATE +// channel the mock identity has not joined. Together they pin the +// visibility boundary WorkflowsRouteScreen/WorkflowsView enforce: open +// non-member channels surface in the aggregate view, private non-member +// channels stay hidden. +const SALES_OPEN_NON_MEMBER_CHANNEL_ID = "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f"; +const EXEC_PRIVATE_NON_MEMBER_CHANNEL_ID = + "5ec4e700-0000-4000-8000-000000000099"; + +async function seedWorkflowInChannel( + page: import("@playwright/test").Page, + channelId: string, + name: string, +) { + const yamlDefinition = [ + `name: ${name}`, + "enabled: true", + "trigger:", + " on: manual", + "steps:", + " - name: step_1", + ].join("\n"); + + await page.evaluate( + async ({ channelId: id, yamlDefinition: yaml }) => { + const testWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + }; + if (!testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__) { + throw new Error("__BUZZ_E2E_INVOKE_MOCK_COMMAND__ is not installed"); + } + await testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("create_workflow", { + channelId: id, + yamlDefinition: yaml, + }); + }, + { channelId, yamlDefinition }, + ); +} + async function navigateToWorkflows(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-workflows-view").click(); @@ -428,3 +473,51 @@ test("triggers a workflow from the detail panel", async ({ page }) => { page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"), ).toContainText("step_1"); }); + +test("surfaces a workflow from an open channel the owner has not joined", async ({ + page, +}) => { + const workflowName = `sales_open_nonmember_${Date.now()}`; + await page.goto("/"); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + await seedWorkflowInChannel( + page, + SALES_OPEN_NON_MEMBER_CHANNEL_ID, + workflowName, + ); + + await page.getByTestId("open-workflows-view").click(); + await expect(page).toHaveURL(/#\/workflows$/); + await expect(page.getByTestId("workflows-view")).toBeVisible(); + + const card = page + .locator('[data-testid^="workflow-card-"]') + .filter({ hasText: workflowName }); + await expect(card).toBeVisible(); + await waitForAnimations(page); + await card.screenshot({ + path: "test-results/screenshots/workflow-visible-open-nonmember-channel.png", + }); +}); + +test("keeps a workflow from a private channel the owner has not joined out of the aggregate view", async ({ + page, +}) => { + const workflowName = `private_nonmember_${Date.now()}`; + await page.goto("/"); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + await seedWorkflowInChannel( + page, + EXEC_PRIVATE_NON_MEMBER_CHANNEL_ID, + workflowName, + ); + + await page.getByTestId("open-workflows-view").click(); + await expect(page).toHaveURL(/#\/workflows$/); + await expect(page.getByTestId("workflows-view")).toBeVisible(); + + await expect(page.getByText("No workflows yet")).toBeVisible(); + await expect( + page.getByTestId("workflows-view").getByText(workflowName), + ).toHaveCount(0); +}); From b66469dab226aa8b5ed5fe4ad243f303c3f8e522 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 15 Aug 2026 15:36:49 -0400 Subject: [PATCH 05/20] chore: trigger CI run Signed-off-by: Michael Feth From ff41a8e8f3e38b24252bf4d94e945533290bdafb Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 15 Aug 2026 16:14:33 -0400 Subject: [PATCH 06/20] feat(desktop): render circuit-breaker alerts in the agent transcript emit_circuit_open_alert/emit_circuit_recovered_alert route through the existing owner-encrypted observer-frame pipeline used by turn_error and agent_panic, but processTranscriptEvent had no branch for the circuit_open/circuit_recovered kinds, so the alerts landed on the wire and were silently dropped by the desktop UI. Add a matching render branch and correct the doc comment that incorrectly claimed desktop already handled this. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 6 ++- .../agents/ui/agentSessionTranscript.test.mjs | 46 +++++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 20 ++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4566989dc2f..ea2000fb6ea 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3973,8 +3973,10 @@ fn spawn_failure_notice( /// invisible unless someone is watching server logs, so a permanently dark /// slot could go unnoticed indefinitely. This routes through the same /// owner-encrypted observer-frame pipeline already used for `agent_panic` -/// and `turn_error`, which the desktop app already renders via -/// `friendlyAgentLastError` — no new transport, no new client-side wiring. +/// and `turn_error`; the desktop app renders it via a dedicated +/// `circuit_open`/`circuit_recovered` branch in `processTranscriptEvent` +/// (desktop/src/features/agents/ui/agentSessionTranscript.ts) — no new +/// transport, but client-side rendering had to be added alongside this. fn emit_circuit_open_alert( observer: Option<&observer::ObserverHandle>, agent_index: usize, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index b4a139eb0ee..181d2173f2b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -720,6 +720,52 @@ test("buildTranscript separates repeated lifecycle text", () => { assert.equal(item.text, "recovered: first\nrecovered: second"); }); +test("buildTranscript renders circuit_open and circuit_recovered alerts", () => { + const events = [ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "circuit_open", + agentIndex: 2, + channelId: null, + sessionId: null, + turnId: null, + payload: { + trigger: "crashed", + cooldown_secs: 300, + error: + "Agent slot 2 crashed repeatedly and its circuit breaker is now open " + + "— it will not respond until the 300s cooldown elapses and a health probe succeeds.", + }, + }, + { + seq: 2, + timestamp: "2026-06-30T09:05:00.000Z", + kind: "circuit_recovered", + agentIndex: 2, + channelId: null, + sessionId: null, + turnId: null, + payload: { + error: + "Agent slot 2 recovered — its circuit breaker probe succeeded and it is responding again.", + }, + }, + ]; + + const transcript = buildTranscript(events); + assert.equal(transcript.length, 2); + + const [openItem, recoveredItem] = transcript; + assert.equal(openItem.type, "lifecycle"); + assert.equal(openItem.title, "Agent suspended (repeated crashes)"); + assert.match(openItem.text, /circuit breaker is now open/); + + assert.equal(recoveredItem.type, "lifecycle"); + assert.equal(recoveredItem.title, "Agent recovered"); + assert.match(recoveredItem.text, /responding again/); +}); + // --- permission outcome (Fix #3) --- function makePermissionRequest(seq, requestId, turnId = "turn-1") { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc30..baf43b5584d 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -786,6 +786,26 @@ export function processTranscriptEvent( ctx, event.kind, ); + } else if ( + event.kind === "circuit_open" || + event.kind === "circuit_recovered" + ) { + const payload = asRecord(event.payload); + const message = asString(payload.error) ?? "Unknown circuit breaker state"; + const title = + event.kind === "circuit_open" + ? "Agent suspended (repeated crashes)" + : "Agent recovered"; + upsertTextItem( + d, + `${event.kind}:${ch}:${event.agentIndex ?? event.seq}`, + "lifecycle", + title, + message, + event.timestamp, + ctx, + event.kind, + ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); const method = asString(payload.method); From 43f0873506db6093ba8f2f355694f8631ba41d74 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 15 Aug 2026 16:23:34 -0400 Subject: [PATCH 07/20] test(desktop): screenshot the circuit-breaker alert rendering Covers both circuit_open (panicked-in-channel, carries the real channel_id) and circuit_recovered against the new transcript render branch. circuit_recovered is always emitted with channel_id=None in production (a respawned agent slot isn't tied to one channel), and every desktop transcript surface is channel-scoped via scopeByChannel -- so that specific alert has no visible surface in the app today. This test proves the render branch itself is correct; it does not address that separate, pre-existing gap. Signed-off-by: Michael Feth --- .../e2e/observer-feed-screenshots.spec.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 44ff609c9ee..fedd676d1aa 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -814,4 +814,66 @@ test.describe("observer feed screenshots", () => { path: `${SHOTS}/11-first-turn-ordering.png`, }); }); + + test("12 — circuit_open and circuit_recovered lifecycle alerts", async ({ + page, + }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY); + + // Circuit breaker opened after a panic inside this channel (the real + // panicked-in-channel call site passes the triggering channel's id, see + // emit_circuit_open_alert's caller in buzz-acp/src/lib.rs), then + // recovered. Both events are seeded with this channel's id to prove the + // renderer works when a channel context is present. NOTE: the real + // circuit_recovered alert is always emitted with channel_id=None (a + // respawned agent slot isn't tied to any single channel) and every + // desktop surface that shows agent transcripts is channel-scoped + // (scopeByChannel drops non-matching events) — that alert has no visible + // surface in the app today. That is a pre-existing, separate gap this + // change does not address; this test only proves the render branch + // itself is correct. + await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [ + { + seq: 1, + timestamp: NOW, + kind: "circuit_open", + agentIndex: 0, + channelId: CHANNEL_ID, + sessionId: null, + turnId: null, + payload: { + trigger: "panicked", + cooldown_secs: 300, + error: + "Agent slot 0 panicked repeatedly and its circuit breaker is now open " + + "— it will not respond until the 300s cooldown elapses and a health probe succeeds.", + }, + }, + { + seq: 2, + timestamp: NOW, + kind: "circuit_recovered", + agentIndex: 0, + channelId: CHANNEL_ID, + sessionId: null, + turnId: null, + payload: { + error: + "Agent slot 0 recovered — its circuit breaker probe succeeded and it is responding again.", + }, + }, + ]); + + await expect( + feedPanel.getByText("Agent suspended (repeated crashes)"), + ).toBeVisible({ timeout: 5_000 }); + await expect(feedPanel.getByText("Agent recovered")).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(feedPanel); + await feedPanel.screenshot({ + path: `${SHOTS}/12-circuit-breaker-alerts.png`, + }); + }); }); From fbf1ce9d2bf2856d115630d34ed99d04d389ce43 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 10:07:13 -0400 Subject: [PATCH 08/20] fix(acp): thread real channel context into circuit-open alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn_respawn_task (the generic crash/timeout/transport-error respawn path — the common case, not just the panic path) always emitted circuit_open with channel_id=None. Since the desktop transcript is channel-scoped, most circuit-open alerts had no surface to render on, even after the earlier fix that added the circuit_open/circuit_recovered render branch. Extract the triggering prompt's channel (if any) from PromptSource in handle_prompt_result and thread it through to spawn_respawn_task so the alert carries real channel context, matching the panic path which already had this via meta.channel_id. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ea2000fb6ea..c687b8ec6e4 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4039,6 +4039,14 @@ fn handle_prompt_result( ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; + // The channel that triggered this prompt, if any — threaded through to + // any crash-recovery alert below so it has somewhere to render instead + // of silently emitting with no channel context (a heartbeat prompt has + // no channel, so None is correct there). + let source_channel_id = match result.source { + PromptSource::Channel(channel_id) => Some(channel_id), + PromptSource::Heartbeat => None, + }; let successful_steer_deliveries = pool .task_map() .values() @@ -4284,6 +4292,7 @@ fn handle_prompt_result( respawn_tx, respawn_tasks, observer.clone(), + source_channel_id, ) { // Circuit open — slot stays empty until maintenance refill. if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { @@ -4324,6 +4333,7 @@ fn handle_prompt_result( respawn_tx, respawn_tasks, observer.clone(), + source_channel_id, ) { // Circuit open — slot stays empty until maintenance refill. if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { @@ -4389,6 +4399,7 @@ fn handle_prompt_result( respawn_tx, respawn_tasks, observer, + source_channel_id, ) && pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { @@ -4680,6 +4691,13 @@ fn default_heartbeat_prompt() -> String { /// the actual shutdown + backoff + spawn_and_init work into a background task. /// The result comes back through `respawn_tx` so the main loop stays responsive. /// +/// `channel_id` is the channel of the prompt that triggered this crash, if +/// any (`None` for a heartbeat-triggered crash) — threaded through so a +/// circuit-open alert has somewhere to render; previously this was always +/// `None` here, so most circuit-open alerts (the generic crash/timeout path, +/// as opposed to the panic path which already had `meta.channel_id`) had no +/// channel-scoped surface in the desktop app. +/// /// Returns `true` if a respawn task was spawned, `false` if the circuit is open. fn spawn_respawn_task( old_agent: OwnedAgent, @@ -4688,6 +4706,7 @@ fn spawn_respawn_task( respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, + channel_id: Option, ) -> bool { let index = old_agent.index; @@ -4695,7 +4714,7 @@ fn spawn_respawn_task( let delay = match slot.record_crash() { CrashVerdict::CircuitOpen => { tracing::error!(agent = index, "circuit open — not respawning"); - emit_circuit_open_alert(observer.as_ref(), index, None, "crashed"); + emit_circuit_open_alert(observer.as_ref(), index, channel_id, "crashed"); slot.alerted_open = true; return false; } From 83166d91875984422290209c640f3342c4130c09 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 10:23:40 -0400 Subject: [PATCH 09/20] feat(agent): default the reply guard on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUZZ_AGENT_REQUIRE_REPLY defaulted to off, relying on Desktop's mesh launcher to force it on per-process (insert_default_if_unset). Any buzz-agent run outside that path — manual/dev runs, other harnesses — silently missed the guard against a turn that does real work and never posts it anywhere. Flip the binary's own default to on, matching what Desktop already forces for its primary managed use case. An explicit BUZZ_AGENT_REQUIRE_REPLY=0 (agent, persona, or global env) still opts out — Desktop's insert_default_if_unset never overrides an explicit value either, so this doesn't change mesh-agent opt-out behavior. Left the two require_reply: false in config.rs's for_discovery() and llm.rs's test cfg() helper alone — both already run with the entire _Stop guard budget disabled (stop_max_rejections: 0), so require_reply is inert there regardless of value. Signed-off-by: Michael Feth --- crates/buzz-agent/README.md | 18 +++++++++--------- crates/buzz-agent/src/config.rs | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 0bc03db7813..9c81800ae6d 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -164,24 +164,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | -| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `1` | `0` disables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. | ## Reply Guard -Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop -sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is -about to end without any recognized attempt to post to Buzz gets a reminder that -its assistant text is invisible to humans, and is rerolled. +On by default. A turn that is about to end without any recognized attempt to +post to Buzz gets a reminder that its assistant text is invisible to humans, +and is rerolled. This exists because a Buzz agent's reasoning and tool output are not shown to anyone. A turn that does real work and never posts is a silent failure — the requester waits on a result that was produced and thrown away. -Mesh agents get it by default because they run on small local models, which are -the ones most likely to do the work and then end the turn without publishing it. -Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a -mesh agent back out; the default never overrides an explicit value. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts +out. Buzz shared-compute (mesh) agents also always get it via Desktop's +`insert_default_if_unset`, which never overrides an explicit value — so an +agent, persona, or global `BUZZ_AGENT_REQUIRE_REPLY=0` still opts a mesh agent +out even though the binary's own default now agrees with it. **Advisory, never a trap.** At most two reminders, then the turn ends whether or not anything was published. The guard catches accidental omission; it does not diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 67d7c593b56..1a8bf3e8f4b 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -484,8 +484,8 @@ pub struct Config { /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, /// Remind the model to publish when a turn is about to end without any - /// recognized attempt to post to Buzz. Default off; opt in per agent with - /// `BUZZ_AGENT_REQUIRE_REPLY=1`. + /// recognized attempt to post to Buzz. Default on; opt out per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=0`. /// /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), /// then the turn ends regardless. Bounded by the same @@ -624,7 +624,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, - require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 1u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, From 658e61f16d6363bb2dfb671c9e0f88ab1c81f692 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 15:34:18 -0400 Subject: [PATCH 10/20] fix(acp): thread channel context through circuit_recovered alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit circuit_open alerts already carry the real channel_id (previous commit), but circuit_recovered was always emitted with channel_id: None. Since desktop transcript rendering is channel-scoped, the "Agent suspended" message from circuit_open had no matching resolution ever visible in any channel, even after the agent recovered — a permanently dangling crash indicator. Adds SlotCircuit::alerted_channel_id to carry the triggering channel forward from the circuit_open call site to the respawn-complete path, so circuit_recovered renders in the same channel it resolves. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 40 +++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index c687b8ec6e4..fe3cd6c36c2 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1417,6 +1417,10 @@ struct SlotCircuit { /// not yet followed by a successful respawn. Lets the respawn-complete /// path emit a matching "recovered" alert exactly once. alerted_open: bool, + /// Channel of the prompt that triggered the crash behind `alerted_open`, + /// if any. Carried forward so the "recovered" alert renders in the same + /// channel as the "suspended" alert it resolves, instead of nowhere. + alerted_channel_id: Option, } /// Result of [`SlotCircuit::record_crash`]. @@ -1799,6 +1803,7 @@ mod idle_pool_sleep_tests { open_until: None, respawn_in_flight, alerted_open: false, + alerted_channel_id: None, } } @@ -2379,6 +2384,7 @@ async fn tokio_main() -> Result<()> { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }) .collect(); @@ -2492,7 +2498,8 @@ async fn tokio_main() -> Result<()> { tracing::info!(agent = rr.index, "respawn complete"); if crash_history[rr.index].alerted_open { crash_history[rr.index].alerted_open = false; - emit_circuit_recovered_alert(observer.as_ref(), rr.index); + let channel_id = crash_history[rr.index].alerted_channel_id.take(); + emit_circuit_recovered_alert(observer.as_ref(), rr.index, channel_id); } respawn_collected = true; } @@ -4007,14 +4014,25 @@ fn emit_circuit_open_alert( /// /// Closes the loop for an owner watching the observer stream: they see both /// when a slot went dark and when it came back, rather than only the former. -fn emit_circuit_recovered_alert(observer: Option<&observer::ObserverHandle>, agent_index: usize) { +/// +/// `channel_id` is the channel of the prompt that triggered the crash behind +/// this recovery, if any — carried from `SlotCircuit::alerted_channel_id` so +/// this alert renders in the same channel as the "suspended" alert it +/// resolves. Previously this was always emitted with no channel, so it had no +/// channel-scoped surface in the desktop app and the "suspended" message +/// never appeared to resolve. +fn emit_circuit_recovered_alert( + observer: Option<&observer::ObserverHandle>, + agent_index: usize, + channel_id: Option, +) { let Some(observer) = observer else { return; }; observer.emit( "circuit_recovered", Some(agent_index), - &observer::context_for(None, None, None), + &observer::context_for(channel_id, None, None), serde_json::json!({ "error": format!( "Agent slot {agent_index} recovered — its circuit breaker probe succeeded and it is responding again." @@ -4492,6 +4510,7 @@ fn recover_panicked_agent( tracing::error!(agent = i, "circuit open after panic — not respawning"); emit_circuit_open_alert(observer.as_ref(), i, meta.channel_id, "panicked"); slot.alerted_open = true; + slot.alerted_channel_id = meta.channel_id; return; } CrashVerdict::HalfOpenProbe => { @@ -4716,6 +4735,7 @@ fn spawn_respawn_task( tracing::error!(agent = index, "circuit open — not respawning"); emit_circuit_open_alert(observer.as_ref(), index, channel_id, "crashed"); slot.alerted_open = true; + slot.alerted_channel_id = channel_id; return false; } CrashVerdict::HalfOpenProbe => { @@ -7549,6 +7569,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7622,6 +7643,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7737,6 +7759,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7799,6 +7822,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7881,6 +7905,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -7970,6 +7995,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8063,6 +8089,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8170,6 +8197,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8248,6 +8276,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8343,6 +8372,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8472,6 +8502,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8603,6 +8634,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8793,6 +8825,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); @@ -8880,6 +8913,7 @@ mod error_outcome_emission_tests { open_until: None, respawn_in_flight: false, alerted_open: false, + alerted_channel_id: None, }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); From 10e40d9d6b90658bf16b5e66833a0b58945a8460 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 18:14:00 -0400 Subject: [PATCH 11/20] feat(desktop): persistent per-agent circuit-breaker health badge Previously, when an agent's circuit breaker opened, the only visible sign was a transcript message in whatever channel happened to trigger the crash. Looking anywhere else in the app, or at a different channel, gave no indication the agent was suspended. Add a derived, channel-independent circuit-breaker status (getAgentCircuitStatus/useAgentCircuitStatus) built from the same circuit_open/circuit_recovered observer events, tracked per agent slot so a recovery on one slot can't mask another slot still being open. Render it as a persistent badge on ManagedAgentRow, clickable through to the channel that triggered it when known. Extracted compareObserverEvents/isObserverEventAfter into lib/observerEventOrdering.ts and the new circuit-status logic into agentCircuitStatus.ts to keep observerRelayStore.ts under the desktop file-size ratchet. Signed-off-by: Michael Feth --- .../src/features/agents/agentCircuitStatus.ts | 121 +++++++++ .../agents/lib/observerEventOrdering.ts | 45 ++++ .../observerRelayStore.circuitStatus.test.mjs | 231 ++++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 87 ++++--- .../features/agents/ui/ManagedAgentRow.tsx | 55 ++++- .../e2e/observer-feed-screenshots.spec.ts | 15 +- 6 files changed, 504 insertions(+), 50 deletions(-) create mode 100644 desktop/src/features/agents/agentCircuitStatus.ts create mode 100644 desktop/src/features/agents/lib/observerEventOrdering.ts create mode 100644 desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs diff --git a/desktop/src/features/agents/agentCircuitStatus.ts b/desktop/src/features/agents/agentCircuitStatus.ts new file mode 100644 index 00000000000..6a23f9df3d3 --- /dev/null +++ b/desktop/src/features/agents/agentCircuitStatus.ts @@ -0,0 +1,121 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { ObserverEvent } from "./ui/agentSessionTypes"; +import { isObserverEventAfter } from "./lib/observerEventOrdering"; + +export type AgentCircuitStatus = { + isOpen: boolean; + message: string | null; + channelId: string | null; + timestamp: string | null; +}; + +const IDLE_CIRCUIT_STATUS: AgentCircuitStatus = { + isOpen: false, + message: null, + channelId: null, + timestamp: null, +}; + +// Latest known circuit-breaker state per (agent, slot). Slots are independent +// prompt lanes within one managed-agent process (see SlotCircuit in +// buzz-acp/src/lib.rs) — a recovery on slot 1 must not clear a still-open +// circuit on slot 0, so state is tracked per slot and the derived per-agent +// status reports "open" if ANY slot is open. +type CircuitSlotState = { + isOpen: boolean; + message: string; + channelId: string | null; + timestamp: string; + seq: number; +}; +const circuitStateBySlot = new Map(); + +// Reference-stable per-agent snapshots for useSyncExternalStore, invalidated +// only when that agent's circuit state actually changes. +const circuitStatusCache = new Map(); + +function circuitSlotKey( + agentPubkey: string, + agentIndex: number | null | undefined, +): string { + return `${normalizePubkey(agentPubkey)}:${agentIndex ?? 0}`; +} + +/** + * Fold a circuit_open/circuit_recovered event into per-slot circuit state. + * Ignores any other event kind and any event that doesn't sort strictly after + * the slot's currently-stored state, so a late/replayed frame can't regress + * an already-newer status. Returns true if state actually changed. + */ +export function applyCircuitEvent( + agentPubkey: string, + event: ObserverEvent, +): boolean { + if (event.kind !== "circuit_open" && event.kind !== "circuit_recovered") { + return false; + } + const key = circuitSlotKey(agentPubkey, event.agentIndex); + const existing = circuitStateBySlot.get(key); + if (existing && !isObserverEventAfter(event, existing)) { + return false; + } + const payload = event.payload as { error?: unknown } | null; + const message = + typeof payload?.error === "string" + ? payload.error + : event.kind === "circuit_open" + ? "Agent suspended (repeated crashes)" + : "Agent recovered"; + circuitStateBySlot.set(key, { + isOpen: event.kind === "circuit_open", + message, + channelId: event.channelId ?? null, + timestamp: event.timestamp, + seq: event.seq, + }); + circuitStatusCache.delete(normalizePubkey(agentPubkey)); + return true; +} + +/** + * Derived circuit-breaker status for one agent: open if ANY of its slots is + * currently open. Reference-stable while unchanged (useSyncExternalStore-safe + * — see the React.memo/useSyncExternalStore gotcha in this repo's CLAUDE.md). + */ +export function getAgentCircuitStatus( + agentPubkey?: string | null, +): AgentCircuitStatus { + if (!agentPubkey) { + return IDLE_CIRCUIT_STATUS; + } + const key = normalizePubkey(agentPubkey); + const cached = circuitStatusCache.get(key); + if (cached) { + return cached; + } + const prefix = `${key}:`; + let openSlot: CircuitSlotState | null = null; + for (const [slotKey, slot] of circuitStateBySlot) { + if (!slotKey.startsWith(prefix) || !slot.isOpen) continue; + if (!openSlot || isObserverEventAfter(slot, openSlot)) { + openSlot = slot; + } + } + const status: AgentCircuitStatus = openSlot + ? { + isOpen: true, + message: openSlot.message, + channelId: openSlot.channelId, + timestamp: openSlot.timestamp, + } + : IDLE_CIRCUIT_STATUS; + circuitStatusCache.set(key, status); + return status; +} + +/** Clears all per-agent circuit state. Called from resetAgentObserverStore so + * circuit state can't leak across community switches. */ +export function resetCircuitState() { + circuitStateBySlot.clear(); + circuitStatusCache.clear(); +} diff --git a/desktop/src/features/agents/lib/observerEventOrdering.ts b/desktop/src/features/agents/lib/observerEventOrdering.ts new file mode 100644 index 00000000000..476538c34fc --- /dev/null +++ b/desktop/src/features/agents/lib/observerEventOrdering.ts @@ -0,0 +1,45 @@ +import type { ObserverEvent } from "../ui/agentSessionTypes"; + +/** + * Shared two-key ordering for observer events: later timestamp wins; equal + * timestamp falls back to higher seq. Extracted out of observerRelayStore.ts + * so other per-agent derived stores (e.g. agentCircuitStatus.ts) can apply the + * exact same ordering without importing the whole observer store module and + * without drifting from it. + */ +export function compareObserverEvents( + left: ObserverEvent, + right: ObserverEvent, +) { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { + const timeDiff = leftTime - rightTime; + if (timeDiff !== 0) { + return timeDiff; + } + } + + return left.seq - right.seq; +} + +/** + * Returns true if `candidate` sorts strictly after `stored` using the same + * two-key ordering as `compareObserverEvents`: later timestamp wins; equal + * timestamp falls back to higher seq. Extracted so latest-live advancement + * (and any other derived per-agent state) cannot drift from transcript + * ordering. + */ +export function isObserverEventAfter( + candidate: { timestamp: string; seq: number }, + stored: { timestamp: string; seq: number }, +): boolean { + const candidateTime = Date.parse(candidate.timestamp); + const storedTime = Date.parse(stored.timestamp); + if (Number.isFinite(candidateTime) && Number.isFinite(storedTime)) { + if (candidateTime !== storedTime) { + return candidateTime > storedTime; + } + } + return candidate.seq > stored.seq; +} diff --git a/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs new file mode 100644 index 00000000000..e9af63694b4 --- /dev/null +++ b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach, afterEach } from "node:test"; + +import { + getAgentCircuitStatus, + resetAgentObserverStore, + _testProcessLiveObserverEvents, +} from "./observerRelayStore.ts"; + +const AGENT = + "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; + +function makeEvent(overrides) { + return { + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-1", + sessionId: null, + turnId: null, + payload: null, + ...overrides, + }; +} + +describe("getAgentCircuitStatus", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + afterEach(() => { + resetAgentObserverStore(); + }); + + it("reports isOpen false when no circuit events have been seen for the agent", () => { + const status = getAgentCircuitStatus(AGENT); + assert.equal(status.isOpen, false); + assert.equal(status.message, null); + assert.equal(status.channelId, null); + assert.equal(status.timestamp, null); + }); + + it("flips isOpen true on a circuit_open event, surfacing its message/channel/timestamp", () => { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { + error: + "Agent slot 0 panicked repeatedly and its circuit breaker is now open.", + }, + }), + ]); + + const status = getAgentCircuitStatus(AGENT); + assert.equal(status.isOpen, true); + assert.equal( + status.message, + "Agent slot 0 panicked repeatedly and its circuit breaker is now open.", + ); + assert.equal(status.channelId, "chan-uuid-1"); + assert.equal(status.timestamp, "2024-01-01T00:00:00Z"); + }); + + it("flips isOpen back to false when a newer circuit_recovered event arrives on the same slot", () => { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 2, + timestamp: "2024-01-01T00:01:00Z", + kind: "circuit_recovered", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "recovered" }, + }), + ]); + + const status = getAgentCircuitStatus(AGENT); + assert.equal(status.isOpen, false); + assert.equal(status.message, null); + assert.equal(status.channelId, null); + assert.equal(status.timestamp, null); + }); + + it("reports isOpen true overall (any-slot-open) with two independent slots, one open and one recovered", () => { + // Slot 0 stays open. + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-slot-0", + payload: { error: "slot 0 opened" }, + }), + ]); + // Slot 1 opens then recovers. + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 2, + timestamp: "2024-01-01T00:00:01Z", + kind: "circuit_open", + agentIndex: 1, + channelId: "chan-slot-1", + payload: { error: "slot 1 opened" }, + }), + makeEvent({ + seq: 3, + timestamp: "2024-01-01T00:00:02Z", + kind: "circuit_recovered", + agentIndex: 1, + channelId: "chan-slot-1", + payload: { error: "slot 1 recovered" }, + }), + ]); + + const status = getAgentCircuitStatus(AGENT); + assert.equal( + status.isOpen, + true, + "any slot still open must report the agent as open", + ); + assert.equal(status.channelId, "chan-slot-0"); + assert.equal(status.message, "slot 0 opened"); + }); + + it("ignores a stale/out-of-order event arriving after a recovery", () => { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + makeEvent({ + seq: 2, + timestamp: "2024-01-01T00:01:00Z", + kind: "circuit_recovered", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "recovered" }, + }), + ]); + assert.equal(getAgentCircuitStatus(AGENT).isOpen, false); + + // A delayed circuit_open with an OLDER timestamp+seq than the currently + // stored (recovered) state must be ignored — it must not reopen the + // circuit or otherwise change status. + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "stale reopen" }, + }), + ]); + + const status = getAgentCircuitStatus(AGENT); + assert.equal( + status.isOpen, + false, + "a stale out-of-order event must not regress an already-newer status", + ); + assert.equal(status.message, null); + assert.equal(status.channelId, null); + assert.equal(status.timestamp, null); + }); + + it("returns a stable reference across repeated calls with no intervening event", () => { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + + const ref1 = getAgentCircuitStatus(AGENT); + const ref2 = getAgentCircuitStatus(AGENT); + assert.strictEqual( + ref1, + ref2, + "must return the cached object reference when nothing changed — a " + + "regression here would cause an infinite useSyncExternalStore render loop", + ); + }); + + it("resetAgentObserverStore clears circuit state back to isOpen:false", () => { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + + resetAgentObserverStore(); + + const status = getAgentCircuitStatus(AGENT); + assert.equal(status.isOpen, false); + assert.equal(status.message, null); + assert.equal(status.channelId, null); + assert.equal(status.timestamp, null); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 7ae4d0bfc81..3cb96d93aa9 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -25,6 +25,25 @@ import { createEmptyTranscriptState, processTranscriptEvent, } from "./ui/agentSessionTranscript"; +import { + compareObserverEvents, + isObserverEventAfter, +} from "./lib/observerEventOrdering"; +import { + applyCircuitEvent, + getAgentCircuitStatus, + resetCircuitState, + type AgentCircuitStatus, +} from "./agentCircuitStatus"; + +export { + compareObserverEvents, + isObserverEventAfter, +} from "./lib/observerEventOrdering"; +export { + getAgentCircuitStatus, + type AgentCircuitStatus, +} from "./agentCircuitStatus"; const MAX_OBSERVER_EVENTS = 3000; // Length the per-agent journal is evicted down to when it overflows @@ -65,6 +84,15 @@ const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +/** Persistent per-agent circuit-breaker status, independent of channel/transcript scope. */ +export function useAgentCircuitStatus( + agentPubkey: string | null | undefined, +): AgentCircuitStatus { + return React.useSyncExternalStore(subscribeAgentObserverStore, () => + getAgentCircuitStatus(agentPubkey), + ); +} + // Per-agent eviction floor: the ordering key of the newest event that eviction // has ever discarded for this agent. Once the journal is trimmed to the // low-water mark, the dedup set (built only from the retained array) no longer @@ -386,42 +414,6 @@ export function getArchivedChannelEvents( ); } -export function compareObserverEvents( - left: ObserverEvent, - right: ObserverEvent, -) { - const leftTime = Date.parse(left.timestamp); - const rightTime = Date.parse(right.timestamp); - if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { - const timeDiff = leftTime - rightTime; - if (timeDiff !== 0) { - return timeDiff; - } - } - - return left.seq - right.seq; -} - -/** - * Returns true if `candidate` sorts strictly after `stored` using the same - * two-key ordering as `compareObserverEvents`: later timestamp wins; equal - * timestamp falls back to higher seq. Extracted so latest-live advancement - * cannot drift from transcript ordering. - */ -export function isObserverEventAfter( - candidate: { timestamp: string; seq: number }, - stored: { timestamp: string; seq: number }, -): boolean { - const candidateTime = Date.parse(candidate.timestamp); - const storedTime = Date.parse(stored.timestamp); - if (Number.isFinite(candidateTime) && Number.isFinite(storedTime)) { - if (candidateTime !== storedTime) { - return candidateTime > storedTime; - } - } - return candidate.seq > stored.seq; -} - // Observer event kind for a batch envelope wrapping multiple events. The ACP // harness publishes one frame per second; everything that accumulated between // ticks arrives as `{ kind: "batch", payload: { events: [...] } }` with every @@ -457,6 +449,8 @@ function processLiveObserverEvents( const addedEvents = appendAgentEvents(agentPubkey, events); for (const parsed of events) { + applyCircuitEvent(agentPubkey, parsed); + // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, // so we never attribute a session to the wrong channel. @@ -807,6 +801,12 @@ export async function ingestArchivedObserverEvents( try { const parsed = (await _decryptFn(event)) as ObserverEvent; for (const inner of unwrapObserverBatch(parsed)) { + // Circuit state is derived independent of channel scope, so apply it + // regardless of which branch below routes the raw event. + if (applyCircuitEvent(agentPubkey, inner)) { + archiveChanged = true; + } + // Route archived events to the channel-scoped archive window (no cap) // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). // Events without a channelId fall through to the live store so they @@ -849,8 +849,16 @@ export function injectObserverEventsForE2E( events: ObserverEvent[], ) { const added = appendAgentEvents(agentPubkey, events); + let circuitChanged = false; + for (const event of events) { + if (applyCircuitEvent(agentPubkey, event)) circuitChanged = true; + } if (added) { + // The targeted payload also wakes circuit subscribers, so one notify covers + // both; a circuit-only change still needs the untargeted broadcast. notifyListeners({ agentPubkey, events: added }); + } else if (circuitChanged) { + notifyListeners(); } } @@ -863,8 +871,14 @@ export function syncAgentObserverEvents( events: ObserverEvent[], ) { const added = appendAgentEvents(agentPubkey, events); + let circuitChanged = false; + for (const event of events) { + if (applyCircuitEvent(agentPubkey, event)) circuitChanged = true; + } if (added) { notifyListeners({ agentPubkey, events: added }); + } else if (circuitChanged) { + notifyListeners(); } } @@ -878,6 +892,7 @@ export function resetAgentObserverStore() { transcriptByAgent.clear(); evictionFloorByAgent.clear(); snapshotByAgent.clear(); + resetCircuitState(); archiveEventsByChannel.clear(); knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 3d205ba3cce..7a262869861 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -7,6 +7,7 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { Badge } from "@/shared/ui/badge"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; +import { useAgentCircuitStatus } from "@/features/agents/observerRelayStore"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useNow } from "@/shared/lib/useNow"; @@ -60,6 +61,7 @@ export function ManagedAgentRow({ : null; const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()]; const activeTurns = useAgentWorking(agent.pubkey).channels; + const circuitStatus = useAgentCircuitStatus(agent.pubkey); const activeWorkingChannels = React.useMemo( () => activeTurns @@ -120,6 +122,7 @@ export function ManagedAgentRow({ presenceStatus={presenceStatus} /> { + e.stopPropagation(); + void goChannel(channelId); + } + : undefined + } + > + + Suspended — repeated crashes + + ); +} + function StatusBlock({ + circuitStatus, friendlyError, isWorking, presenceLoaded, @@ -361,6 +395,7 @@ function StatusBlock({ processDetail, status, }: { + circuitStatus: ReturnType; friendlyError: ReturnType; isWorking: boolean; presenceLoaded: boolean; @@ -371,12 +406,20 @@ function StatusBlock({ return (
Status - +
+ + {circuitStatus.isOpen ? ( + + ) : null} +

{processDetail}

{friendlyError ? (

{ // panicked-in-channel call site passes the triggering channel's id, see // emit_circuit_open_alert's caller in buzz-acp/src/lib.rs), then // recovered. Both events are seeded with this channel's id to prove the - // renderer works when a channel context is present. NOTE: the real - // circuit_recovered alert is always emitted with channel_id=None (a - // respawned agent slot isn't tied to any single channel) and every - // desktop surface that shows agent transcripts is channel-scoped - // (scopeByChannel drops non-matching events) — that alert has no visible - // surface in the app today. That is a pre-existing, separate gap this - // change does not address; this test only proves the render branch - // itself is correct. + // renderer works when a channel context is present. The real + // circuit_recovered alert now carries that same real channel_id too — + // it's threaded through from the SlotCircuit that opened it (see + // emit_circuit_recovered_alert's caller in buzz-acp/src/lib.rs) rather + // than always being channel_id=None. Both the transcript bubble here and + // the persistent ManagedAgentRow badge + // (data-testid="managed-agent-circuit-open") reflect it. await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [ { seq: 1, From 17f58757d7e516211b63118adc8cc1a58c01599e Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 18:34:48 -0400 Subject: [PATCH 12/20] fix(desktop): fix live-path notify gap and surface circuit badge in the real Agents view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of the circuit-breaker badge (890cfb6) found two issues, both confirmed by independently tracing the actual code: - processLiveObserverEvents gated notifyListeners() solely on appendAgentEvents' return value, discarding applyCircuitEvent's own changed flag. appendAgentEvents' per-agent eviction floor and applyCircuitEvent's per-slot ordering gate compare against different reference points, so a circuit-only change could be committed to the store without ever notifying useAgentCircuitStatus subscribers. OR the two signals together, matching every other ingestion path in the file. Added a regression test reproducing the divergence via a raw- journal dedup collision (cheaper to construct than real eviction). - The badge was wired into ManagedAgentRow/AgentGroupRows, but that component tree has no import anywhere outside itself — it is not mounted on the app's actual Agents route. The real reachable screen (AgentsScreen -> AgentsView -> UnifiedAgentsSection, rendering AgentPersonaCard/StandaloneAgentCard) had no circuit-status wiring at all, so the feature had no effect in the running app. Added the same badge to both card components' existing statusBadge slot, taking priority over the "Configuration missing" badge when both apply. Left ManagedAgentRow's badge in place (still covered by its own tests) rather than deciding unilaterally whether that component tree is dead code or a future integration point. Signed-off-by: Michael Feth --- .../observerRelayStore.circuitStatus.test.mjs | 51 +++++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 17 ++++++- .../agents/ui/UnifiedAgentsSection.tsx | 29 ++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs index e9af63694b4..432ac9a6fa5 100644 --- a/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs +++ b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs @@ -4,6 +4,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { getAgentCircuitStatus, resetAgentObserverStore, + subscribeAgentObserverStore, _testProcessLiveObserverEvents, } from "./observerRelayStore.ts"; @@ -207,6 +208,56 @@ describe("getAgentCircuitStatus", () => { ); }); + it("notifies subscribers on a circuit change even when appendAgentEvents rejects the raw event as a dedup collision", () => { + // appendAgentEvents dedups purely on (timestamp, seq), independent of + // kind — so a circuit_open event that happens to share its (timestamp, + // seq) with an already-appended, unrelated event is treated as a + // duplicate by the raw journal (appendAgentEvents returns false) even + // though it is the first-ever circuit event for its own slot, so + // applyCircuitEvent legitimately applies it (returns true). Regression + // guard for the case where only appendAgentEvents' return value gated + // notifyListeners(): the circuit state would update silently with no + // subscriber ever told, so useAgentCircuitStatus consumers (the + // suspended-agent badge) would not re-render until unrelated traffic + // happened to touch the same agent. + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 7, + timestamp: "2024-01-01T00:00:00Z", + kind: "acp_read", + agentIndex: null, + channelId: "chan-uuid-1", + payload: {}, + }), + ]); + + let notifyCount = 0; + const unsubscribe = subscribeAgentObserverStore(() => { + notifyCount += 1; + }); + try { + _testProcessLiveObserverEvents(AGENT, [ + makeEvent({ + seq: 7, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 2, + channelId: "chan-uuid-2", + payload: { error: "slot 2 opened" }, + }), + ]); + } finally { + unsubscribe(); + } + + assert.equal( + notifyCount, + 1, + "a circuit-only state change must still notify useSyncExternalStore subscribers", + ); + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + }); + it("resetAgentObserverStore clears circuit state back to isOpen:false", () => { _testProcessLiveObserverEvents(AGENT, [ makeEvent({ diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 3cb96d93aa9..4848666c12a 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -447,9 +447,20 @@ function processLiveObserverEvents( // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. const addedEvents = appendAgentEvents(agentPubkey, events); + let observerChanged = addedEvents !== null; for (const parsed of events) { - applyCircuitEvent(agentPubkey, parsed); + // appendAgentEvents' per-agent eviction floor and applyCircuitEvent's + // per-slot ordering gate compare against different reference points, so a + // circuit event can be rejected by one and accepted by the other (e.g. a + // late-delivered frame after a relay reconnect, at/before the agent's + // floor but the first event ever seen for its slot). OR the two signals + // so a circuit-only change still notifies subscribers — matches every + // other ingestion path in this file (ingestArchivedObserverEvents, + // injectObserverEventsForE2E, syncAgentObserverEvents). + if (applyCircuitEvent(agentPubkey, parsed)) { + observerChanged = true; + } // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, @@ -494,6 +505,10 @@ function processLiveObserverEvents( // before specialized callbacks, but external-store subscribers publish once. if (addedEvents) { notifyListeners({ agentPubkey, events: addedEvents }); + } else if (observerChanged) { + // Circuit-only change: no events were retained, so there is no targeted + // payload to publish, but circuit subscribers still need waking. + notifyListeners(); } } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index d0ff2e2738a..352df290fe9 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -9,6 +9,7 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { useAgentCircuitStatus } from "@/features/agents/observerRelayStore"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; @@ -261,6 +262,7 @@ function AgentPersonaCard({ }); const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); + const circuitStatus = useAgentCircuitStatus(agent?.pubkey); const avatarUrl = agent ? resolveAgentCardAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) : persona.avatarUrl; @@ -326,7 +328,19 @@ function AgentPersonaCard({ onOpenPersonaProfile(persona); }} statusBadge={ - agent?.personaOrphaned ? ( + circuitStatus.isOpen ? ( + + + Suspended — repeated crashes + + ) : agent?.personaOrphaned ? ( Configuration missing @@ -359,6 +373,7 @@ function StandaloneAgentCard({ }) { const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); + const circuitStatus = useAgentCircuitStatus(agent.pubkey); const friendlyError = friendlyAgentLastError( agent.lastError, agent.lastErrorCode, @@ -407,7 +422,17 @@ function StandaloneAgentCard({ ); }} statusBadge={ - agent.personaOrphaned ? ( + circuitStatus.isOpen ? ( + + + Suspended — repeated crashes + + ) : agent.personaOrphaned ? ( Configuration missing From f251b878108e19aa673a5a30d9ed8a13114cbdc4 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 19:16:27 -0400 Subject: [PATCH 13/20] fix(desktop): accessible tooltip and keyboard activation for the circuit-open badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review of the circuit-breaker badge (890cfb6) flagged two accessibility gaps against this repo's own established pattern (RestartDiffBadge's Tooltip/TooltipTrigger/TooltipContent): - The badge's explanatory message was a bare `title` attribute — hover-only, no keyboard trigger, inconsistently exposed by screen readers. Swapped for the same Radix Tooltip pattern RestartDiffBadge already uses, with tabIndex={0} so it's keyboard-focusable. - ManagedAgentRow's CircuitOpenBadge has an onClick that navigates to the triggering channel, but no tabIndex/role/onKeyDown, so it was inert for keyboard/switch-device users despite being the most urgent and most-worth-clicking badge on the row. Added role="button" and an Enter/Space onKeyDown handler, mirroring the existing keyboard pattern in ForumPostCard. The UnifiedAgentsSection cards' circuit badge has no click action (the card itself already opens the agent's profile), so it gets the Tooltip fix but not the button semantics/keyboard handler — matching RestartDiffBadge's own non-interactive-but-tooltip-focusable shape. Factored the previously-duplicated inline badge JSX in both AgentPersonaCard and StandaloneAgentCard into one CircuitOpenStatusBadge component in the same pass. Signed-off-by: Michael Feth --- .../features/agents/ui/ManagedAgentRow.tsx | 63 ++++++++++++++----- .../agents/ui/UnifiedAgentsSection.tsx | 60 ++++++++++++------ 2 files changed, 88 insertions(+), 35 deletions(-) diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 7a262869861..11d3ed5fa77 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -24,6 +24,7 @@ import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { PubKey } from "@/shared/ui/PubKey"; import { SubsectionLabel } from "@/shared/ui/PageHeader"; import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { RestartDiffBadge } from "./RestartDiffBadge"; export function ManagedAgentRow({ @@ -365,24 +366,52 @@ function CircuitOpenBadge({ message: string | null; }) { const { goChannel } = useAppNavigation(); - return ( - { - e.stopPropagation(); - void goChannel(channelId); - } - : undefined + const activate = channelId + ? () => { + void goChannel(channelId); } - > - - Suspended — repeated crashes - + : undefined; + + return ( + + {/* asChild renders the trigger as the Badge's — see + RestartDiffBadge for why this must stay a non-nested-interactive + element rather than a real

{processDetail}

diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 205782d96b8..0ae8963e6f4 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -9,12 +9,17 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; -import { useAgentCircuitStatus } from "@/features/agents/observerRelayStore"; +import { + circuitCooldownRemainingMs, + useAgentCircuitStatus, +} from "@/features/agents/observerRelayStore"; +import { formatDurationMs } from "@/features/agents/ui/agentSessionUtils"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; +import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -228,12 +233,23 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { * reachable, mirroring RestartDiffBadge's non-interactive tooltip pattern. */ function CircuitOpenStatusBadge({ + circuitStatus, dataTestId, - message, }: { + circuitStatus: ReturnType; dataTestId: string | undefined; - message: string | null; }) { + // See CircuitOpenBadge in ManagedAgentRow.tsx for why this ticks every + // second — same shared-timer useNow, cheap even with many suspended agents. + const now = useNow(1000); + const remainingMs = circuitCooldownRemainingMs(circuitStatus, now); + const label = + remainingMs === null + ? "Suspended — repeated crashes" + : remainingMs > 0 + ? `Suspended — retrying in ${formatDurationMs(remainingMs)}` + : "Suspended — health check pending"; + return ( @@ -241,14 +257,14 @@ function CircuitOpenStatusBadge({ className="gap-1" data-testid={dataTestId} tabIndex={0} - variant="destructive" + variant={remainingMs === 0 ? "warning" : "destructive"} > - Suspended — repeated crashes + {label} - {message ?? "Agent suspended (repeated crashes)."} + {circuitStatus.message ?? "Agent suspended (repeated crashes)."} ); @@ -364,10 +380,10 @@ function AgentPersonaCard({ statusBadge={ circuitStatus.isOpen ? ( ) : agent?.personaOrphaned ? ( @@ -453,8 +469,8 @@ function StandaloneAgentCard({ statusBadge={ circuitStatus.isOpen ? ( ) : agent.personaOrphaned ? ( From 41c61294136268e6c892ece462d06e2d575d8725 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 21:17:31 -0400 Subject: [PATCH 16/20] feat(desktop): surface suspended agents in the channel composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The circuit-open badge only ever lived on the Agents settings screen — a user typing to an agent that just crashed mid-conversation had no in-channel sign of it, only the working/typing indicators that, by definition, a crashed agent can never satisfy. BotActivityComposerAction now also renders for agents with an open circuit (via the new useOpenCircuitAgents hook), not just working ones, with a distinct destructive-styled trigger and popover section that takes priority over the working display — a dead agent is more urgent to notice than a busy one. Suspended agents were previously invisible to this component's whole mount condition, so the parent gates (ChannelPane's hasComposerBottomActivity, which controls whether the reserved composer rail even animates into view, and ChannelComposerActivityAccessory's own wrapper) both needed the same suspended-aware check — fixing only the leaf component would have left the row structurally unable to appear. Factored the popover's working/suspended row rendering (previously one copy, now two near-identical ones) into a shared AgentPopoverSection component rather than duplicating the JSX a second time. Signed-off-by: Michael Feth --- .../features/channels/ui/BotActivityBar.tsx | 196 +++++++++++++----- .../ui/ChannelComposerActivityAccessory.tsx | 28 +-- .../src/features/channels/ui/ChannelPane.tsx | 10 +- 3 files changed, 171 insertions(+), 63 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a961030..0a216a46ecf 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { Loader2 } from "lucide-react"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { useOpenCircuitAgents } from "@/features/agents/observerRelayStore"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { getActivityHeadline, @@ -51,6 +52,12 @@ export function BotActivityComposerAction({ return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase())); }, [agents, workingBotPubkeys]); + // A suspended (crashed, circuit-open) agent is never "working" — this is + // the in-channel counterpart to the persistent circuit badge on the Agents + // screen, so a user typing to an agent that just crashed sees it here too + // instead of only after navigating to Settings. + const suspendedAgents = useOpenCircuitAgents(agents); + const hasSuspended = suspendedAgents.length > 0; const singleWorkingAgent = workingAgents.length === 1 ? (workingAgents[0] ?? null) : null; const transcript = useAgentTranscript( @@ -136,20 +143,29 @@ export function BotActivityComposerAction({ return () => window.clearInterval(interval); }, [activityHeadlines.length]); - if (workingAgents.length === 0) { + if (workingAgents.length === 0 && !hasSuspended) { return null; } const agentAvatarUrl = (agent: BotActivityAgent) => profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null; - const triggerLabel = - workingAgents.length === 1 + // Suspended takes priority in the trigger — an agent that's dead is more + // urgent to notice than one that's merely busy. + const headlineAgents = hasSuspended ? suspendedAgents : workingAgents; + const triggerLabel = hasSuspended + ? suspendedAgents.length === 1 + ? `${suspendedAgents[0]?.name ?? "Agent"} suspended — repeated crashes` + : `${suspendedAgents.length} agents suspended` + : workingAgents.length === 1 ? `${workingAgents[0]?.name ?? "Agent"} is working` : `${workingAgents.length} agents working`; const isInline = variant === "inline"; - const visibleStatusLabel = - workingAgents.length === 1 + const visibleStatusLabel = hasSuspended + ? suspendedAgents.length === 1 + ? `${suspendedAgents[0]?.name ?? "Agent"}: Suspended` + : `${suspendedAgents[0]?.name ?? "Agent"} +${suspendedAgents.length - 1} suspended` + : workingAgents.length === 1 ? `${workingAgents[0]?.name ?? "Agent"}: ${ activityHeadlines[headlineIndex % activityHeadlines.length] ?? "Working" @@ -163,6 +179,9 @@ export function BotActivityComposerAction({ aria-label={`${triggerLabel}. View activity.`} className={cn( "inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary", + hasSuspended && + !isInline && + "border-destructive/40 text-destructive hover:border-destructive/60 hover:bg-destructive/5 hover:text-destructive data-[state=open]:border-destructive/60 data-[state=open]:bg-destructive/10 data-[state=open]:text-destructive", isInline ? "min-w-0 gap-1.5 overflow-visible border-transparent bg-transparent px-0 text-xs font-normal leading-normal shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" : "h-9 min-w-9 gap-1.5 px-2 text-xs", @@ -179,7 +198,7 @@ export function BotActivityComposerAction({ type="button" > - {workingAgents.slice(0, 2).map((agent) => ( + {headlineAgents.slice(0, 2).map((agent) => ( ))} - {workingAgents.length > 2 ? ( + {headlineAgents.length > 2 ? ( - +{workingAgents.length - 2} + +{headlineAgents.length - 2} ) : null} {isInline ? ( - + {visibleStatusLabel} + ) : hasSuspended ? ( + "suspended" ) : ( "working" )} - {isInline ? null : ( + {isInline ? null : hasSuspended ? ( + + ) : ( )} @@ -227,46 +255,116 @@ export function BotActivityComposerAction({ side="top" sideOffset={8} > -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); + {hasSuspended ? ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey, channelId); + }} + selectedPubkey={selectedPubkey} + trailingIcon={ + + } + variant="destructive" + /> + ) : null} + {workingAgents.length > 0 ? ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey, channelId); + }} + selectedPubkey={selectedPubkey} + trailingIcon={ + + } + variant="default" + /> + ) : null} + + + ); +} - return ( - - ); - })} -
- - + )} + data-testid={`bot-activity-composer-item-${agent.pubkey}`} + key={agent.pubkey} + onClick={() => onCloseWithSelection(agent.pubkey)} + type="button" + > + + {agent.name} + + {itemStatusLabel} + + {trailingIcon} + + ); + })} + + ); } diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx index f99888f0112..cdd28ecf64f 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -42,19 +42,21 @@ export function ChannelComposerActivityAccessory({ >
{cardMintJobs.length > 0 ? : null} - {workingBotPubkeys.length > 0 ? ( -
- -
- ) : null} + {/* BotActivityComposerAction also renders for suspended (circuit-open) + agents, not just working ones — its own internal check decides + whether there's anything to show, so this wrapper no longer gates + on workingBotPubkeys alone. */} +
+ +
{typingPubkeys.length > 0 ? ( 0; + // A suspended (circuit-open) agent is never "working", so it wouldn't + // otherwise open this reserved rail — without this, a user typing to an + // agent that just crashed sees no in-channel sign of it at all, only the + // Agents settings screen (see BotActivityComposerAction, which renders the + // matching suspended state once this gate lets it mount). + const composerSuspendedAgents = useOpenCircuitAgents(activityAgents); + const hasComposerBotActivity = + composerWorkingBotPubkeys.length > 0 || composerSuspendedAgents.length > 0; const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; From 1ba081f75f36470b09542bad6b143f3444950191 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 20:09:38 -0400 Subject: [PATCH 17/20] fix(desktop): restore the live-path circuit notify lost in the main merge The `Merge branch 'main'` resolution of observerRelayStore.ts kept `observerChanged = true` in the live ingestion loop but dropped its declaration, so the branch did not compile: src/features/agents/observerRelayStore.ts:461:7 - error TS2552: Cannot find name 'observerChanged'. Did you mean 'observerTag'? `tsc --noEmit` exited 2 on the merge head. Underneath that, the same resolution reintroduced the bug cf048ac exists to fix. Upstream had changed appendAgentEvents to return the appended events and notifyListeners to take them as a payload; the merge kept upstream's `if (addedEvents)` guard, which fires only when something was appended. A circuit-only change appends nothing, so the badge never repainted -- exactly the live-path notify gap, restored. Both halves are needed, so both are kept: `addedEvents` carries the payload subscribers receive, and `observerChanged` starts from whether anything was appended and is OR'd with applyCircuitEvent in the loop, as the loop's own comment already describes. The notify site now falls through to a payload-less notifyListeners() when only circuit state moved. This is deliberately a commit on top rather than a re-resolution of the merge: force-pushing a rebase here would have discarded e97e264 (the badge's accessible tooltip and keyboard activation), which landed after the merge. Also signs off the merge commit itself, which GitHub's web UI created without a Signed-off-by trailer -- the DCO check was failing on it. Verified: tsc --noEmit clean (was exit 2), desktop unit suite 4963/4963. Still failing and NOT addressed here, because it is pre-existing to this branch and is a structural call for the author: `pnpm check` fails the file size ratchet on src/features/agents/ui/agentSessionTranscript.ts (1174 -> 1194). The file is 1193 lines on the pre-merge branch tip too, so this commit does not affect it. Per CLAUDE.md the remedy is splitting the file, never raising the limit. Co-authored-by: Michael Feth Signed-off-by: Michael Feth --- desktop/src/features/agents/observerRelayStore.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index e36b36d6146..db644428d72 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -475,6 +475,9 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. + // `addedEvents` is the payload external-store subscribers receive; + // `observerChanged` additionally tracks circuit-only changes, which append + // nothing but must still repaint the badge (see the OR in the loop below). const addedEvents = appendAgentEvents(agentPubkey, events); let observerChanged = addedEvents !== null; From bda6be69332b85c4e4dd50cb1445701da6f72258 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 21:30:34 -0400 Subject: [PATCH 18/20] fix(desktop): restore circuit-event handling dropped by the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of main into this branch (067ba4e) auto-resolved cleanly — no textual conflicts — but silently dropped the applyCircuitEvent loop from injectObserverEventsForE2E and syncAgentObserverEvents. Upstream had changed appendAgentEvents' return shape and these two functions' bodies changed enough on both sides that git's merge picked a version missing the circuit-event fold entirely, with nothing to flag it since there was no conflicting line. Caught by reading the merged file rather than trusting a clean merge plus passing tests — the existing unit tests only ever exercised _testProcessLiveObserverEvents (which was correct post-merge), so this specific regression had zero coverage. Restored both loops, adapted to the new appendAgentEvents(...) => ObserverEvent[] | null shape (OR circuit-only changes into a payload-less notifyListeners() call, matching processLiveObserverEvents' own pattern), and added regression tests for both functions plus the notify-on-dedup-collision case that the live path already covers. Signed-off-by: Michael Feth --- .../observerRelayStore.circuitStatus.test.mjs | 85 +++++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 5 ++ 2 files changed, 90 insertions(+) diff --git a/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs index d099ffffe01..57bf5e92200 100644 --- a/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs +++ b/desktop/src/features/agents/observerRelayStore.circuitStatus.test.mjs @@ -5,8 +5,10 @@ import { circuitCooldownRemainingMs, getAgentCircuitStatus, getOpenCircuitPubkeySignature, + injectObserverEventsForE2E, resetAgentObserverStore, subscribeAgentObserverStore, + syncAgentObserverEvents, _testProcessLiveObserverEvents, } from "./observerRelayStore.ts"; @@ -303,6 +305,89 @@ describe("getAgentCircuitStatus", () => { }); }); +describe("injectObserverEventsForE2E and syncAgentObserverEvents fold circuit events", () => { + // Regression coverage: a prior merge with upstream (which changed + // appendAgentEvents' return shape) silently dropped the applyCircuitEvent + // loop from both of these functions — no textual conflict, so it merged + // clean but wrong. _testProcessLiveObserverEvents alone wouldn't have + // caught it, since neither of these functions routes through it. + beforeEach(() => { + resetAgentObserverStore(); + }); + + afterEach(() => { + resetAgentObserverStore(); + }); + + it("injectObserverEventsForE2E updates circuit status, not just the raw journal", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + }); + + it("syncAgentObserverEvents updates circuit status, not just the raw journal", () => { + syncAgentObserverEvents(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + }); + + it("injectObserverEventsForE2E notifies subscribers on a circuit-only change (no new raw events appended)", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "acp_read", + agentIndex: null, + channelId: "chan-uuid-1", + payload: {}, + }), + ]); + + let notifyCount = 0; + const unsubscribe = subscribeAgentObserverStore(() => { + notifyCount += 1; + }); + try { + // Same (timestamp, seq) as the event above — appendAgentEvents treats + // this as a duplicate and returns null, but it's the first-ever circuit + // event for this slot, so applyCircuitEvent still applies it. + injectObserverEventsForE2E(AGENT, [ + makeEvent({ + seq: 1, + timestamp: "2024-01-01T00:00:00Z", + kind: "circuit_open", + agentIndex: 0, + channelId: "chan-uuid-1", + payload: { error: "opened" }, + }), + ]); + } finally { + unsubscribe(); + } + + assert.equal(notifyCount, 1); + assert.equal(getAgentCircuitStatus(AGENT).isOpen, true); + }); +}); + describe("circuitCooldownRemainingMs", () => { const OPEN_STATUS = { isOpen: true, diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index db644428d72..b47e5e82fc3 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -896,6 +896,11 @@ export function injectObserverEventsForE2E( events: ObserverEvent[], ) { const added = appendAgentEvents(agentPubkey, events); + // Circuit events must still be folded even when appendAgentEvents rejects + // the raw journal entry (see the matching comment in processLiveObserverEvents) — + // this path exists specifically so E2E specs exercise the real ingestion + // pipeline, and a circuit_open/circuit_recovered event injected here that + // silently failed to update circuit state would make that guarantee false. let circuitChanged = false; for (const event of events) { if (applyCircuitEvent(agentPubkey, event)) circuitChanged = true; From 126936768e61e0daecd26eab8835fdcc7e001bc2 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sun, 16 Aug 2026 21:34:55 -0400 Subject: [PATCH 19/20] test(desktop): screenshot the circuit-open badge on the real Agents view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests in observerRelayStore.circuitStatus.test.mjs cover the store/derivation layer, but nothing proved the badge actually renders in the live app — the adversarial review that caught the badge's original dead-code placement (ManagedAgentRow/AgentGroupRows, no reachable route) flagged this as a real gap: a UI-visible feature with no UI-level test. Mirrors needs-restart-screenshots.spec.ts's navigation pattern (installMockBridge -> gotoAgentsView) and observer-feed-screenshots.spec.ts's event-seeding pattern (__BUZZ_E2E_SEED_OBSERVER_EVENTS__), covering both card variants this repo actually renders (StandaloneAgentCard, AgentPersonaCard): badge absent while closed, appears with an accessible tooltip on circuit_open, disappears on a matching circuit_recovered. Ran against the real build (pnpm build:e2e + playwright test --project=smoke) rather than just typechecked — both tests pass, screenshots visually confirm the destructive-styled "Suspended — repeated crashes" badge renders correctly on the actual agent card. Signed-off-by: Michael Feth --- desktop/playwright.config.ts | 1 + .../circuit-open-badge-screenshots.spec.ts | 213 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 desktop/tests/e2e/circuit-open-badge-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91b..b71015f4d07 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -147,6 +147,7 @@ export default defineConfig({ "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", "**/needs-restart-screenshots.spec.ts", + "**/circuit-open-badge-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/tests/e2e/circuit-open-badge-screenshots.spec.ts b/desktop/tests/e2e/circuit-open-badge-screenshots.spec.ts new file mode 100644 index 00000000000..304d896124c --- /dev/null +++ b/desktop/tests/e2e/circuit-open-badge-screenshots.spec.ts @@ -0,0 +1,213 @@ +/** + * Screenshot spec for the persistent circuit-breaker "Suspended" badge on the + * real Agents view (block/buzz#5888). + * + * An adversarial review of this feature found the badge had first been wired + * into ManagedAgentRow/AgentGroupRows — a component tree with no reachable + * route in the shipped app — so it had zero real-world effect. It was + * relocated to StandaloneAgentCard/AgentPersonaCard in UnifiedAgentsSection, + * the actual cards this repo's own "Agents" screen renders. This spec proves + * the badge renders there, not just that the underlying store logic is + * correct (the unit tests in observerRelayStore.circuitStatus.test.mjs cover + * that layer already). + * + * Exercises: + * - Badge absent while the circuit is closed. + * - Badge appears on a circuit_open event, with an accessible tooltip + * (Tooltip/TooltipTrigger, not a bare `title` attribute — see the + * RestartDiffBadge precedent this reuses). + * - Badge disappears on a matching circuit_recovered event. + * - Same coverage on the persona-linked card variant. + */ + +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/circuit-open-badge-screenshots"; + +const STANDALONE_AGENT = { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "Local Agent", + status: "running" as const, +}; + +const PERSONA_AGENT = { + pubkey: TEST_IDENTITIES.bob.pubkey, + name: "Persona Agent", + personaId: "builtin:fizz", + status: "running" as const, +}; + +async function waitForSeedHook(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForSeedHook(page); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); +} + +async function seedObserverEvents( + page: import("@playwright/test").Page, + agentPubkey: string, + events: Array<{ + seq: number; + timestamp: string; + kind: string; + agentIndex: number | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + payload: unknown; + }>, +) { + await page.evaluate( + ({ pubkey, evts }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey: pubkey, + events: evts, + }); + }, + { pubkey: agentPubkey, evts: events }, + ); + // Let React re-render after the store update. + await page.waitForTimeout(300); +} + +// No cooldown_secs in the payload — keeps the badge on its static +// "Suspended — repeated crashes" label rather than a live countdown, so the +// screenshot and text assertions aren't timing-sensitive. +function circuitOpenEvent(overrides: { channelId?: string | null } = {}) { + return { + seq: 1, + timestamp: new Date().toISOString(), + kind: "circuit_open", + agentIndex: 0, + channelId: overrides.channelId ?? null, + sessionId: null, + turnId: null, + payload: { + error: + "Agent slot 0 panicked repeatedly and its circuit breaker is now open.", + }, + }; +} + +function circuitRecoveredEvent() { + return { + seq: 2, + timestamp: new Date(Date.now() + 1000).toISOString(), + kind: "circuit_recovered", + agentIndex: 0, + channelId: null, + sessionId: null, + turnId: null, + payload: { error: "Agent slot 0 recovered." }, + }; +} + +test.describe("circuit-open badge screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + test("01-standalone-card-circuit-open-and-recover", async ({ page }) => { + await installMockBridge(page, { managedAgents: [STANDALONE_AGENT] }); + await gotoAgentsView(page); + + const agentCard = page.getByTestId( + `managed-agent-${STANDALONE_AGENT.pubkey}`, + ); + await expect(agentCard).toBeVisible({ timeout: 10_000 }); + const badge = agentCard.getByTestId( + `managed-agent-circuit-open-${STANDALONE_AGENT.pubkey}`, + ); + + // Closed by default — no badge. + await expect(badge).toHaveCount(0); + + await seedObserverEvents(page, STANDALONE_AGENT.pubkey, [ + circuitOpenEvent(), + ]); + + await expect(badge).toBeVisible({ timeout: 5_000 }); + await expect(badge).toHaveText("Suspended — repeated crashes"); + + // Accessible tooltip: keyboard-focusable (tabIndex), Tooltip primitive + // rather than a bare `title` attribute. + await badge.hover(); + const tooltip = page.locator("[role=tooltip]"); + await expect(tooltip).toBeVisible({ timeout: 5_000 }); + await expect(tooltip).toHaveText( + "Agent slot 0 panicked repeatedly and its circuit breaker is now open.", + ); + + await waitForAnimations(page); + await agentCard.screenshot({ + path: `${SHOTS}/01-standalone-card-circuit-open.png`, + }); + + await seedObserverEvents(page, STANDALONE_AGENT.pubkey, [ + circuitRecoveredEvent(), + ]); + + await expect(badge).toHaveCount(0); + }); + + test("02-persona-card-circuit-open-and-recover", async ({ page }) => { + await installMockBridge(page, { + activePersonaIds: [PERSONA_AGENT.personaId], + managedAgents: [PERSONA_AGENT], + }); + await gotoAgentsView(page); + + const personaCard = page.getByTestId( + `persona-agent-row-${PERSONA_AGENT.personaId}`, + ); + await expect(personaCard).toBeVisible({ timeout: 10_000 }); + const badge = personaCard.getByTestId( + `managed-agent-circuit-open-${PERSONA_AGENT.pubkey}`, + ); + + await expect(badge).toHaveCount(0); + + await seedObserverEvents(page, PERSONA_AGENT.pubkey, [ + circuitOpenEvent({ channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301" }), + ]); + + await expect(badge).toBeVisible({ timeout: 5_000 }); + await expect(badge).toHaveText("Suspended — repeated crashes"); + + await waitForAnimations(page); + await personaCard.screenshot({ + path: `${SHOTS}/02-persona-card-circuit-open.png`, + }); + + await seedObserverEvents(page, PERSONA_AGENT.pubkey, [ + circuitRecoveredEvent(), + ]); + + await expect(badge).toHaveCount(0); + }); +}); From 64b8dcdc9fbe6ce5d46401e0b7877a7a43d7c262 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Mon, 17 Aug 2026 07:51:56 -0400 Subject: [PATCH 20/20] Revert "feat(agent): default the reply guard on" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 20a6763 on this branch only. The change now lives in its own PR, block/buzz#6118, where it can be reviewed as what it is: a product-default change to the buzz-agent binary affecting self-hosted deployments. It does not belong here. This PR is about circuit-breaker alerting and workflow visibility, and its own description argued against flipping this default — a reviewer reading the body and then the diff found them in direct contradiction. The flip was also incomplete as carried here. reply_guard_off_by_default (crates/buzz-agent/tests/regressions.rs) spawns with no env override and asserts exactly one LLM call, documented as the invariant that keeps the guard free for anyone who has not opted in. Flipping the default without inverting that test leaves cargo test -p buzz-agent red. #6118 carries the inversion, along with the two comments that documented the old policy. No behaviour change to the circuit-breaker work in this PR. Co-authored-by: Michael Feth Signed-off-by: Michael Feth --- crates/buzz-agent/README.md | 18 +++++++++--------- crates/buzz-agent/src/config.rs | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 9c81800ae6d..0bc03db7813 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -164,24 +164,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | -| `BUZZ_AGENT_REQUIRE_REPLY` | `1` | `0` disables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | ## Reply Guard -On by default. A turn that is about to end without any recognized attempt to -post to Buzz gets a reminder that its assistant text is invisible to humans, -and is rerolled. +Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop +sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is +about to end without any recognized attempt to post to Buzz gets a reminder that +its assistant text is invisible to humans, and is rerolled. This exists because a Buzz agent's reasoning and tool output are not shown to anyone. A turn that does real work and never posts is a silent failure — the requester waits on a result that was produced and thrown away. -Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts -out. Buzz shared-compute (mesh) agents also always get it via Desktop's -`insert_default_if_unset`, which never overrides an explicit value — so an -agent, persona, or global `BUZZ_AGENT_REQUIRE_REPLY=0` still opts a mesh agent -out even though the binary's own default now agrees with it. +Mesh agents get it by default because they run on small local models, which are +the ones most likely to do the work and then end the turn without publishing it. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a +mesh agent back out; the default never overrides an explicit value. **Advisory, never a trap.** At most two reminders, then the turn ends whether or not anything was published. The guard catches accidental omission; it does not diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 1a8bf3e8f4b..67d7c593b56 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -484,8 +484,8 @@ pub struct Config { /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, /// Remind the model to publish when a turn is about to end without any - /// recognized attempt to post to Buzz. Default on; opt out per agent with - /// `BUZZ_AGENT_REQUIRE_REPLY=0`. + /// recognized attempt to post to Buzz. Default off; opt in per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=1`. /// /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), /// then the turn ends regardless. Bounded by the same @@ -624,7 +624,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, - require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 1u8)? != 0, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,