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,
} 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<void> {
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<void> {
);
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
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.',
`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')
);

View file

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

View file

@ -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<TeamTask, 'id' | 'displayId'>): 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')}`
);

View file

@ -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');

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'
? '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(

View file

@ -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 (
<div className="mb-3">

View file

@ -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 (
<article
@ -137,9 +141,9 @@ export const PendingRepliesBlock = ({
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
title="Message sent, awaiting reply"
title={advisoryTitle ?? 'Message sent, awaiting reply'}
>
awaiting reply
{advisoryLabel ?? 'awaiting reply'}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{since}

View file

@ -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 }}
/>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
awaiting reply
<span
className="shrink-0 text-[10px] text-[var(--color-text-muted)]"
title={runtimeAdvisoryTitle ?? 'Message sent, awaiting reply'}
>
{runtimeAdvisoryLabel ?? 'awaiting reply'}
</span>
</>
) : null}

View file

@ -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<MemberSpawnStatus, string> = {
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
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<TeamTaskStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },

View file

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

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();
});
});