feat(team): clarify teammate check-ins and retry state
This commit is contained in:
parent
cb9eb5f701
commit
c20fc1a312
13 changed files with 463 additions and 30 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
178
src/main/services/team/TeamMemberRuntimeAdvisoryService.ts
Normal file
178
src/main/services/team/TeamMemberRuntimeAdvisoryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
159
test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts
Normal file
159
test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue