feat(team): clarify teammate check-ins and retry state

This commit is contained in:
iliya 2026-04-07 10:28:00 +03:00
parent cb9eb5f701
commit c20fc1a312
13 changed files with 463 additions and 30 deletions

View file

@ -85,6 +85,7 @@ import {
writeTeamControlApiState, writeTeamControlApiState,
} from './services/team/TeamControlApiState'; } from './services/team/TeamControlApiState';
import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamInboxReader } from './services/team/TeamInboxReader';
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { getAppIconPath } from './utils/appIcon'; import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
@ -756,7 +757,12 @@ async function initializeServices(): Promise<void> {
updaterService = new UpdaterService(); updaterService = new UpdaterService();
cliInstallerService = new CliInstallerService(); cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
teamMemberLogsFinder
);
teamDataService = new TeamDataService(); teamDataService = new TeamDataService();
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService(); teamProvisioningService = new TeamProvisioningService();
// Startup GC: remove stale MCP config files from previous sessions (best-effort) // Startup GC: remove stale MCP config files from previous sessions (best-effort)
void new TeamMcpConfigBuilder().gcStaleConfigs(); void new TeamMcpConfigBuilder().gcStaleConfigs();
@ -786,7 +792,6 @@ async function initializeServices(): Promise<void> {
); );
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
let teammateToolTracker: TeammateToolTracker | null = null; let teammateToolTracker: TeammateToolTracker | null = null;

View file

@ -1478,6 +1478,14 @@ function buildMessageDeliveryText(
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
...(replyRecipient === 'user'
? [
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
'Only after that first acknowledgement may you message the lead or another teammate.',
'After you get the needed information, send the final answer back to "user".',
'Do NOT stay silent while you go ask someone else.',
]
: []),
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
].join('\n') ].join('\n')
); );

View file

@ -382,7 +382,7 @@ function normalizeBootstrapMemberState(
: runtimeAlive : runtimeAlive
? bootstrapConfirmed ? bootstrapConfirmed
? ['late heartbeat received'] ? ['late heartbeat received']
: ['runtime alive', 'waiting for bootstrap'] : ['runtime alive', 'waiting for teammate check-in']
: agentToolAccepted : agentToolAccepted
? ['spawn accepted'] ? ['spawn accepted']
: undefined, : undefined,

View file

@ -38,6 +38,7 @@ import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore'; import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
@ -131,13 +132,18 @@ export class TeamDataService {
claudeDir: getClaudeBasePath(), claudeDir: getClaudeBasePath(),
}), }),
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(), private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService()
) {} ) {}
private getController(teamName: string): AgentTeamsController { private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName); return this.controllerFactory(teamName);
} }
setMemberRuntimeAdvisoryService(service: TeamMemberRuntimeAdvisoryService): void {
this.memberRuntimeAdvisoryService = service;
}
private getTaskLabel(task: Pick<TeamTask, 'id' | 'displayId'>): string { private getTaskLabel(task: Pick<TeamTask, 'id' | 'displayId'>): string {
return formatTaskDisplayLabel(task); return formatTaskDisplayLabel(task);
} }
@ -754,6 +760,22 @@ export class TeamDataService {
); );
mark('resolveMembers'); mark('resolveMembers');
try {
const runtimeAdvisories = await this.memberRuntimeAdvisoryService.getMemberAdvisories(
teamName,
members
);
for (const member of members) {
const advisory = runtimeAdvisories.get(member.name);
if (advisory) {
member.runtimeAdvisory = advisory;
}
}
} catch {
warnings.push('Member runtime advisories failed to load');
}
mark('runtimeAdvisories');
// Enrich members with git branch when it differs from lead's branch // Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config); await this.enrichMemberBranches(members, config);
mark('enrichBranches'); mark('enrichBranches');
@ -777,7 +799,9 @@ export class TeamDataService {
'sentMessages' 'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc' 'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( )} resolveMembers=${msSince('resolveMembers')} runtimeAdvisories=${msSince(
'runtimeAdvisories'
)} enrichBranches=${msSince(
'enrichBranches' 'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}` )} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
); );

View file

@ -52,7 +52,8 @@ function buildDiagnostics(
if (member.agentToolAccepted) diagnostics.push('spawn accepted'); if (member.agentToolAccepted) diagnostics.push('spawn accepted');
if (member.runtimeAlive) diagnostics.push('runtime alive'); if (member.runtimeAlive) diagnostics.push('runtime alive');
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
if (member.runtimeAlive && !member.bootstrapConfirmed) diagnostics.push('waiting for bootstrap'); if (member.runtimeAlive && !member.bootstrapConfirmed)
diagnostics.push('waiting for teammate check-in');
if (member.hardFailureReason) if (member.hardFailureReason)
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');

View file

@ -0,0 +1,178 @@
import * as fs from 'fs/promises';
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
const LOOKBACK_MS = 10 * 60 * 1000;
const CACHE_TTL_MS = 5_000;
const TAIL_BYTES = 64 * 1024;
interface CachedRuntimeAdvisory {
value: MemberRuntimeAdvisory | null;
expiresAt: number;
}
export class TeamMemberRuntimeAdvisoryService {
private readonly cache = new Map<string, CachedRuntimeAdvisory>();
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
async getMemberAdvisories(
teamName: string,
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
): Promise<Map<string, MemberRuntimeAdvisory>> {
const advisoryEntries = await Promise.all(
members
.filter((member) => !member.removedAt)
.map(async (member) => {
const advisory = await this.getMemberAdvisory(teamName, member.name);
return advisory ? ([member.name, advisory] as const) : null;
})
);
return new Map(
advisoryEntries.filter(
(entry): entry is readonly [string, MemberRuntimeAdvisory] => entry !== null
)
);
}
async getMemberAdvisory(
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const cacheKey = `${teamName.toLowerCase()}::${memberName.toLowerCase()}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
this.cache.set(cacheKey, {
value: advisory,
expiresAt: Date.now() + CACHE_TTL_MS,
});
return advisory;
}
private async findRecentMemberAdvisory(
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const summaries = await this.logsFinder.findMemberLogs(
teamName,
memberName,
Date.now() - LOOKBACK_MS
);
for (const summary of summaries) {
if (!summary.filePath) {
continue;
}
const advisory = await this.readRecentApiRetryAdvisory(summary.filePath);
if (advisory) {
return advisory;
}
}
return null;
}
private async readRecentApiRetryAdvisory(
filePath: string
): Promise<MemberRuntimeAdvisory | null> {
let handle: fs.FileHandle | null = null;
try {
handle = await fs.open(filePath, 'r');
const stat = await handle.stat();
if (!stat.isFile() || stat.size <= 0) {
return null;
}
const start = Math.max(0, stat.size - TAIL_BYTES);
const buffer = Buffer.alloc(stat.size - start);
if (buffer.length === 0) {
return null;
}
await handle.read(buffer, 0, buffer.length, start);
const tail = buffer.toString('utf8');
const lines = tail.split('\n');
if (start > 0) {
lines.shift();
}
for (let index = lines.length - 1; index >= 0; index -= 1) {
const advisory = this.extractApiRetryAdvisory(lines[index]?.trim() ?? '');
if (advisory) {
return advisory;
}
}
return null;
} catch {
return null;
} finally {
await handle?.close().catch(() => {});
}
}
private extractApiRetryAdvisory(line: string): MemberRuntimeAdvisory | null {
if (
!line ||
(!line.includes('"subtype":"api_error"') && !line.includes('"subtype": "api_error"'))
) {
return null;
}
try {
const parsed = JSON.parse(line) as {
type?: string;
subtype?: string;
retryInMs?: number;
timestamp?: string;
error?: {
message?: string;
error?: {
message?: string;
error?: {
message?: string;
};
};
};
};
if (parsed.type !== 'system' || parsed.subtype !== 'api_error') {
return null;
}
const retryInMs =
typeof parsed.retryInMs === 'number' &&
Number.isFinite(parsed.retryInMs) &&
parsed.retryInMs > 0
? parsed.retryInMs
: null;
const observedAt =
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
if (!retryInMs || !Number.isFinite(observedAt)) {
return null;
}
const retryUntil = observedAt + retryInMs;
if (retryUntil <= Date.now()) {
return null;
}
const message =
parsed.error?.error?.error?.message?.trim() ||
parsed.error?.error?.message?.trim() ||
parsed.error?.message?.trim() ||
undefined;
return {
kind: 'sdk_retrying',
observedAt: new Date(observedAt).toISOString(),
retryUntil: new Date(retryUntil).toISOString(),
retryDelayMs: retryInMs,
...(message ? { message } : {}),
};
} catch {
return null;
}
}
}

View file

@ -1747,9 +1747,9 @@ function buildGeminiPostLaunchHydrationPrompt(
: status?.launchState === 'confirmed_alive' : status?.launchState === 'confirmed_alive'
? 'bootstrap confirmed' ? 'bootstrap confirmed'
: status?.runtimeAlive : status?.runtimeAlive
? 'runtime started, bootstrap pending' ? 'runtime started, check-in pending'
: status?.launchState === 'runtime_pending_bootstrap' : status?.launchState === 'runtime_pending_bootstrap'
? 'spawn accepted, bootstrap pending' ? 'spawn accepted, check-in pending'
: status?.status === 'spawning' : status?.status === 'spawning'
? 'spawn in progress' ? 'spawn in progress'
: 'runtime state unclear'; : 'runtime state unclear';
@ -3004,7 +3004,7 @@ export class TeamProvisioningService {
const detail = const detail =
parsedStatus.reason === 'already_running' parsedStatus.reason === 'already_running'
? 'duplicate spawn skipped - already running' ? 'duplicate spawn skipped - already running'
: 'duplicate spawn skipped - bootstrap pending'; : 'duplicate spawn skipped - check-in pending';
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail); this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
return; return;
} }
@ -3156,7 +3156,7 @@ export class TeamProvisioningService {
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
run, run,
memberName, memberName,
'spawn accepted, waiting for bootstrap' 'spawn accepted, waiting for teammate check-in'
); );
} else if (status === 'online' && livenessSource === 'heartbeat' && !prev.bootstrapConfirmed) { } else if (status === 'online' && livenessSource === 'heartbeat' && !prev.bootstrapConfirmed) {
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
@ -3168,7 +3168,7 @@ export class TeamProvisioningService {
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
run, run,
memberName, memberName,
'runtime process is alive, bootstrap not yet confirmed' 'runtime process is alive, teammate check-in not yet received'
); );
} else if (status === 'error') { } else if (status === 'error') {
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
@ -6097,7 +6097,7 @@ export class TeamProvisioningService {
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
run, run,
memberName, memberName,
'respawn blocked as duplicate — teammate already alive or bootstrap pending' 'respawn blocked as duplicate — teammate already alive or check-in pending'
); );
continue; continue;
} }
@ -6369,13 +6369,13 @@ export class TeamProvisioningService {
launchSummary.runtimeAlivePendingCount > 0 && launchSummary.runtimeAlivePendingCount > 0 &&
launchSummary.runtimeAlivePendingCount === run.expectedMembers.length; launchSummary.runtimeAlivePendingCount === run.expectedMembers.length;
return allRuntimeAlive return allRuntimeAlive
? `${prefix} — teammate runtimes started, waiting for bootstrap confirmation` ? `${prefix} — teammate runtimes online, waiting for check-ins`
: launchSummary.runtimeAlivePendingCount > 0 : launchSummary.runtimeAlivePendingCount > 0
? `${prefix}${launchSummary.runtimeAlivePendingCount}/${run.expectedMembers.length} teammate runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} started${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}, waiting for bootstrap confirmation` ? `${prefix}${launchSummary.runtimeAlivePendingCount}/${run.expectedMembers.length} teammate runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}, waiting for check-ins`
: `${prefix} — teammates are still starting, waiting for bootstrap confirmation`; : `${prefix} — teammates are still starting, waiting for check-ins`;
} }
return `${prefix}${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} waiting for bootstrap confirmation` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining` : ''}`; return `${prefix}${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates checked in${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} waiting for check-in` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining` : ''}`;
} }
private buildRuntimeSpawnStatusRecord( private buildRuntimeSpawnStatusRecord(

View file

@ -152,12 +152,12 @@ export const TeamProvisioningBanner = ({
: fallbackTeammateCount === 0 : fallbackTeammateCount === 0
? 'Team provisioned — lead online' ? 'Team provisioned — lead online'
: allTeammatesConfirmedAlive : allTeammatesConfirmedAlive
? `Team provisioned — all ${fallbackTeammateCount} teammates confirmed alive` ? `Team provisioned — all ${fallbackTeammateCount} teammates checked in`
: allPendingRuntimesStarted : allPendingRuntimesStarted
? 'Team provisioned — teammate runtimes started, waiting for bootstrap confirmation' ? 'Team provisioned — teammate runtimes online, waiting for check-ins'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0 : processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` ? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates checked in${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} online and waiting for check-in` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team provisioned — teammate liveness is still being confirmed'; : 'Team provisioned — teammate check-ins are still coming in';
const readyDetailSeverity = const readyDetailSeverity =
failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0 failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0
? 'warning' ? 'warning'
@ -168,12 +168,12 @@ export const TeamProvisioningBanner = ({
: fallbackTeammateCount === 0 : fallbackTeammateCount === 0
? 'Team launched — lead online' ? 'Team launched — lead online'
: allTeammatesConfirmedAlive : allTeammatesConfirmedAlive
? `Team launched — all ${fallbackTeammateCount} teammates confirmed alive` ? `Team launched — all ${fallbackTeammateCount} teammates checked in`
: allPendingRuntimesStarted : allPendingRuntimesStarted
? 'Team launched — teammate runtimes started, waiting for bootstrap confirmation' ? 'Team launched — teammate runtimes online, waiting for check-ins'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0 : processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` ? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates checked in${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} online and waiting for check-in` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team launched — teammate liveness is still being confirmed'; : 'Team launched — teammate check-ins are still coming in';
return ( return (
<div className="mb-3"> <div className="mb-3">

View file

@ -7,6 +7,8 @@ import {
agentAvatarUrl, agentAvatarUrl,
buildMemberColorMap, buildMemberColorMap,
displayMemberName, displayMemberName,
getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
@ -79,6 +81,8 @@ export const PendingRepliesBlock = ({
const roleLabel = formatAgentRole( const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
); );
const advisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
const advisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
return ( return (
<article <article
@ -137,9 +141,9 @@ export const PendingRepliesBlock = ({
<span <span
className="min-w-0 flex-1 truncate text-[10px]" className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }} style={{ color: CARD_ICON_MUTED }}
title="Message sent, awaiting reply" title={advisoryTitle ?? 'Message sent, awaiting reply'}
> >
awaiting reply {advisoryLabel ?? 'awaiting reply'}
</span> </span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{since} {since}

View file

@ -6,6 +6,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl, agentAvatarUrl,
displayMemberName, displayMemberName,
getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle,
getSpawnAwareDotClass, getSpawnAwareDotClass,
getSpawnAwarePresenceLabel, getSpawnAwarePresenceLabel,
getSpawnCardClass, getSpawnCardClass,
@ -85,6 +87,8 @@ export const MemberCard = ({
isTeamProvisioning, isTeamProvisioning,
leadActivity leadActivity
); );
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
const presenceLabel = getSpawnAwarePresenceLabel( const presenceLabel = getSpawnAwarePresenceLabel(
member, member,
spawnStatus, spawnStatus,
@ -178,8 +182,11 @@ export const MemberCard = ({
className="size-3 shrink-0 animate-spin" className="size-3 shrink-0 animate-spin"
style={{ color: colors.border }} style={{ color: colors.border }}
/> />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> <span
awaiting reply className="shrink-0 text-[10px] text-[var(--color-text-muted)]"
title={runtimeAdvisoryTitle ?? 'Message sent, awaiting reply'}
>
{runtimeAdvisoryLabel ?? 'awaiting reply'}
</span> </span>
</> </>
) : null} ) : null}

View file

@ -8,6 +8,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type { import type {
LeadActivityState, LeadActivityState,
MemberLaunchState, MemberLaunchState,
MemberRuntimeAdvisory,
MemberSpawnLivenessSource, MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
MemberStatus, MemberStatus,
@ -100,7 +101,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = { export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
offline: 'offline', offline: 'offline',
waiting: 'bootstrap pending', waiting: 'check-in pending',
spawning: 'starting', spawning: 'starting',
online: 'ready', online: 'ready',
error: 'spawn failed', error: 'spawn failed',
@ -159,13 +160,13 @@ export function getSpawnAwarePresenceLabel(
return 'waiting for Agent'; return 'waiting for Agent';
} }
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) { if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return 'bootstrap pending'; return 'check-in pending';
} }
if (spawnStatus === 'waiting') { if (spawnStatus === 'waiting') {
return SPAWN_PRESENCE_LABELS.waiting; return SPAWN_PRESENCE_LABELS.waiting;
} }
if (spawnStatus === 'online' && livenessSource === 'process') { if (spawnStatus === 'online' && livenessSource === 'process') {
return 'bootstrap pending'; return 'check-in pending';
} }
if (spawnStatus && isTeamProvisioning) { if (spawnStatus && isTeamProvisioning) {
return SPAWN_PRESENCE_LABELS[spawnStatus]; return SPAWN_PRESENCE_LABELS[spawnStatus];
@ -194,6 +195,43 @@ export function getSpawnCardClass(spawnStatus: MemberSpawnStatus | undefined): s
} }
} }
function formatRetryCountdown(ms: number): string {
const totalSeconds = Math.max(1, Math.ceil(ms / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
export function getMemberRuntimeAdvisoryLabel(
advisory: MemberRuntimeAdvisory | undefined,
nowMs = Date.now()
): string | null {
if (!advisory || advisory.kind !== 'sdk_retrying') {
return null;
}
const retryUntilMs = Date.parse(advisory.retryUntil);
if (!Number.isFinite(retryUntilMs)) {
return 'SDK retrying';
}
const remainingMs = retryUntilMs - nowMs;
if (remainingMs <= 0) {
return 'SDK retrying';
}
return `SDK retrying · ${formatRetryCountdown(remainingMs)}`;
}
export function getMemberRuntimeAdvisoryTitle(
advisory: MemberRuntimeAdvisory | undefined
): string | undefined {
if (!advisory || advisory.kind !== 'sdk_retrying') {
return undefined;
}
return advisory.message?.trim() || 'The SDK is retrying after a provider error.';
}
export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = { export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' }, pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },

View file

@ -497,9 +497,18 @@ export interface ResolvedTeamMember {
cwd?: string; cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */ /** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string; gitBranch?: string;
runtimeAdvisory?: MemberRuntimeAdvisory;
removedAt?: number; removedAt?: number;
} }
export interface MemberRuntimeAdvisory {
kind: 'sdk_retrying';
observedAt: string;
retryUntil: string;
retryDelayMs: number;
message?: string;
}
export interface TeamProcess { export interface TeamProcess {
id: string; id: string;
port?: number; port?: number;

View file

@ -0,0 +1,159 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
describe('TeamMemberRuntimeAdvisoryService', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('returns active sdk retry advisory for a teammate log', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'signal-ops';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const leadSessionId = 'lead-session';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
`${JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'Start' },
})}\n`,
'utf8'
);
const nowIso = new Date().toISOString();
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'),
[
JSON.stringify({
timestamp: nowIso,
type: 'user',
message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).' },
}),
JSON.stringify({
timestamp: nowIso,
type: 'system',
subtype: 'api_error',
retryInMs: 45_000,
retryAttempt: 1,
maxRetries: 10,
error: {
error: {
error: {
message: 'Gemini cli backend error: capacity exceeded.',
},
},
},
}),
].join('\n') + '\n',
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService();
const advisory = await service.getMemberAdvisory(teamName, 'alice');
expect(advisory).not.toBeNull();
expect(advisory?.kind).toBe('sdk_retrying');
expect(advisory?.message).toContain('capacity exceeded');
});
it('ignores expired retry advisories', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'signal-ops';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const leadSessionId = 'lead-session';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
`${JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'Start' },
})}\n`,
'utf8'
);
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'),
[
JSON.stringify({
timestamp: new Date(Date.now() - 60_000).toISOString(),
type: 'user',
message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).' },
}),
JSON.stringify({
timestamp: new Date(Date.now() - 60_000).toISOString(),
type: 'system',
subtype: 'api_error',
retryInMs: 5_000,
retryAttempt: 1,
maxRetries: 10,
error: {
error: {
error: {
message: 'Old retry window',
},
},
},
}),
].join('\n') + '\n',
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService();
await expect(service.getMemberAdvisory(teamName, 'alice')).resolves.toBeNull();
});
});