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.
This commit is contained in:
Hermes Agent
2026-09-25 01:21:15 -05:00
committed by brooklyn!
parent 4153869e6e
commit c488b42a82
4 changed files with 97 additions and 14 deletions

View File

@@ -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<SubagentStreamEntry['kind'], string> = {
}
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 <AlertCircle aria-hidden className="mt-0.5 size-3 shrink-0 text-destructive" />
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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden">
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p>
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">
{summary.map((part, index) => (
<Fragment key={part}>
{index > 0 ? ' · ' : null}
{part === failedLabel ? <span className="text-destructive">{part}</span> : part}
</Fragment>
))}
</p>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
<div className="flex min-w-0 flex-col gap-6">
{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 (
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}>
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
{glyph ? <span className="flex h-[0.95rem] shrink-0 items-center">{glyph}</span> : null}
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
{entry.text}
{active ? (

View File

@@ -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

View File

@@ -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 })

View File

@@ -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)
}