Refine launch status and Gemini staging flow

This commit is contained in:
iliya 2026-04-05 12:08:13 +03:00
parent e9c08c4b6d
commit 2959a3d074
7 changed files with 397 additions and 70 deletions

View file

@ -106,6 +106,7 @@ import type {
CrossTeamSendResult,
InboxMessage,
LeadContextUsage,
MemberLaunchState,
MemberSpawnStatus,
MemberSpawnLivenessSource,
MemberSpawnStatusEntry,
@ -591,8 +592,16 @@ interface ProvisioningRun {
string,
{
status: MemberSpawnStatus;
launchState: MemberLaunchState;
error?: string;
hardFailureReason?: string;
livenessSource?: MemberSpawnLivenessSource;
agentToolAccepted?: boolean;
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
hardFailure?: boolean;
firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string;
updatedAt: string;
}
>;
@ -620,10 +629,44 @@ interface PromptSizeSummary {
lines: number;
}
const MEMBER_LAUNCH_GRACE_MS = 90_000;
function nowIso(): string {
return new Date().toISOString();
}
function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
const updatedAt = nowIso();
return {
status: 'offline',
launchState: 'starting',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
updatedAt,
};
}
function deriveMemberLaunchState(entry: {
status: MemberSpawnStatus;
agentToolAccepted?: boolean;
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
hardFailure?: boolean;
}): MemberLaunchState {
if (entry.hardFailure || entry.status === 'error') {
return 'failed_to_start';
}
if (entry.bootstrapConfirmed) {
return 'confirmed_alive';
}
if (entry.runtimeAlive || entry.agentToolAccepted || entry.status === 'waiting') {
return 'runtime_pending_bootstrap';
}
return 'starting';
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@ -1720,15 +1763,17 @@ function buildGeminiPostLaunchHydrationPrompt(
.map((member) => {
const status = run.memberSpawnStatuses.get(member.name);
const label =
status?.status === 'error'
? `failed to start${status.error ? `${status.error}` : ''}`
: status?.status === 'online'
status?.launchState === 'failed_to_start'
? `failed to start${status.hardFailureReason ? `${status.hardFailureReason}` : status.error ? `${status.error}` : ''}`
: status?.launchState === 'confirmed_alive'
? 'bootstrap confirmed'
: status?.status === 'waiting'
: status?.runtimeAlive
? 'runtime started, bootstrap pending'
: status?.status === 'spawning'
? 'spawn in progress'
: 'runtime state unclear';
: status?.launchState === 'runtime_pending_bootstrap'
? 'spawn accepted, bootstrap pending'
: status?.status === 'spawning'
? 'spawn in progress'
: 'runtime state unclear';
return `- @${member.name}: ${label}`;
})
.join('\n')}\n`
@ -2580,6 +2625,44 @@ export class TeamProvisioningService {
}
}
private async refreshMemberSpawnStatusesFromLeadInbox(run: ProvisioningRun): Promise<void> {
const leadName = this.getRunLeadName(run);
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
leadInboxMessages = await this.inboxReader.getMessagesFor(run.teamName, leadName);
} catch {
return;
}
const runStartedAtMs = Date.parse(run.startedAt);
const teammateMessages = leadInboxMessages
.filter((message) => {
const from = typeof message.from === 'string' ? message.from.trim() : '';
if (!from || from === leadName || from === 'user' || from === 'system') return false;
if (!run.expectedMembers.includes(from)) return false;
const messageTs = Date.parse(message.timestamp);
if (
Number.isFinite(messageTs) &&
Number.isFinite(runStartedAtMs) &&
messageTs < runStartedAtMs
) {
return false;
}
return typeof message.text === 'string' && message.text.trim().length > 0;
})
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
for (const message of teammateMessages) {
const from = message.from.trim();
const reason = extractBootstrapFailureReason(message.text);
if (reason) {
this.setMemberSpawnStatus(run, from, 'error', reason);
continue;
}
this.setMemberSpawnStatus(run, from, 'online', undefined, 'heartbeat');
}
}
private persistSentMessage(teamName: string, message: InboxMessage): void {
try {
createController({
@ -3008,20 +3091,65 @@ export class TeamProvisioningService {
error?: string,
livenessSource?: MemberSpawnLivenessSource
): void {
const prev = run.memberSpawnStatuses.get(memberName);
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
const updatedAt = nowIso();
const next: MemberSpawnStatusEntry = {
...prev,
status,
updatedAt,
};
if (status === 'spawning') {
next.launchState = 'starting';
} else if (status === 'waiting') {
next.agentToolAccepted = true;
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
next.launchState = 'runtime_pending_bootstrap';
} else if (status === 'online') {
next.agentToolAccepted = true;
next.runtimeAlive = true;
next.livenessSource = livenessSource;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
if (livenessSource === 'heartbeat') {
next.bootstrapConfirmed = true;
next.lastHeartbeatAt = updatedAt;
}
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.launchState = deriveMemberLaunchState(next);
} else if (status === 'error') {
next.error = error;
next.hardFailure = true;
next.hardFailureReason = error;
next.launchState = 'failed_to_start';
} else if (status === 'offline') {
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
}
next.launchState = deriveMemberLaunchState(next);
if (
prev?.status === status &&
prev?.error === error &&
prev?.livenessSource === livenessSource
prev.status === next.status &&
prev.launchState === next.launchState &&
prev.error === next.error &&
prev.hardFailureReason === next.hardFailureReason &&
prev.livenessSource === next.livenessSource &&
prev.agentToolAccepted === next.agentToolAccepted &&
prev.runtimeAlive === next.runtimeAlive &&
prev.bootstrapConfirmed === next.bootstrapConfirmed &&
prev.hardFailure === next.hardFailure &&
prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt &&
prev.lastHeartbeatAt === next.lastHeartbeatAt
) {
return;
}
run.memberSpawnStatuses.set(memberName, {
status,
error,
livenessSource,
updatedAt: nowIso(),
});
run.memberSpawnStatuses.set(memberName, next);
this.syncMemberLaunchGraceCheck(run, memberName, next);
if (status === 'spawning') {
this.appendMemberBootstrapDiagnostic(run, memberName, 'Agent tool invoked');
} else if (status === 'waiting') {
@ -3074,14 +3202,96 @@ export class TeamProvisioningService {
for (const [name, entry] of run.memberSpawnStatuses) {
result[name] = {
status: entry.status,
launchState: entry.launchState,
error: entry.error,
hardFailureReason: entry.hardFailureReason,
livenessSource: entry.livenessSource,
agentToolAccepted: entry.agentToolAccepted,
runtimeAlive: entry.runtimeAlive,
bootstrapConfirmed: entry.bootstrapConfirmed,
hardFailure: entry.hardFailure,
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
lastHeartbeatAt: entry.lastHeartbeatAt,
updatedAt: entry.updatedAt,
};
}
return { statuses: result, runId };
}
private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string {
return `member-launch-grace:${run.runId}:${memberName}`;
}
private syncMemberLaunchGraceCheck(
run: ProvisioningRun,
memberName: string,
entry: MemberSpawnStatusEntry
): void {
const key = this.getMemberLaunchGraceKey(run, memberName);
const existing = this.pendingTimeouts.get(key);
if (entry.launchState === 'failed_to_start' || entry.launchState === 'confirmed_alive') {
if (existing) {
clearTimeout(existing);
this.pendingTimeouts.delete(key);
}
return;
}
if (!entry.firstSpawnAcceptedAt) {
return;
}
const remainingMs =
Date.parse(entry.firstSpawnAcceptedAt) + MEMBER_LAUNCH_GRACE_MS - Date.now();
if (remainingMs <= 0) {
if (existing) {
clearTimeout(existing);
this.pendingTimeouts.delete(key);
}
void this.reevaluateMemberLaunchStatus(run, memberName);
return;
}
if (existing) {
return;
}
const timer = setTimeout(() => {
this.pendingTimeouts.delete(key);
void this.reevaluateMemberLaunchStatus(run, memberName);
}, remainingMs);
timer.unref?.();
this.pendingTimeouts.set(key, timer);
}
private async reevaluateMemberLaunchStatus(
run: ProvisioningRun,
memberName: string
): Promise<void> {
const current = run.memberSpawnStatuses.get(memberName);
if (!current) return;
if (
current.launchState === 'failed_to_start' ||
current.launchState === 'confirmed_alive' ||
!current.firstSpawnAcceptedAt
) {
return;
}
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
await this.auditMemberSpawnStatuses(run);
const refreshed = run.memberSpawnStatuses.get(memberName);
if (!refreshed) return;
if (
refreshed.launchState === 'failed_to_start' ||
refreshed.launchState === 'confirmed_alive' ||
refreshed.runtimeAlive
) {
return;
}
this.setMemberSpawnStatus(
run,
memberName,
'error',
'Teammate did not join within the launch grace window.'
);
}
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
@ -4063,7 +4273,7 @@ export class TeamProvisioningService {
geminiPostLaunchHydrationSent: false,
suppressGeminiPostLaunchHydrationOutput: false,
memberSpawnStatuses: new Map(
request.members.map((m) => [m.name, { status: 'offline' as const, updatedAt: nowIso() }])
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
progress: {
@ -4545,7 +4755,7 @@ export class TeamProvisioningService {
geminiPostLaunchHydrationSent: false,
suppressGeminiPostLaunchHydrationOutput: false,
memberSpawnStatuses: new Map(
expectedMembers.map((name) => [name, { status: 'offline' as const, updatedAt: nowIso() }])
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
progress: {
@ -5155,6 +5365,8 @@ export class TeamProvisioningService {
return 0;
}
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
const unread = leadInboxMessages
.filter((m): m is InboxMessage & { messageId: string } => {
if (m.read) return false;
@ -5731,7 +5943,12 @@ export class TeamProvisioningService {
// Flag any expected member not found in config.json (excluding the lead)
for (const expected of run.expectedMembers) {
const current = run.memberSpawnStatuses.get(expected);
if (current?.status === 'error' || current?.status === 'online') continue;
if (
current?.launchState === 'failed_to_start' ||
current?.launchState === 'confirmed_alive'
) {
continue;
}
const matchedRuntimeNames = [...registeredNames].filter((name) => {
if (name === expected) return true;
@ -5739,30 +5956,50 @@ export class TeamProvisioningService {
return parsed !== null && parsed.suffix >= 2 && parsed.base === expected;
});
// A teammate may intentionally stay silent after bootstrap. If Claude Code
// registered the runtime and the OS process is still alive, treat it as
// process-confirmed running. Keep this distinct from heartbeat-confirmed online.
if (
const runtimeAlive =
matchedRuntimeNames.length > 0 &&
matchedRuntimeNames.some((runtimeName) =>
this.hasLiveTeamAgentProcess(run.teamName, runtimeName)
)
) {
);
// A teammate may intentionally stay silent after bootstrap. If Claude Code
// registered the runtime and the OS process is still alive, treat it as
// process-confirmed running. Keep this distinct from heartbeat-confirmed online.
if (runtimeAlive) {
this.setMemberSpawnStatus(run, expected, 'online', undefined, 'process');
continue;
}
if (matchedRuntimeNames.length > 0) continue;
if (matchedRuntimeNames.length > 0) {
if (current?.agentToolAccepted) {
this.setMemberSpawnStatus(run, expected, 'waiting');
}
continue;
}
const acceptedAtMs =
current?.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const graceExpired =
current?.agentToolAccepted === true &&
Number.isFinite(acceptedAtMs) &&
Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
if (current?.agentToolAccepted && !graceExpired) {
this.setMemberSpawnStatus(run, expected, 'waiting');
continue;
}
logger.warn(
`[${run.teamName}] Member "${expected}" not found in config.json members after provisioning`
);
this.setMemberSpawnStatus(
run,
expected,
'error',
'Teammate not registered after provisioning — spawned incorrectly. Restart the team to fix.'
);
if (graceExpired) {
this.setMemberSpawnStatus(
run,
expected,
'error',
'Teammate not registered after provisioning within the launch grace window.'
);
}
}
}
@ -5802,11 +6039,43 @@ export class TeamProvisioningService {
run: ProvisioningRun
): { name: string; error?: string; updatedAt: string }[] {
return [...run.memberSpawnStatuses.entries()]
.filter(([, entry]) => entry.status === 'error')
.map(([name, entry]) => ({ name, error: entry.error, updatedAt: entry.updatedAt }))
.filter(([, entry]) => entry.launchState === 'failed_to_start')
.map(([name, entry]) => ({
name,
error: entry.hardFailureReason ?? entry.error,
updatedAt: entry.updatedAt,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
private getMemberLaunchSummary(run: ProvisioningRun): {
confirmedCount: number;
pendingCount: number;
failedCount: number;
runtimeAlivePendingCount: number;
} {
let confirmedCount = 0;
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
for (const expected of run.expectedMembers) {
const entry = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
if (entry.launchState === 'confirmed_alive') {
confirmedCount += 1;
continue;
}
if (entry.launchState === 'failed_to_start') {
failedCount += 1;
continue;
}
pendingCount += 1;
if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1;
}
}
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
}
private async persistPartialLaunchState(run: ProvisioningRun): Promise<void> {
if (!run.isLaunch || !run.expectedMembers || run.expectedMembers.length === 0) {
if (run.isLaunch) {
@ -8140,18 +8409,24 @@ export class TeamProvisioningService {
}
// Audit: flag any expected member not registered in config.json after launch.
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
await this.auditMemberSpawnStatuses(run);
await this.persistPartialLaunchState(run);
const failedSpawnMembers = this.getFailedSpawnMembers(run);
const launchSummary = this.getMemberLaunchSummary(run);
const hasSpawnFailures = failedSpawnMembers.length > 0;
const hasPendingBootstrap =
!hasSpawnFailures && launchSummary.pendingCount > 0 && run.expectedMembers.length > 0;
const readyMessage = hasSpawnFailures
? `Launch completed with teammate errors — ${failedSpawnMembers
.map((member) => member.name)
.join(', ')} failed to start`
: 'Team launched — process alive and ready';
: hasPendingBootstrap
? `Launch completed — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending`
: 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, {
cliLogsTail: extractCliLogsFromRun(run),
messageSeverity: hasSpawnFailures ? 'warning' : undefined,
messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined,
});
run.onProgress(progress);
this.provisioningRunByTeam.delete(run.teamName);
@ -8171,7 +8446,7 @@ export class TeamProvisioningService {
detail: 'lead-session-sync',
});
if (!hasSpawnFailures) {
if (!hasSpawnFailures && !hasPendingBootstrap) {
// Fire "Team Launched" notification only for clean launches.
void this.fireTeamLaunchedNotification(run);
}
@ -8293,10 +8568,14 @@ export class TeamProvisioningService {
await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {});
// Audit: flag any expected member not registered in config.json after provisioning.
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
await this.auditMemberSpawnStatuses(run);
await this.clearPartialLaunchState(run.teamName);
const failedSpawnMembers = this.getFailedSpawnMembers(run);
const launchSummary = this.getMemberLaunchSummary(run);
const hasSpawnFailures = failedSpawnMembers.length > 0;
const hasPendingBootstrap =
!hasSpawnFailures && launchSummary.pendingCount > 0 && run.expectedMembers.length > 0;
const progress = updateProgress(
run,
'ready',
@ -8304,10 +8583,12 @@ export class TeamProvisioningService {
? `Provisioning completed with teammate errors — ${failedSpawnMembers
.map((member) => member.name)
.join(', ')} failed to start`
: 'Team provisioned — process alive and ready',
: hasPendingBootstrap
? `Team provisioned — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending`
: 'Team provisioned — process alive and ready',
{
cliLogsTail: extractCliLogsFromRun(run),
messageSeverity: hasSpawnFailures ? 'warning' : undefined,
messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined,
}
);
run.onProgress(progress);
@ -8328,7 +8609,7 @@ export class TeamProvisioningService {
detail: 'lead-session-sync',
});
if (!hasSpawnFailures) {
if (!hasSpawnFailures && !hasPendingBootstrap) {
// Fire "Team Launched" notification only for clean launches.
void this.fireTeamLaunchedNotification(run);
}
@ -8702,6 +8983,14 @@ export class TeamProvisioningService {
this.pendingTimeouts.delete(key);
}
}
for (const memberName of run.memberSpawnStatuses.keys()) {
const key = this.getMemberLaunchGraceKey(run, memberName);
const timer = this.pendingTimeouts.get(key);
if (timer) {
clearTimeout(timer);
this.pendingTimeouts.delete(key);
}
}
run.activeCrossTeamReplyHints = [];
run.pendingInboxRelayCandidates = [];
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {

View file

@ -577,14 +577,20 @@ export const TeamDetailView = ({
string,
{
status: MemberSpawnStatusEntry['status'];
launchState: MemberSpawnStatusEntry['launchState'];
error?: string;
updatedAt: string;
runtimeAlive?: boolean;
livenessSource?: MemberSpawnStatusEntry['livenessSource'];
}
>();
for (const [name, entry] of Object.entries(memberSpawnStatuses)) {
map.set(name, {
status: entry.status,
launchState: entry.launchState,
error: entry.error,
updatedAt: entry.updatedAt,
runtimeAlive: entry.runtimeAlive,
livenessSource: entry.livenessSource,
});
}

View file

@ -106,20 +106,23 @@ export const TeamProvisioningBanner = ({
const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member));
const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter(
([, entry]) => entry.status === 'error'
([, entry]) => entry.launchState === 'failed_to_start'
);
const failedSpawnCount = failedSpawnEntries.length;
const heartbeatConfirmedCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'online' && entry.livenessSource === 'heartbeat';
return entry?.launchState === 'confirmed_alive';
}).length;
const processOnlyAliveCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'online' && entry.livenessSource === 'process';
return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true;
}).length;
const awaitingHeartbeatCount = teammates.filter((member) => {
const pendingSpawnCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'waiting';
return (
entry?.launchState === 'starting' ||
(entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true)
);
}).length;
const allTeammatesConfirmedAlive =
teammates.length > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === teammates.length;
@ -132,8 +135,8 @@ export const TeamProvisioningBanner = ({
? 'Team launched — lead online'
: allTeammatesConfirmedAlive
? `Team launched — all ${teammates.length} teammates confirmed alive`
: processOnlyAliveCount > 0 || awaitingHeartbeatCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${awaitingHeartbeatCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${awaitingHeartbeatCount} awaiting first heartbeat` : ''}`
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} 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';
return (
@ -151,7 +154,11 @@ export const TeamProvisioningBanner = ({
defaultLiveOutputOpen={false}
onCancel={null}
successMessage={readyMessage}
successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'}
successMessageSeverity={
failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0
? 'warning'
: 'success'
}
onDismiss={() => setDismissed(true)}
/>
</div>

View file

@ -18,6 +18,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
ResolvedTeamMember,
@ -39,6 +40,8 @@ interface MemberCardProps {
spawnStatus?: MemberSpawnStatus;
spawnError?: string;
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean;
onOpenTask?: () => void;
onOpenReviewTask?: () => void;
onClick?: () => void;
@ -61,6 +64,8 @@ export const MemberCard = ({
spawnStatus,
spawnError,
spawnLivenessSource,
spawnLaunchState,
spawnRuntimeAlive,
onOpenTask,
onOpenReviewTask,
onClick,
@ -75,6 +80,7 @@ export const MemberCard = ({
const dotClass = getSpawnAwareDotClass(
member,
spawnStatus,
spawnLaunchState,
isTeamAlive,
isTeamProvisioning,
leadActivity
@ -82,7 +88,9 @@ export const MemberCard = ({
const presenceLabel = getSpawnAwarePresenceLabel(
member,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
isTeamAlive,
isTeamProvisioning,
leadActivity

View file

@ -15,24 +15,17 @@ import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
export interface MemberSpawnEntry {
status: MemberSpawnStatus;
error?: string;
livenessSource?: MemberSpawnLivenessSource;
}
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnEntry>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
@ -137,6 +130,8 @@ export const MemberList = ({
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
onClick={() => onMemberClick?.(member)}

View file

@ -7,6 +7,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type {
LeadActivityState,
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberStatus,
@ -99,7 +100,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
offline: 'offline',
waiting: 'awaiting heartbeat',
waiting: 'bootstrap pending',
spawning: 'starting',
online: 'ready',
error: 'spawn failed',
@ -112,13 +113,17 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
export function getSpawnAwareDotClass(
member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (spawnStatus === 'error') {
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_DOT_COLORS.error;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
return SPAWN_DOT_COLORS.spawning;
}
if (spawnStatus === 'waiting') {
return SPAWN_DOT_COLORS.waiting;
}
@ -140,22 +145,27 @@ export function getSpawnAwareDotClass(
export function getSpawnAwarePresenceLabel(
member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
livenessSource: MemberSpawnLivenessSource | undefined,
runtimeAlive: boolean | undefined,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (spawnStatus === 'error') {
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_PRESENCE_LABELS.error;
}
if (spawnStatus === 'offline' && isTeamProvisioning) {
return 'waiting for Agent';
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return 'bootstrap pending';
}
if (spawnStatus === 'waiting') {
return SPAWN_PRESENCE_LABELS.waiting;
}
if (spawnStatus === 'online' && livenessSource === 'process') {
return 'running';
return 'bootstrap pending';
}
if (spawnStatus && isTeamProvisioning) {
return SPAWN_PRESENCE_LABELS[spawnStatus];

View file

@ -440,15 +440,12 @@ export interface AddTaskCommentRequest {
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
/**
* Spawn lifecycle status for a team member during team launch/reconnect.
* - offline: queued, Agent tool_use not sent yet
* - spawning: Agent tool_use sent, awaiting tool_result
* - waiting: teammate process accepted by runtime, awaiting first heartbeat/inbox signal
* - online: first heartbeat/inbox signal received
* - error: spawn failed (tool_result with error)
*/
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
export type MemberLaunchState =
| 'starting'
| 'runtime_pending_bootstrap'
| 'confirmed_alive'
| 'failed_to_start';
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
@ -610,14 +607,29 @@ export interface ProjectBranchChangeEvent {
/** Per-member spawn status entry, exposed to renderer via IPC. */
export interface MemberSpawnStatusEntry {
status: MemberSpawnStatus;
launchState: MemberLaunchState;
/** Error message when status === 'error'. */
error?: string;
/** Hard failure reason for failed_to_start. */
hardFailureReason?: string;
/**
* Optional provenance for `online`.
* - heartbeat: teammate sent a real inbox/native message after bootstrap
* - process: runtime process is alive, but bootstrap/first reply is not yet confirmed
*/
livenessSource?: MemberSpawnLivenessSource;
/** Agent tool_result confirmed the spawn request was accepted by the runtime. */
agentToolAccepted?: boolean;
/** Runtime process or registered teammate runtime is currently alive. */
runtimeAlive?: boolean;
/** A real teammate heartbeat/bootstrap confirmation was observed. */
bootstrapConfirmed?: boolean;
/** Hard failure observed from spawn/bootstrap/runtime evidence. */
hardFailure?: boolean;
/** ISO timestamp of the first accepted teammate spawn for this member. */
firstSpawnAcceptedAt?: string;
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
lastHeartbeatAt?: string;
/** ISO timestamp of the last status change. */
updatedAt: string;
}