From c488b42a82bcb9e8c920e5b7fc2df21c6b47516a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 01:21:15 -0500 Subject: [PATCH] fix(desktop): show recently failed delegations in the Agents panel and status bar subagent.list now carries durable failed async delegations. Reconcile them into terminal failed rows so a failure stays visible after its child ends or a renderer reload empties the live roster. A live row for the same task wins, and a row a turn already retired stays gone. The Agents summary tints the "N failed" count and drops the duplicate error glyph on stream lines. --- apps/desktop/src/app/agents/index.tsx | 22 ++++++++--- .../status-stack/use-subagent-snapshot.ts | 12 +++--- apps/desktop/src/store/subagents.test.ts | 39 +++++++++++++++++++ apps/desktop/src/store/subagents.ts | 38 +++++++++++++++++- 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 480efe444b..3603471252 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -1,6 +1,6 @@ import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' -import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { Fragment, type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' @@ -51,8 +51,10 @@ const STREAM_TONE: Record = { } function streamGlyph(entry: SubagentStreamEntry): ReactNode { + // Errors carry no glyph of their own: the row's status icon already marks + // the failure, and the destructive text says what went wrong. if (entry.isError) { - return + return null } if (entry.kind === 'tool') { @@ -238,10 +240,12 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { ) } + const failedLabel = failed > 0 ? t.agents.failedCount(failed) : '' + const summary = [ t.agents.agentsCount(flat.length), active > 0 ? t.agents.activeCount(active) : '', - failed > 0 ? t.agents.failedCount(failed) : '', + failedLabel, tools > 0 ? t.agents.toolsCount(tools) : '', files > 0 ? t.agents.filesCount(files) : '', tokens > 0 ? fmtTokens(tokens, t.agents) : '', @@ -250,7 +254,14 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { return (
-

{summary.join(' · ')}

+

+ {summary.map((part, index) => ( + + {index > 0 ? ' · ' : null} + {part === failedLabel ? {part} : part} + + ))} +

{groups.map(group => ( @@ -302,10 +313,11 @@ function StreamLine({ const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) const isMono = entry.kind === 'tool' const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] + const glyph = streamGlyph(entry) return (
- {streamGlyph(entry)} + {glyph ? {glyph} : null} {entry.text} {active ? ( diff --git a/apps/desktop/src/app/chat/composer/status-stack/use-subagent-snapshot.ts b/apps/desktop/src/app/chat/composer/status-stack/use-subagent-snapshot.ts index 67a2a46ca2..88582d09ed 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/use-subagent-snapshot.ts +++ b/apps/desktop/src/app/chat/composer/status-stack/use-subagent-snapshot.ts @@ -36,12 +36,10 @@ export function useSubagentSnapshot(sessionId: string | null, poll = true) { const owner = JSON.stringify(knownOwnerForSession(sessionId)) try { - const snapshot = await requestForOwnedSession<{ subagents: SubagentPayload[] }>( - sessionId, - rejectUnownedSubagentRequest, - 'subagent.list', - { session_id: sessionId } - ) + const snapshot = await requestForOwnedSession<{ + delegations?: SubagentPayload[] + subagents: SubagentPayload[] + }>(sessionId, rejectUnownedSubagentRequest, 'subagent.list', { session_id: sessionId }) if ( !cancelled && @@ -49,7 +47,7 @@ export function useSubagentSnapshot(sessionId: string | null, poll = true) { before === $subagentsBySession.get()[sessionId] && Array.isArray(snapshot.subagents) ) { - reconcileSubagentSnapshot(sessionId, snapshot.subagents) + reconcileSubagentSnapshot(sessionId, snapshot.subagents, snapshot.delegations ?? []) } failures = 0 diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 5ca542f092..e2b538c22e 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -61,6 +61,45 @@ describe('subagent store', () => { expect(activeSubagentCount(listFor('owner'))).toBe(1) }) + it('lands durable failed delegations as failed rows once, and never over a live row or a retired one (#97202)', () => { + const failure = { + completed_at: 1_700_000_000, + delegation_id: 'd1', + dispatched_at: 1_699_999_700, + error: 'interrupted: waiting for model response', + goal: 'Audit billing', + status: 'error', + task_index: 0 + } + + // A renderer reload emptied the store; the roster has no live children left. + reconcileSubagentSnapshot('owner', [], [failure]) + const [row] = listFor('owner') + expect(row).toMatchObject({ goal: 'Audit billing', status: 'failed', summary: failure.error, delegationId: 'd1' }) + expect(row).toMatchObject({ durationSeconds: 300, startedAt: 1_699_999_700_000, updatedAt: 1_700_000_000_000 }) + expect(failedSubagentCount(listFor('owner'))).toBe(1) + + // Repeated polls keep the same frame. + const first = listFor('owner') + reconcileSubagentSnapshot('owner', [], [failure]) + expect(listFor('owner')).toBe(first) + + // A turn that pruned the row retires it for good. + pruneFinishedSessionSubagents('owner') + reconcileSubagentSnapshot('owner', [], [failure]) + expect(listFor('owner')).toEqual([]) + + // An event-fed row for the same task wins over the durable copy. + upsertSubagent( + 'live', + { delegation_id: 'd1', goal: 'Audit billing', status: 'failed', subagent_id: 'sa-1', task_index: 0 }, + true, + 'subagent.complete' + ) + reconcileSubagentSnapshot('live', [], [failure]) + expect(listFor('live').map(item => item.id)).toEqual(['sa-1']) + }) + it('builds parent/child trees', () => { upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 }) upsertSubagent('s1', { goal: 'child', parent_id: 'p', status: 'queued', subagent_id: 'c', task_index: 1 }) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index b08a70bd03..3bd1652b8b 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -241,8 +241,17 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined } } -/** Reconcile a scoped, race-checked snapshot without replacing stream history. */ -export function reconcileSubagentSnapshot(sid: string, children: SubagentPayload[]) { +const failedDelegationId = (p: SubagentPayload) => `delegation:${str(p.delegation_id)}:${num(p.task_index) ?? 0}` + +/** Reconcile a scoped, race-checked snapshot without replacing stream history. + * `failedDelegations` are durable failed tasks the live roster no longer holds + * (ended, or a renderer reload dropped them): they land as terminal failed rows + * unless a live row already covers that task or a turn already retired it. */ +export function reconcileSubagentSnapshot( + sid: string, + children: SubagentPayload[], + failedDelegations: SubagentPayload[] = [] +) { const map = $subagentsBySession.get() const previous = getSubagentsForSession(map, sid) ?? [] const ids = new Set(children.map(p => str(p.subagent_id)).filter(Boolean)) @@ -279,6 +288,31 @@ export function reconcileSubagentSnapshot(sid: string, children: SubagentPayload } } + for (const payload of failedDelegations) { + const id = failedDelegationId(payload) + const delegationId = str(payload.delegation_id) + const taskIndex = num(payload.task_index) ?? 0 + + if ( + !delegationId || + retiredSubagents.get(previous)?.has(id) || + next.some(item => item.id === id || (item.delegationId === delegationId && item.taskIndex === taskIndex)) + ) { + continue + } + + const at = (num(payload.completed_at) ?? 0) * 1000 || Date.now() + const startedAt = (num(payload.dispatched_at) ?? 0) * 1000 || at + + next.push({ + ...toProgress({ ...payload, subagent_id: id, summary: str(payload.error) }, undefined, 'subagent.complete'), + durationSeconds: Math.max(0, Math.round((at - startedAt) / 1000)), + id, + startedAt, + updatedAt: at + }) + } + if (next.length !== previous.length || next.some((item, index) => item !== previous[index])) { setSessionSubagents(sid, previous, next) }