Skip to content

Commit fd42b0c

Browse files
authored
fix(agent-teams): keep long-running teams recoverable (#1426)
Long Agent Teams runs lost members for good: a truncated provider stream ended a member's turn with nobody to wake it, the desktop Stop button and every lead restart killed all members and marked the plan interrupted, mail sent to a stopped member landed in an inbox nothing read, and a lead kept inside one long turn never saw member reports. Aligned with the official CLI 2.1.284 and verified with DeepSeek Flash through a fault-injecting proxy. Stream recovery - Re-send a stream that breaks before any tool ran (proxy truncation, transport errors), with the existing retry budget and backoff; the desktop drops the discarded attempt's tool cards and todo update. Desktop team runtime (teamPlanRuntime) - The server supervises members: a stopped member restarts from its own transcript when messaged; transient failures continue automatically (15s/45s/2m/5m/10m) and only exhausted retries reach the lead; ready dependent tasks wake their owner; a crash-loop guard ignores user stops. - Stop pauses the team instead of ending it; the lead's next user message is followed by a notice listing the stopped members and their open tasks. Lead restarts (model/permission switch, crash) keep members; server restarts re-own the team. Teams end on /clear or session delete. - Approving a plan no longer races a concurrent plan read into "Launch ownership was lost". Mailbox and messaging - Atomic inbox writes, identity-based read marking, read history files, idle notifications with result/failureReason, and write failures reported instead of "Message sent". External builds keep the official between-turn delivery to the lead. - SendMessage resumes non-running in-process teammates from their transcript, notes restarting desktop members, queues mail for members of a plan awaiting approval, and rejects unknown names. CLI in-process teammates - Compaction uses the teammate's own controller and real history and no longer kills it on error; failed turns are classified and continued; the turn-end mailbox drains as one batch; one durable transcript per teammate. Lead behaviour - An unmet /goal ends the lead turn while members work, so member reports arrive; WaitSessions on own team members returns immediately. Desktop UI - Member states for stopped, auto-retrying and failed, with reason, countdown and recovery hint in all five locales. Tests and tooling - Regression tests for every behaviour above; module mocks in four test files are restored after use so the single-process coverage run is not polluted; the desktop smoke asserts the new Stop semantics.
1 parent 6d8071b commit fd42b0c

104 files changed

Lines changed: 9886 additions & 1060 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,49 @@ describe('AgentTeamsCanvas', () => {
401401
const lead = screen.queryByTestId('agent-teams-canvas-member-model-team-lead@canvas-team')
402402
expect(lead).toBeNull()
403403
})
404+
405+
it('labels stopped, retrying and failed members and keeps their failure on hover', () => {
406+
const current = snapshot('current')
407+
const recovering: TeamWorkbenchSnapshot = {
408+
...current,
409+
team: {
410+
...current.team,
411+
members: current.team.members.map(member => (
412+
member.agentId === 'builder@canvas-team'
413+
? {
414+
...member,
415+
status: 'idle' as const,
416+
activity: 'idle' as const,
417+
lastError: 'API Error: 529 overloaded',
418+
autoRetry: { attempt: 2, max: 5, nextAt: Date.parse('2026-08-12T02:30:45.000Z') },
419+
}
420+
: member.agentId === 'reviewer@canvas-team'
421+
? { ...member, status: 'idle' as const, activity: 'stopped' as const }
422+
: member.agentId === 'qa@canvas-team'
423+
? { ...member, status: 'error' as const, activity: 'idle' as const, lastError: 'Credit balance is too low' }
424+
: member
425+
)),
426+
},
427+
}
428+
429+
render(<AgentTeamsCanvas {...props({
430+
snapshots: [recovering],
431+
selectedIndex: 0,
432+
snapshot: recovering,
433+
previousSnapshot: undefined,
434+
activeMessageId: null,
435+
})} />)
436+
437+
const builder = screen.getByTestId('agent-teams-canvas-member-builder@canvas-team')
438+
expect(builder.getAttribute('data-member-state')).toBe('retrying')
439+
expect(screen.getByText('Auto-retry 2/5').getAttribute('title')).toBe('API Error: 529 overloaded')
440+
441+
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@canvas-team')
442+
expect(reviewer.getAttribute('data-member-state')).toBe('stopped')
443+
expect(reviewer.textContent).toContain('Stopped')
444+
445+
const qa = screen.getByTestId('agent-teams-canvas-member-qa@canvas-team')
446+
expect(qa.getAttribute('data-member-state')).toBe('error')
447+
expect(screen.getByText('Error').getAttribute('title')).toBe('Credit balance is too low')
448+
})
404449
})

‎desktop/src/components/agentTeams/AgentTeamsCanvas.tsx‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,24 @@ function memberStatusColor(state: MemberWorkState): string {
117117
return 'var(--color-text-secondary)'
118118
}
119119

120+
function memberDotColor(state: MemberWorkState, accent: string): string {
121+
if (state === 'working') return accent
122+
// The raw warning accent is too light for label text, but reads as a fill.
123+
if (state === 'retrying') return 'var(--color-warning)'
124+
return memberStatusColor(state)
125+
}
126+
120127
function taskStateLabel(state: WorkbenchTaskState, t: TranslationFn): string {
121128
return t(`agentTeams.task.${state}` as TranslationKey)
122129
}
123130

124-
function memberStateLabel(state: MemberWorkState, t: TranslationFn): string {
131+
function memberStateLabel(state: MemberWorkState, member: TeamMember, t: TranslationFn): string {
132+
if (state === 'retrying') {
133+
return t('agentTeams.member.retrying', {
134+
attempt: member.autoRetry?.attempt ?? '?',
135+
max: member.autoRetry?.max ?? '?',
136+
})
137+
}
125138
return t(`agentTeams.member.${state}` as TranslationKey)
126139
}
127140

@@ -466,10 +479,10 @@ function MemberNode({
466479
: waitingDependency
467480
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
468481
: t('agentTeams.member.waitingForTask')
469-
: memberStateLabel(state, t)
482+
: memberStateLabel(state, member, t)
470483
const characterClass = state === 'working'
471484
? 'agent-teams-character-working'
472-
: state === 'idle'
485+
: state === 'idle' || state === 'retrying'
473486
? 'agent-teams-character-idle'
474487
: state === 'exited'
475488
? 'agent-teams-character-archived'
@@ -547,10 +560,15 @@ function MemberNode({
547560
{!isLead ? (
548561
<span
549562
className="h-1.5 w-1.5 shrink-0 rounded-full"
550-
style={{ backgroundColor: state === 'working' ? accent : memberStatusColor(state) }}
563+
style={{ backgroundColor: memberDotColor(state, accent) }}
551564
/>
552565
) : null}
553-
<span className="truncate">{stateLabel}</span>
566+
<span
567+
className="truncate"
568+
title={state === 'stopped' || state === 'retrying' || state === 'error' ? member.lastError : undefined}
569+
>
570+
{stateLabel}
571+
</span>
554572
{isLead ? (
555573
<span className="shrink-0 text-[var(--color-text-tertiary)]">
556574
· {t('agentTeams.member.inbox', { count: position.inbox })}

‎desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,141 @@ describe('AgentTeamsMemberInspector', () => {
238238
expect(cell.textContent).toBe('Unknown')
239239
expect(cell.textContent).not.toContain('undefined')
240240
})
241+
242+
describe('recovery states', () => {
243+
const now = Date.parse('2026-08-08T07:00:00.000Z')
244+
245+
function renderMember(member: TeamMember, clock?: number) {
246+
const base = snapshot('2026-08-08T07:00:00.000Z', 'in_progress')
247+
const frame: TeamWorkbenchSnapshot = {
248+
...base,
249+
team: { ...base.team, members: [member, reviewer] },
250+
}
251+
return render(
252+
<AgentTeamsMemberInspector
253+
snapshots={[frame]}
254+
selectedIndex={0}
255+
snapshot={frame}
256+
member={member}
257+
isLead={false}
258+
leadIsStreaming={false}
259+
now={clock}
260+
onBack={vi.fn()}
261+
onClose={vi.fn()}
262+
onOpenExecution={vi.fn()}
263+
/>,
264+
)
265+
}
266+
267+
it('says a stopped member resumes from its saved conversation when messaged', () => {
268+
const onOpenExecution = vi.fn()
269+
const frame = snapshot('2026-08-08T07:00:00.000Z', 'in_progress')
270+
const stopped: TeamMember = { ...builder, status: 'idle', activity: 'stopped' }
271+
render(
272+
<AgentTeamsMemberInspector
273+
snapshots={[frame]}
274+
selectedIndex={0}
275+
snapshot={frame}
276+
member={stopped}
277+
isLead={false}
278+
leadIsStreaming={false}
279+
onBack={vi.fn()}
280+
onClose={vi.fn()}
281+
onOpenExecution={onOpenExecution}
282+
/>,
283+
)
284+
285+
expect(screen.getByText('Stopped')).toBeTruthy()
286+
const notice = screen.getByTestId('agent-teams-member-recovery')
287+
expect(notice.getAttribute('data-member-state')).toBe('stopped')
288+
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
289+
.toBe('Send a message to resume it from its saved conversation')
290+
expect(screen.queryByTestId('agent-teams-member-last-error')).toBeNull()
291+
292+
// The way back is the member's own conversation, where the composer lives.
293+
const execution = screen.getByRole('button', { name: 'View builder execution' }) as HTMLButtonElement
294+
expect(execution.disabled).toBe(false)
295+
fireEvent.click(execution)
296+
expect(onOpenExecution).toHaveBeenCalledOnce()
297+
})
298+
299+
it('counts down to the next automatic retry and names the failure behind it', () => {
300+
const retrying: TeamMember = {
301+
...builder,
302+
status: 'idle',
303+
activity: 'idle',
304+
lastError: 'API Error: 529 overloaded',
305+
autoRetry: { attempt: 2, max: 5, nextAt: now + 45_000 },
306+
}
307+
const view = renderMember(retrying, now)
308+
309+
expect(screen.getByText('Auto-retry 2/5')).toBeTruthy()
310+
expect(screen.getByTestId('agent-teams-member-recovery').getAttribute('data-member-state'))
311+
.toBe('retrying')
312+
expect(screen.getByTestId('agent-teams-member-last-error').textContent)
313+
.toBe('API Error: 529 overloaded')
314+
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
315+
.toBe('Retrying automatically in 45s')
316+
317+
view.rerender(
318+
<AgentTeamsMemberInspector
319+
snapshots={[]}
320+
selectedIndex={0}
321+
snapshot={snapshot('2026-08-08T07:00:00.000Z', 'in_progress')}
322+
member={{ ...retrying, autoRetry: { attempt: 3, max: 5, nextAt: now + 125_000 } }}
323+
isLead={false}
324+
leadIsStreaming={false}
325+
now={now}
326+
onBack={vi.fn()}
327+
onClose={vi.fn()}
328+
onOpenExecution={vi.fn()}
329+
/>,
330+
)
331+
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
332+
.toBe('Retrying automatically in about 2 min')
333+
view.unmount()
334+
335+
// Past its schedule, the retry is due rather than counting negative.
336+
renderMember(retrying, now + 46_000)
337+
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
338+
.toBe('Retrying automatically now')
339+
})
340+
341+
it('shows no countdown while replaying, where the present clock means nothing', () => {
342+
renderMember({
343+
...builder,
344+
status: 'idle',
345+
activity: 'idle',
346+
lastError: 'API Error: 529 overloaded',
347+
autoRetry: { attempt: 1, max: 5, nextAt: now + 15_000 },
348+
})
349+
350+
expect(screen.getByText('Auto-retry 1/5')).toBeTruthy()
351+
expect(screen.getByTestId('agent-teams-member-last-error').textContent)
352+
.toBe('API Error: 529 overloaded')
353+
expect(screen.queryByTestId('agent-teams-member-recovery-hint')).toBeNull()
354+
})
355+
356+
it('keeps a long failure reason on one line with the full text on hover', () => {
357+
const reason = 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 215000 tokens > 200000 maximum"}}'
358+
renderMember({ ...builder, status: 'error', activity: 'idle', lastError: reason })
359+
360+
expect(screen.getByText('Error')).toBeTruthy()
361+
expect(screen.getByTestId('agent-teams-member-recovery').getAttribute('data-member-state'))
362+
.toBe('error')
363+
const lastError = screen.getByTestId('agent-teams-member-last-error')
364+
expect(lastError.textContent).toBe(reason)
365+
expect(lastError.getAttribute('title')).toBe(reason)
366+
expect(lastError.className).toContain('truncate')
367+
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
368+
.toBe('Once the problem is fixed, send a message to let it continue')
369+
})
370+
371+
it('drops the failure notice while a failed member works its way back', () => {
372+
renderMember({ ...builder, status: 'error', activity: 'active', lastError: 'Credit balance is too low' })
373+
374+
expect(screen.getByText('Executing #7')).toBeTruthy()
375+
expect(screen.queryByTestId('agent-teams-member-recovery')).toBeNull()
376+
})
377+
})
241378
})

‎desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { IconButton } from '@/components/ui/IconButton'
2323
import { useTranslation, type TranslationKey } from '@/i18n'
2424
import type {
2525
TeamMember,
26+
TeamMemberAutoRetry,
2627
TeamWorkbenchMessage,
2728
TeamWorkbenchSnapshot,
2829
TeamWorkbenchTask,
@@ -35,6 +36,11 @@ export type AgentTeamsMemberInspectorProps = {
3536
member: TeamMember
3637
isLead: boolean
3738
leadIsStreaming: boolean
39+
/**
40+
* The live clock, for the time left until an automatic retry. Omitted while
41+
* replaying, where a countdown from the present would describe nothing.
42+
*/
43+
now?: number
3844
onBack: () => void
3945
onClose: () => void
4046
onOpenExecution: () => void
@@ -190,10 +196,25 @@ function taskTone(state: WorkbenchTaskState): Tone {
190196
function memberTone(state: MemberWorkState): Tone {
191197
if (state === 'working') return 'brand'
192198
if (state === 'error') return 'danger'
199+
if (state === 'retrying') return 'warning'
193200
if (state === 'exited' || state === 'stopped') return 'neutral'
194201
return 'info'
195202
}
196203

204+
/** Container and foreground pairs, the same ones `Badge` uses for these tones. */
205+
function recoveryNoticeClasses(state: MemberWorkState): string {
206+
if (state === 'error') return 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
207+
if (state === 'retrying') return 'bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
208+
return 'bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]'
209+
}
210+
211+
function retryCountdown(autoRetry: TeamMemberAutoRetry, now: number, t: TranslationFn): string {
212+
const seconds = Math.ceil((autoRetry.nextAt - now) / 1000)
213+
if (seconds <= 0) return t('agentTeams.member.retryNow')
214+
if (seconds < 60) return t('agentTeams.member.retryInSeconds', { n: seconds })
215+
return t('agentTeams.member.retryInMinutes', { n: Math.round(seconds / 60) })
216+
}
217+
197218
function leadStatusLabel(snapshot: TeamWorkbenchSnapshot, t: TranslationFn): string {
198219
if (snapshot.deletedAt) return t('agentTeams.lead.archived')
199220
if (snapshot.tasks.length === 0) return t('agentTeams.lead.forming')
@@ -283,6 +304,7 @@ export function AgentTeamsMemberInspector({
283304
member,
284305
isLead,
285306
leadIsStreaming,
307+
now,
286308
onBack,
287309
onClose,
288310
onOpenExecution,
@@ -324,7 +346,27 @@ export function AgentTeamsMemberInspector({
324346
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
325347
: workState === 'idle'
326348
? t('agentTeams.member.waitingForTask')
327-
: t(`agentTeams.member.${workState}` as TranslationKey)
349+
: workState === 'retrying'
350+
? t('agentTeams.member.retrying', {
351+
attempt: member.autoRetry?.attempt ?? '?',
352+
max: member.autoRetry?.max ?? '?',
353+
})
354+
: t(`agentTeams.member.${workState}` as TranslationKey)
355+
// Stopped, retrying and failed members all come back on their own or through
356+
// a message; say why they paused and what brings them back.
357+
const awaitsRecovery = !isLead && (
358+
workState === 'stopped' || workState === 'retrying' || workState === 'error'
359+
)
360+
const failureReason = awaitsRecovery ? member.lastError : undefined
361+
const recoveryHint = !awaitsRecovery
362+
? undefined
363+
: workState === 'stopped'
364+
? t('agentTeams.member.stoppedHint')
365+
: workState === 'error'
366+
? t('agentTeams.member.errorHint')
367+
: member.autoRetry && now !== undefined
368+
? retryCountdown(member.autoRetry, now, t)
369+
: undefined
328370

329371
return (
330372
<section
@@ -424,6 +466,32 @@ export function AgentTeamsMemberInspector({
424466
<dd className="mt-0.5 truncate font-extrabold" data-testid="agent-teams-member-provider">{member.providerName || member.providerId || t('teamPlan.official')}</dd>
425467
</div>}
426468
</dl>
469+
470+
{failureReason || recoveryHint ? (
471+
<div
472+
data-testid="agent-teams-member-recovery"
473+
data-member-state={workState}
474+
className={`mt-3 min-w-0 rounded-[var(--radius-md)] px-2.5 py-2 text-[11px] leading-[1.45] ${recoveryNoticeClasses(workState)}`}
475+
>
476+
{failureReason ? (
477+
<p className="flex min-w-0 items-baseline gap-1.5">
478+
<span className="shrink-0 font-semibold">{t('agentTeams.inspector.failureReason')}</span>
479+
<span
480+
data-testid="agent-teams-member-last-error"
481+
className="min-w-0 truncate font-mono"
482+
title={failureReason}
483+
>
484+
{failureReason}
485+
</span>
486+
</p>
487+
) : null}
488+
{recoveryHint ? (
489+
<p data-testid="agent-teams-member-recovery-hint" className={failureReason ? 'mt-0.5' : undefined}>
490+
{recoveryHint}
491+
</p>
492+
) : null}
493+
</div>
494+
) : null}
427495
</header>
428496

429497
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">

‎desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
518518
member={selectedMember}
519519
isLead={selectedMemberIsLead}
520520
leadIsStreaming={leadIsStreaming}
521+
now={followingLive ? now : undefined}
521522
onBack={() => setSelectedMemberId(null)}
522523
onClose={closeCommunication}
523524
onOpenExecution={openSelectedExecution}

0 commit comments

Comments
 (0)