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:
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user