diff --git a/src/main/index.ts b/src/main/index.ts index 7b769c35..cd69b5d5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -85,6 +85,7 @@ import { writeTeamControlApiState, } from './services/team/TeamControlApiState'; import { TeamInboxReader } from './services/team/TeamInboxReader'; +import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; @@ -756,7 +757,12 @@ async function initializeServices(): Promise { updaterService = new UpdaterService(); cliInstallerService = new CliInstallerService(); ptyTerminalService = new PtyTerminalService(); + const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( + teamMemberLogsFinder + ); teamDataService = new TeamDataService(); + teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); // Startup GC: remove stale MCP config files from previous sessions (best-effort) void new TeamMcpConfigBuilder().gcStaleConfigs(); @@ -786,7 +792,6 @@ async function initializeServices(): Promise { ); teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); - const teamMemberLogsFinder = new TeamMemberLogsFinder(); const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); let teammateToolTracker: TeammateToolTracker | null = null; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 747c92b5..65b78257 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1478,6 +1478,14 @@ function buildMessageDeliveryText( '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.`, '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, ].join('\n') ); diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts index 04dedc5a..97aba073 100644 --- a/src/main/services/team/TeamBootstrapStateReader.ts +++ b/src/main/services/team/TeamBootstrapStateReader.ts @@ -382,7 +382,7 @@ function normalizeBootstrapMemberState( : runtimeAlive ? bootstrapConfirmed ? ['late heartbeat received'] - : ['runtime alive', 'waiting for bootstrap'] + : ['runtime alive', 'waiting for teammate check-in'] : agentToolAccepted ? ['spawn accepted'] : undefined, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 815bf8fe..e7fe4ea6 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -38,6 +38,7 @@ import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamMemberResolver } from './TeamMemberResolver'; +import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; @@ -131,13 +132,18 @@ export class TeamDataService { claudeDir: getClaudeBasePath(), }), 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 { return this.controllerFactory(teamName); } + setMemberRuntimeAdvisoryService(service: TeamMemberRuntimeAdvisoryService): void { + this.memberRuntimeAdvisoryService = service; + } + private getTaskLabel(task: Pick): string { return formatTaskDisplayLabel(task); } @@ -754,6 +760,22 @@ export class TeamDataService { ); 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 await this.enrichMemberBranches(members, config); mark('enrichBranches'); @@ -777,7 +799,9 @@ export class TeamDataService { 'sentMessages' )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( 'kanbanGc' - )} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( + )} resolveMembers=${msSince('resolveMembers')} runtimeAdvisories=${msSince( + 'runtimeAdvisories' + )} enrichBranches=${msSince( 'enrichBranches' )} syncComments=${msSince('syncComments')} processes=${msSince('processes')}` ); diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 99f8035b..14599520 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -52,7 +52,8 @@ function buildDiagnostics( if (member.agentToolAccepted) diagnostics.push('spawn accepted'); if (member.runtimeAlive) diagnostics.push('runtime alive'); 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) diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts new file mode 100644 index 00000000..e4cda49b --- /dev/null +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -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(); + + constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {} + + async getMemberAdvisories( + teamName: string, + members: readonly Pick[] + ): Promise> { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9b07b330..05c22d9e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1747,9 +1747,9 @@ function buildGeminiPostLaunchHydrationPrompt( : status?.launchState === 'confirmed_alive' ? 'bootstrap confirmed' : status?.runtimeAlive - ? 'runtime started, bootstrap pending' + ? 'runtime started, check-in pending' : status?.launchState === 'runtime_pending_bootstrap' - ? 'spawn accepted, bootstrap pending' + ? 'spawn accepted, check-in pending' : status?.status === 'spawning' ? 'spawn in progress' : 'runtime state unclear'; @@ -3004,7 +3004,7 @@ export class TeamProvisioningService { const detail = parsedStatus.reason === 'already_running' ? 'duplicate spawn skipped - already running' - : 'duplicate spawn skipped - bootstrap pending'; + : 'duplicate spawn skipped - check-in pending'; this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail); return; } @@ -3156,7 +3156,7 @@ export class TeamProvisioningService { this.appendMemberBootstrapDiagnostic( run, memberName, - 'spawn accepted, waiting for bootstrap' + 'spawn accepted, waiting for teammate check-in' ); } else if (status === 'online' && livenessSource === 'heartbeat' && !prev.bootstrapConfirmed) { this.appendMemberBootstrapDiagnostic( @@ -3168,7 +3168,7 @@ export class TeamProvisioningService { this.appendMemberBootstrapDiagnostic( run, memberName, - 'runtime process is alive, bootstrap not yet confirmed' + 'runtime process is alive, teammate check-in not yet received' ); } else if (status === 'error') { this.appendMemberBootstrapDiagnostic( @@ -6097,7 +6097,7 @@ export class TeamProvisioningService { this.appendMemberBootstrapDiagnostic( run, memberName, - 'respawn blocked as duplicate — teammate already alive or bootstrap pending' + 'respawn blocked as duplicate — teammate already alive or check-in pending' ); continue; } @@ -6369,13 +6369,13 @@ export class TeamProvisioningService { launchSummary.runtimeAlivePendingCount > 0 && launchSummary.runtimeAlivePendingCount === run.expectedMembers.length; return allRuntimeAlive - ? `${prefix} — teammate runtimes started, waiting for bootstrap confirmation` + ? `${prefix} — teammate runtimes online, waiting for check-ins` : 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} — teammates are 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 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( diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 36684490..087323be 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -152,12 +152,12 @@ export const TeamProvisioningBanner = ({ : fallbackTeammateCount === 0 ? 'Team provisioned — lead online' : allTeammatesConfirmedAlive - ? `Team provisioned — all ${fallbackTeammateCount} teammates confirmed alive` + ? `Team provisioned — all ${fallbackTeammateCount} teammates checked in` : allPendingRuntimesStarted - ? 'Team provisioned — teammate runtimes started, waiting for bootstrap confirmation' + ? 'Team provisioned — teammate runtimes online, waiting for check-ins' : 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 — teammate liveness is still being confirmed'; + ? `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 check-ins are still coming in'; const readyDetailSeverity = failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0 ? 'warning' @@ -168,12 +168,12 @@ export const TeamProvisioningBanner = ({ : fallbackTeammateCount === 0 ? 'Team launched — lead online' : allTeammatesConfirmedAlive - ? `Team launched — all ${fallbackTeammateCount} teammates confirmed alive` + ? `Team launched — all ${fallbackTeammateCount} teammates checked in` : allPendingRuntimesStarted - ? 'Team launched — teammate runtimes started, waiting for bootstrap confirmation' + ? 'Team launched — teammate runtimes online, waiting for check-ins' : 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 — teammate liveness is still being confirmed'; + ? `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 check-ins are still coming in'; return (
diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index e7946534..58de583a 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -7,6 +7,8 @@ import { agentAvatarUrl, buildMemberColorMap, displayMemberName, + getMemberRuntimeAdvisoryLabel, + getMemberRuntimeAdvisoryTitle, } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { formatDistanceToNowStrict } from 'date-fns'; @@ -79,6 +81,8 @@ export const PendingRepliesBlock = ({ const roleLabel = formatAgentRole( member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) ); + const advisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory); + const advisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory); return (
- awaiting reply + {advisoryLabel ?? 'awaiting reply'} {since} diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 8934da38..2370a900 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -6,6 +6,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, displayMemberName, + getMemberRuntimeAdvisoryLabel, + getMemberRuntimeAdvisoryTitle, getSpawnAwareDotClass, getSpawnAwarePresenceLabel, getSpawnCardClass, @@ -85,6 +87,8 @@ export const MemberCard = ({ isTeamProvisioning, leadActivity ); + const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory); + const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory); const presenceLabel = getSpawnAwarePresenceLabel( member, spawnStatus, @@ -178,8 +182,11 @@ export const MemberCard = ({ className="size-3 shrink-0 animate-spin" style={{ color: colors.border }} /> - - awaiting reply + + {runtimeAdvisoryLabel ?? 'awaiting reply'} ) : null} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index dd02fd0a..ce61ff2f 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -8,6 +8,7 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { LeadActivityState, MemberLaunchState, + MemberRuntimeAdvisory, MemberSpawnLivenessSource, MemberSpawnStatus, MemberStatus, @@ -100,7 +101,7 @@ export const SPAWN_DOT_COLORS: Record = { export const SPAWN_PRESENCE_LABELS: Record = { offline: 'offline', - waiting: 'bootstrap pending', + waiting: 'check-in pending', spawning: 'starting', online: 'ready', error: 'spawn failed', @@ -159,13 +160,13 @@ export function getSpawnAwarePresenceLabel( return 'waiting for Agent'; } if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) { - return 'bootstrap pending'; + return 'check-in pending'; } if (spawnStatus === 'waiting') { return SPAWN_PRESENCE_LABELS.waiting; } if (spawnStatus === 'online' && livenessSource === 'process') { - return 'bootstrap pending'; + return 'check-in pending'; } if (spawnStatus && isTeamProvisioning) { 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 = { pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' }, in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f5061d57..dfff29d4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -497,9 +497,18 @@ export interface ResolvedTeamMember { cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; removedAt?: number; } +export interface MemberRuntimeAdvisory { + kind: 'sdk_retrying'; + observedAt: string; + retryUntil: string; + retryDelayMs: number; + message?: string; +} + export interface TeamProcess { id: string; port?: number; diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts new file mode 100644 index 00000000..f692bb62 --- /dev/null +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -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(); + }); +});