fix(team): stabilize mixed launch runtime states

This commit is contained in:
777genius 2026-05-04 21:03:41 +03:00
parent d20fe2a538
commit cde85c0396
16 changed files with 634 additions and 55 deletions

View file

@ -545,6 +545,7 @@ export class TeamGraphAdapter {
spawnLaunchState: spawn?.launchState, spawnLaunchState: spawn?.launchState,
spawnLivenessSource: spawn?.livenessSource, spawnLivenessSource: spawn?.livenessSource,
spawnRuntimeAlive: spawn?.runtimeAlive, spawnRuntimeAlive: spawn?.runtimeAlive,
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
spawnBootstrapStalled: spawn?.bootstrapStalled, spawnBootstrapStalled: spawn?.bootstrapStalled,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,

View file

@ -325,6 +325,7 @@ const MemberPopoverContent = ({
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled, spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false, isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,

View file

@ -228,6 +228,24 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
const logger = createLogger('IPC:teams'); const logger = createLogger('IPC:teams');
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
function resolveVisibleDirectReplyProtocol(input: {
providerId?: TeamProviderId;
isLeadRecipient: boolean;
replyRecipient: string;
}): VisibleDirectReplyProtocol {
if (
!input.isLeadRecipient &&
input.replyRecipient.trim().toLowerCase() === 'user' &&
input.providerId === 'codex'
) {
return 'agent_teams_message_send';
}
return 'send_message';
}
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250; const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
@ -2473,7 +2491,11 @@ function buildMessageDeliveryText(
opts: { opts: {
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
isLeadRecipient: boolean; isLeadRecipient: boolean;
memberName?: string;
messageId?: string;
protocol?: VisibleDirectReplyProtocol;
replyRecipient?: string; replyRecipient?: string;
teamName?: string;
} }
): string { ): string {
const hiddenBlocks: string[] = []; const hiddenBlocks: string[] = [];
@ -2482,22 +2504,49 @@ function buildMessageDeliveryText(
hiddenBlocks.push(actionModeBlock); hiddenBlocks.push(actionModeBlock);
} }
if (!opts.isLeadRecipient) { if (!opts.isLeadRecipient) {
const replyRecipient = const rawReplyRecipient =
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0 typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
? opts.replyRecipient.trim() ? opts.replyRecipient.trim()
: 'user'; : 'user';
const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`; const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user';
hiddenBlocks.push( const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient;
[ const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`;
AGENT_BLOCK_OPEN, const protocol = opts.protocol ?? 'send_message';
`You received a direct message from ${senderDescriptor} via the UI.`, const canUseAgentTeamsMessageSend =
protocol === 'agent_teams_message_send' &&
isUserReplyRecipient &&
typeof opts.teamName === 'string' &&
opts.teamName.trim().length > 0 &&
typeof opts.memberName === 'string' &&
opts.memberName.trim().length > 0 &&
typeof opts.messageId === 'string' &&
opts.messageId.trim().length > 0;
const replyInstructionLines = canUseAgentTeamsMessageSend
? [
'CRITICAL: Reply using the Agent Teams MCP message_send tool, not SendMessage.',
'Use tool agent-teams_message_send or mcp__agent-teams__message_send, whichever exposed name is available.',
`CRITICAL: The tool input must include teamName="${opts.teamName!.trim()}", to="user", from="${opts.memberName!.trim()}", text, summary, source="runtime_delivery", and relayOfMessageId="${opts.messageId!.trim()}".`,
'Do NOT answer only with normal assistant text when the Agent Teams message_send tool is available because that will not appear in the UI message thread.',
]
: [
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.', 'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
`CRITICAL: The destination must be exactly to="${replyRecipient}".`, `CRITICAL: The destination must be exactly to="${replyRecipient}".`,
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.', 'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
];
hiddenBlocks.push(
[
AGENT_BLOCK_OPEN,
`You received a direct message from ${senderDescriptor} via the UI.`,
...replyInstructionLines,
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
...(replyRecipient === 'user' ...(canUseAgentTeamsMessageSend
? [
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
]
: []),
...(isUserReplyRecipient
? [ ? [
'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: "Принял, сейчас уточню и вернусь с ответом.").', '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.', 'Only after that first acknowledgement may you message the lead or another teammate.',
@ -2848,22 +2897,37 @@ async function handleSendMessage(
typeof payload.from === 'string' && payload.from.trim().length > 0 typeof payload.from === 'string' && payload.from.trim().length > 0
? payload.from.trim() ? payload.from.trim()
: 'user'; : 'user';
const isOpenCodeRecipient = const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient;
!isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName)); const recipientProviderId = !isLeadRecipient
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
: undefined;
const isOpenCodeRecipient = recipientProviderId === 'opencode';
const directReplyProtocol = resolveVisibleDirectReplyProtocol({
isLeadRecipient,
replyRecipient,
...(recipientProviderId ? { providerId: recipientProviderId } : {}),
});
const inboxMessageId =
directReplyProtocol === 'agent_teams_message_send' ? crypto.randomUUID() : undefined;
const memberDeliveryText = buildMessageDeliveryText(baseText, { const memberDeliveryText = buildMessageDeliveryText(baseText, {
actionMode, actionMode,
isLeadRecipient, isLeadRecipient,
memberName,
protocol: directReplyProtocol,
replyRecipient, replyRecipient,
teamName: tn,
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
}); });
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText; const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
const result = await getTeamDataService().sendMessage(tn, { const result = await getTeamDataService().sendMessage(tn, {
member: memberName, member: memberName,
text: inboxText, text: inboxText,
summary: payload.summary, summary: payload.summary,
from: payload.from, from: storedFrom,
actionMode, actionMode,
source: 'user_sent', source: 'user_sent',
taskRefs: validatedTaskRefs.value, taskRefs: validatedTaskRefs.value,
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
}); });
// Teammate inbox relay DISABLED (2026-03-23). // Teammate inbox relay DISABLED (2026-03-23).

View file

@ -2417,6 +2417,11 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
]), ]),
]; ];
const runtimeAlive = true; const runtimeAlive = true;
const livenessKind =
input.current.livenessKind === 'runtime_process' ||
input.current.livenessKind === 'confirmed_bootstrap'
? input.current.livenessKind
: 'confirmed_bootstrap';
return { return {
...input.previous, ...input.previous,
...input.current, ...input.current,
@ -2428,12 +2433,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
hardFailureReason: undefined, hardFailureReason: undefined,
runtimeRunId: input.session.runId ?? input.current.runtimeRunId, runtimeRunId: input.session.runId ?? input.current.runtimeRunId,
runtimeSessionId: input.session.id, runtimeSessionId: input.session.id,
livenessKind: runtimeAlive livenessKind,
? input.current.livenessKind
: input.current.livenessKind === 'runtime_process' ||
input.current.livenessKind === 'runtime_process_candidate'
? input.current.livenessKind
: 'confirmed_bootstrap',
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.', runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
runtimeDiagnosticSeverity: 'info', runtimeDiagnosticSeverity: 'info',
firstSpawnAcceptedAt: firstSpawnAcceptedAt:
@ -6280,17 +6280,28 @@ export class TeamProvisioningService {
); );
} }
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> { async resolveRuntimeRecipientProviderId(
teamName: string,
memberName: string
): Promise<TeamProviderId | undefined> {
const normalizedMemberName = memberName.trim().toLowerCase(); const normalizedMemberName = memberName.trim().toLowerCase();
if (!normalizedMemberName) { if (!normalizedMemberName) {
return false; return undefined;
} }
const [config, metaMembers] = await Promise.all([ const [config, metaMembers] = await Promise.all([
this.readConfigSnapshot(teamName).catch(() => null), this.readConfigSnapshot(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
]); ]);
return this.isOpenCodeRuntimeRecipientFromSources(normalizedMemberName, config, metaMembers); return this.resolveRuntimeRecipientProviderIdFromSources(
normalizedMemberName,
config,
metaMembers
);
}
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode';
} }
private isOpenCodeDeliveryResponseReadCommitAllowed(input: { private isOpenCodeDeliveryResponseReadCommitAllowed(input: {
@ -18829,10 +18840,17 @@ export class TeamProvisioningService {
continue; continue;
} }
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
const metadataLivenessKind =
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
? metadata.livenessKind === 'runtime_process' ||
metadata.livenessKind === 'confirmed_bootstrap'
? metadata.livenessKind
: current.livenessKind
: metadata.livenessKind;
const nextEntry: MemberSpawnStatusEntry = { const nextEntry: MemberSpawnStatusEntry = {
...current, ...current,
...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}), ...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata.runtimeDiagnosticSeverity ...(metadata.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
@ -19740,7 +19758,8 @@ export class TeamProvisioningService {
return ( return (
previous?.launchState !== member.launchState || previous?.launchState !== member.launchState ||
previous?.bootstrapConfirmed !== member.bootstrapConfirmed || previous?.bootstrapConfirmed !== member.bootstrapConfirmed ||
previous?.runtimeSessionId !== member.runtimeSessionId previous?.runtimeSessionId !== member.runtimeSessionId ||
previous?.livenessKind !== member.livenessKind
); );
}); });
} }
@ -19773,7 +19792,9 @@ export class TeamProvisioningService {
previous: PersistedTeamLaunchMemberState | null previous: PersistedTeamLaunchMemberState | null
): boolean { ): boolean {
if (current.launchState === 'confirmed_alive' && current.bootstrapConfirmed) { if (current.launchState === 'confirmed_alive' && current.bootstrapConfirmed) {
return false; return (
current.livenessKind !== 'confirmed_bootstrap' && current.livenessKind !== 'runtime_process'
);
} }
if ( if (
previous?.launchState === 'confirmed_alive' && previous?.launchState === 'confirmed_alive' &&
@ -25257,7 +25278,7 @@ export class TeamProvisioningService {
if (!hasSpawnFailures && !hasPendingBootstrap) { if (!hasSpawnFailures && !hasPendingBootstrap) {
// Fire "Team Launched" notification only for clean launches. // Fire "Team Launched" notification only for clean launches.
void this.fireTeamLaunchedNotification(run); void this.fireTeamLaunchedNotification(run);
} else { } else if (hasSpawnFailures) {
void this.fireTeamLaunchIncompleteNotification( void this.fireTeamLaunchIncompleteNotification(
run, run,
failedSpawnMembers, failedSpawnMembers,
@ -25442,7 +25463,7 @@ export class TeamProvisioningService {
if (!hasSpawnFailures && !hasPendingBootstrap) { if (!hasSpawnFailures && !hasPendingBootstrap) {
// Fire "Team Launched" notification only for clean launches. // Fire "Team Launched" notification only for clean launches.
void this.fireTeamLaunchedNotification(run); void this.fireTeamLaunchedNotification(run);
} else { } else if (hasSpawnFailures) {
void this.fireTeamLaunchIncompleteNotification( void this.fireTeamLaunchIncompleteNotification(
run, run,
failedSpawnMembers, failedSpawnMembers,
@ -25551,6 +25572,9 @@ export class TeamProvisioningService {
failedMembers, failedMembers,
snapshot snapshot
); );
if (failedNames.length === 0) {
return;
}
const pendingNames = this.getLaunchIncompletePendingNames( const pendingNames = this.getLaunchIncompletePendingNames(
run, run,
expectedMembers, expectedMembers,
@ -25627,6 +25651,15 @@ export class TeamProvisioningService {
const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean)); const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean));
for (const memberName of expectedMembers) { for (const memberName of expectedMembers) {
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName); const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName);
const liveResolved =
live?.launchState === 'confirmed_alive' ||
live?.bootstrapConfirmed === true ||
live?.launchState === 'skipped_for_launch' ||
live?.skippedForLaunch === true;
if (liveResolved) {
failedNames.delete(memberName);
continue;
}
if ( if (
live?.launchState === 'failed_to_start' || live?.launchState === 'failed_to_start' ||
persisted?.launchState === 'failed_to_start' || persisted?.launchState === 'failed_to_start' ||

View file

@ -169,6 +169,7 @@ export const MemberCard = memo(function MemberCard({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled, spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,

View file

@ -252,6 +252,7 @@ export const MemberDetailDialog = ({
spawnLaunchState={spawnEntry?.launchState} spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource} spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive} spawnRuntimeAlive={spawnEntry?.runtimeAlive}
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
spawnBootstrapStalled={spawnEntry?.bootstrapStalled} spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
runtimeEntry={runtimeEntry} runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}

View file

@ -38,6 +38,7 @@ interface MemberDetailHeaderProps {
spawnLaunchState?: MemberLaunchState; spawnLaunchState?: MemberLaunchState;
spawnLivenessSource?: MemberSpawnLivenessSource; spawnLivenessSource?: MemberSpawnLivenessSource;
spawnRuntimeAlive?: boolean; spawnRuntimeAlive?: boolean;
spawnBootstrapConfirmed?: boolean;
spawnBootstrapStalled?: boolean; spawnBootstrapStalled?: boolean;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void; onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
@ -55,6 +56,7 @@ export const MemberDetailHeader = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
isLaunchSettling, isLaunchSettling,
onUpdateRole, onUpdateRole,
@ -81,6 +83,7 @@ export const MemberDetailHeader = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,

View file

@ -132,6 +132,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled, spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,

View file

@ -232,11 +232,14 @@ function buildSpawnBackedDisplayRow(
}; };
} }
if (spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) { if (
(spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) ||
isConfirmedSpawnLaunch(spawn)
) {
return { return {
memberName, memberName,
state: 'running', state: 'running',
stateReason: spawn.runtimeDiagnostic ?? 'Spawn status is online', stateReason: spawn.runtimeDiagnostic ?? 'Bootstrap confirmed',
source: 'spawn-status', source: 'spawn-status',
updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt, updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
runtimeModel: spawn.runtimeModel, runtimeModel: spawn.runtimeModel,
@ -299,6 +302,10 @@ function hasConfirmedSpawnLiveness(spawn: MemberSpawnStatusEntry): boolean {
); );
} }
function isConfirmedSpawnLaunch(spawn: MemberSpawnStatusEntry): boolean {
return spawn.launchState === 'confirmed_alive' && spawn.bootstrapConfirmed === true;
}
function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined { function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined {
const runtimePid = getFinitePid(runtime.runtimePid); const runtimePid = getFinitePid(runtime.runtimePid);
if (runtimePid != null) return `runtime pid ${runtimePid}`; if (runtimePid != null) return `runtime pid ${runtimePid}`;

View file

@ -746,6 +746,7 @@ export function buildMemberLaunchPresentation({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
runtimeAdvisory, runtimeAdvisory,
runtimeEntry, runtimeEntry,
@ -759,6 +760,7 @@ export function buildMemberLaunchPresentation({
spawnLaunchState: MemberLaunchState | undefined; spawnLaunchState: MemberLaunchState | undefined;
spawnLivenessSource: MemberSpawnLivenessSource | undefined; spawnLivenessSource: MemberSpawnLivenessSource | undefined;
spawnRuntimeAlive: boolean | undefined; spawnRuntimeAlive: boolean | undefined;
spawnBootstrapConfirmed?: boolean;
spawnBootstrapStalled?: boolean; spawnBootstrapStalled?: boolean;
runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
@ -767,12 +769,19 @@ export function buildMemberLaunchPresentation({
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
}): MemberLaunchPresentation { }): MemberLaunchPresentation {
const hasConfirmedSpawnLaunch =
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
const effectiveSpawnStatus =
hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning')
? 'online'
: spawnStatus;
const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive;
const presenceLabel = getLaunchAwarePresenceLabel( const presenceLabel = getLaunchAwarePresenceLabel(
member, member,
spawnStatus, effectiveSpawnStatus,
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, effectiveSpawnRuntimeAlive,
runtimeAdvisory, runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
@ -781,18 +790,18 @@ export function buildMemberLaunchPresentation({
); );
const baseDotClass = getSpawnAwareDotClass( const baseDotClass = getSpawnAwareDotClass(
member, member,
spawnStatus, effectiveSpawnStatus,
spawnLaunchState, spawnLaunchState,
spawnRuntimeAlive, effectiveSpawnRuntimeAlive,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity leadActivity
); );
const cardClass = getSpawnCardClass( const cardClass = getSpawnCardClass(
spawnStatus, effectiveSpawnStatus,
spawnLaunchState, spawnLaunchState,
spawnRuntimeAlive, effectiveSpawnRuntimeAlive,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
isTeamProvisioning isTeamProvisioning
@ -812,18 +821,23 @@ export function buildMemberLaunchPresentation({
launchVisualState = 'permission_pending'; launchVisualState = 'permission_pending';
} else if (spawnBootstrapStalled === true) { } else if (spawnBootstrapStalled === true) {
launchVisualState = 'bootstrap_stalled'; launchVisualState = 'bootstrap_stalled';
} else if (runtimeEntry?.livenessKind === 'shell_only') { } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') {
launchVisualState = 'shell_only'; launchVisualState = 'shell_only';
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') { } else if (
!hasConfirmedSpawnLaunch &&
runtimeEntry?.livenessKind === 'runtime_process_candidate'
) {
launchVisualState = 'runtime_candidate'; launchVisualState = 'runtime_candidate';
} else if (runtimeEntry?.livenessKind === 'registered_only') { } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') {
launchVisualState = 'registered_only'; launchVisualState = 'registered_only';
} else if ( } else if (
runtimeEntry?.livenessKind === 'stale_metadata' || !hasConfirmedSpawnLaunch &&
runtimeEntry?.livenessKind === 'not_found' (runtimeEntry?.livenessKind === 'stale_metadata' ||
runtimeEntry?.livenessKind === 'not_found')
) { ) {
launchVisualState = 'stale_runtime'; launchVisualState = 'stale_runtime';
} else if ( } else if (
!hasConfirmedSpawnLaunch &&
isQueuedOpenCodeLaunch( isQueuedOpenCodeLaunch(
member, member,
spawnStatus, spawnStatus,
@ -835,6 +849,7 @@ export function buildMemberLaunchPresentation({
) { ) {
launchVisualState = 'queued'; launchVisualState = 'queued';
} else if ( } else if (
!hasConfirmedSpawnLaunch &&
isLaunchStillStarting( isLaunchStillStarting(
spawnStatus, spawnStatus,
spawnLaunchState, spawnLaunchState,
@ -844,16 +859,13 @@ export function buildMemberLaunchPresentation({
) { ) {
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
} else if ( } else if (
!hasConfirmedSpawnLaunch &&
spawnLaunchState === 'runtime_pending_bootstrap' && spawnLaunchState === 'runtime_pending_bootstrap' &&
(runtimeEntry?.livenessKind === 'runtime_process' || (runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true)) (spawnStatus === 'online' && spawnRuntimeAlive === true))
) { ) {
launchVisualState = 'runtime_pending'; launchVisualState = 'runtime_pending';
} else if ( } else if (isLaunchSettling && spawnLaunchState === 'confirmed_alive') {
isLaunchSettling &&
spawnStatus === 'online' &&
spawnLaunchState === 'confirmed_alive'
) {
launchVisualState = 'settling'; launchVisualState = 'settling';
} }
} }

View file

@ -11,6 +11,35 @@ import type {
TeamProviderId, TeamProviderId,
} from '@shared/types'; } from '@shared/types';
function shouldShowRuntimeMemory(
spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry: TeamAgentRuntimeEntry | undefined
): boolean {
if (typeof runtimeEntry?.rssBytes !== 'number' || runtimeEntry.rssBytes <= 0) {
return false;
}
if (
spawnEntry?.status === 'offline' ||
spawnEntry?.status === 'skipped' ||
spawnEntry?.launchState === 'skipped_for_launch'
) {
return false;
}
if (!spawnEntry) {
return runtimeEntry.alive === true;
}
return (
runtimeEntry.alive === true ||
spawnEntry.runtimeAlive === true ||
spawnEntry.bootstrapConfirmed === true ||
spawnEntry.livenessSource === 'process' ||
spawnEntry.livenessSource === 'heartbeat'
);
}
function normalizeMemberBackendLabel( function normalizeMemberBackendLabel(
providerId: TeamProviderId, providerId: TeamProviderId,
backendLabel: string | undefined backendLabel: string | undefined
@ -101,9 +130,8 @@ export function resolveMemberRuntimeSummary(
configuredProvider, configuredProvider,
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId) formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
); );
const memorySuffix = const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 ? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
? ` · ${formatBytes(runtimeEntry.rssBytes)}`
: ''; : '';
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {

View file

@ -14,6 +14,7 @@ import type {
SendMessageResult, SendMessageResult,
TeamViewSnapshot, TeamViewSnapshot,
TeamCreateRequest, TeamCreateRequest,
TeamProviderId,
TeamProvisioningProgress, TeamProvisioningProgress,
} from '@shared/types/team'; } from '@shared/types/team';
@ -217,7 +218,16 @@ describe('ipc teams handlers', () => {
getLeadMemberName: vi.fn(async () => 'team-lead'), getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'), getTeamDisplayName: vi.fn(async () => 'My Team'),
updateConfig: vi.fn(async () => ({ name: 'My Team' })), updateConfig: vi.fn(async () => ({ name: 'My Team' })),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })), sendMessage: vi.fn(
async (_teamName: string, _request: unknown) => ({ deliveredToInbox: true, messageId: 'm1' })
) as ReturnType<
typeof vi.fn<
(
teamName: string,
request: unknown
) => Promise<{ deliveredToInbox: boolean; messageId: string }>
>
>,
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })), sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })), createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined), requestReview: vi.fn(async () => undefined),
@ -269,6 +279,14 @@ describe('ipc teams handlers', () => {
pushLiveLeadProcessMessage: vi.fn(), pushLiveLeadProcessMessage: vi.fn(),
relayLeadInboxMessages: vi.fn(async () => 0), relayLeadInboxMessages: vi.fn(async () => 0),
relayMemberInboxMessages: vi.fn(async () => 0), relayMemberInboxMessages: vi.fn(async () => 0),
resolveRuntimeRecipientProviderId: vi.fn(
async (_teamName: string, _memberName: string): Promise<TeamProviderId | undefined> =>
undefined
) as ReturnType<
typeof vi.fn<
(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>
>
>,
isOpenCodeRuntimeRecipient: vi.fn(async () => false), isOpenCodeRuntimeRecipient: vi.fn(async () => false),
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
relayed: 0, relayed: 0,
@ -348,6 +366,8 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset();
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset(); mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 }); launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
initializeTeamHandlers( initializeTeamHandlers(
service as never, service as never,
@ -645,8 +665,63 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it('uses Agent Teams MCP reply instructions for Codex user direct messages', async () => {
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('codex');
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'jack',
from: ' User ',
text: 'Здесь?',
})) as { success: boolean };
expect(result.success).toBe(true);
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
| { from?: string; text?: string; messageId?: string }
| undefined;
expect(request).toBeDefined();
expect(request?.from).toBe('user');
expect(request?.messageId).toEqual(expect.any(String));
expect(request?.text).toContain('agent-teams_message_send');
expect(request?.text).toContain('mcp__agent-teams__message_send');
expect(request?.text).toContain('teamName="my-team"');
expect(request?.text).toContain('to="user"');
expect(request?.text).toContain('from="jack"');
expect(request?.text).toContain('source="runtime_delivery"');
expect(request?.text).toContain(`relayOfMessageId="${request?.messageId}"`);
expect(request?.text).toContain('before any visible-message tool attempt');
expect(request?.text).not.toContain('tool call fails before sending');
expect(request?.text).not.toContain('Reply using the SendMessage tool');
});
it.each([
['anthropic' as const],
['gemini' as const],
[undefined],
])('keeps SendMessage reply instructions for %s user direct messages', async (providerId) => {
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'alice',
text: 'Здесь?',
})) as { success: boolean };
expect(result.success).toBe(true);
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
| { text?: string; messageId?: string }
| undefined;
expect(request).toBeDefined();
expect(request).not.toHaveProperty('messageId');
expect(request?.text).toContain('Reply using the SendMessage tool');
expect(request?.text).toContain('to="user"');
expect(request?.text).not.toContain('agent-teams_message_send');
});
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => { it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 1, relayed: 1,
attempted: 1, attempted: 1,
@ -699,7 +774,7 @@ describe('ipc teams handlers', () => {
}); });
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => { it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 0, relayed: 0,
attempted: 1, attempted: 1,
@ -734,7 +809,7 @@ describe('ipc teams handlers', () => {
}); });
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => { it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 0, relayed: 0,
attempted: 1, attempted: 1,
@ -774,7 +849,7 @@ describe('ipc teams handlers', () => {
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => { it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
new Promise(() => undefined) new Promise(() => undefined)
); );

View file

@ -583,9 +583,214 @@ describe('TeamProvisioningService', () => {
await expect(svc.warmup()).resolves.not.toThrow(); await expect(svc.warmup()).resolves.not.toThrow();
expect(spawnCli).toHaveBeenCalled(); expect(spawnCli).toHaveBeenCalled();
}); });
}); });
describe('team launch notifications', () => { describe('team launch notifications', () => {
it('does not fire incomplete notification for pending-only teammates still joining', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const run = {
runId: 'run-beacon-desk-15',
teamName: 'beacon-desk-15',
isLaunch: true,
request: {
cwd: tempClaudeRoot,
displayName: 'beacon-desk-15',
},
expectedMembers: ['alice', 'bob', 'jack', 'tom'],
allEffectiveMembers: [
{ name: 'alice' },
{ name: 'bob' },
{ name: 'jack' },
{ name: 'tom' },
],
memberSpawnStatuses: new Map(
['alice', 'bob', 'jack', 'tom'].map((name) => [
name,
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
}),
])
),
};
const pendingSnapshot = {
expectedMembers: ['alice', 'bob', 'jack', 'tom'],
members: Object.fromEntries(
['alice', 'bob', 'jack', 'tom'].map((name) => [
name,
{
name,
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
},
])
),
summary: {
confirmedCount: 0,
pendingCount: 4,
failedCount: 0,
runtimeAlivePendingCount: 4,
},
};
await (svc as any).fireTeamLaunchIncompleteNotification(
run,
[],
pendingSnapshot.summary,
pendingSnapshot
);
} finally {
NotificationManager.resetInstance();
}
expect(addTeamNotification).not.toHaveBeenCalled();
});
it('ignores stale failed summary without concrete failed member evidence', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const run = {
runId: 'run-stale-summary',
teamName: 'stale-summary-team',
isLaunch: true,
request: {
cwd: tempClaudeRoot,
displayName: 'stale-summary-team',
},
expectedMembers: ['alice'],
allEffectiveMembers: [{ name: 'alice' }],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
}),
],
]),
};
const staleSnapshot = {
expectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
},
},
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
};
await (svc as any).fireTeamLaunchIncompleteNotification(
run,
[],
staleSnapshot.summary,
staleSnapshot
);
} finally {
NotificationManager.resetInstance();
}
expect(addTeamNotification).not.toHaveBeenCalled();
});
it('prefers live confirmed evidence over stale persisted failed member evidence', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const run = {
runId: 'run-live-confirmed',
teamName: 'live-confirmed-team',
isLaunch: true,
request: {
cwd: tempClaudeRoot,
displayName: 'live-confirmed-team',
},
expectedMembers: ['alice'],
allEffectiveMembers: [{ name: 'alice' }],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
}),
],
]),
};
const staleSnapshot = {
expectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'stale failure',
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
},
},
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
};
await (svc as any).fireTeamLaunchIncompleteNotification(
run,
[],
staleSnapshot.summary,
staleSnapshot
);
} finally {
NotificationManager.resetInstance();
}
expect(addTeamNotification).not.toHaveBeenCalled();
});
it('uses live member evidence instead of stale summary for incomplete launch copy', async () => { it('uses live member evidence instead of stale summary for incomplete launch copy', async () => {
const { NotificationManager } = const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager'); await import('@main/services/infrastructure/NotificationManager');
@ -12752,6 +12957,7 @@ describe('TeamProvisioningService', () => {
bootstrapConfirmed: false, bootstrapConfirmed: false,
hardFailure: false, hardFailure: false,
hardFailureReason: undefined, hardFailureReason: undefined,
livenessKind: 'registered_only',
diagnostics: [ diagnostics: [
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.', 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
], ],
@ -12767,6 +12973,7 @@ describe('TeamProvisioningService', () => {
agentToolAccepted: true, agentToolAccepted: true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
runtimeAlive: true, runtimeAlive: true,
livenessKind: 'confirmed_bootstrap',
}); });
const persisted = JSON.parse( const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
@ -12776,6 +12983,7 @@ describe('TeamProvisioningService', () => {
bootstrapConfirmed: true, bootstrapConfirmed: true,
runtimeAlive: true, runtimeAlive: true,
runtimeSessionId: 'ses-tom', runtimeSessionId: 'ses-tom',
livenessKind: 'confirmed_bootstrap',
}); });
}); });
@ -12884,6 +13092,74 @@ describe('TeamProvisioningService', () => {
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success'); expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
}); });
it('normalizes stale confirmed OpenCode secondary liveness from committed bootstrap evidence', async () => {
const teamName = 'zz-opencode-committed-overlay-normalizes-liveness';
const leadSessionId = 'lead-session';
const laneId = 'secondary:opencode:tom';
const runId = 'opencode-run-tom';
writeMembersMeta(teamName, [{ name: 'tom', providerId: 'opencode' }]);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId,
state: 'active',
});
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId,
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
laneId,
runId,
observedAt: '2026-04-22T12:00:00.000Z',
source: 'runtime_bootstrap_checkin',
},
],
});
writeLaunchState(teamName, leadSessionId, {
tom: {
providerId: 'opencode',
laneId,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
runtimeSessionId: 'ses-tom',
livenessKind: 'registered_only',
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
diagnostics: ['opencode_bootstrap_evidence_committed'],
},
});
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.tom).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
livenessKind: 'confirmed_bootstrap',
});
const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
);
expect(persisted.members.tom).toMatchObject({
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
livenessKind: 'confirmed_bootstrap',
});
});
it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => { it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime'; const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';

View file

@ -92,6 +92,29 @@ describe('buildTeamRuntimeDisplayRows', () => {
}); });
}); });
it('treats confirmed spawn bootstrap as running even if stale status is still waiting', () => {
const rows = buildTeamRuntimeDisplayRows({
members: [{ name: 'alice' }],
spawnStatuses: {
alice: createSpawnStatus({
status: 'waiting',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
livenessKind: 'registered_only',
}),
},
});
expect(rows[0]).toMatchObject({
memberName: 'alice',
state: 'running',
source: 'spawn-status',
stateReason: 'Bootstrap confirmed',
actionsAllowed: false,
});
});
it('maps a non-alive runtime with error diagnostics to degraded', () => { it('maps a non-alive runtime with error diagnostics to degraded', () => {
const rows = buildTeamRuntimeDisplayRows({ const rows = buildTeamRuntimeDisplayRows({
members: [{ name: 'alice' }], members: [{ name: 'alice' }],

View file

@ -385,6 +385,7 @@ describe('memberHelpers spawn-aware presence', () => {
spawnLaunchState: 'confirmed_alive', spawnLaunchState: 'confirmed_alive',
spawnLivenessSource: 'process', spawnLivenessSource: 'process',
spawnRuntimeAlive: true, spawnRuntimeAlive: true,
spawnBootstrapConfirmed: true,
runtimeEntry: { runtimeEntry: {
memberName: 'alice', memberName: 'alice',
alive: false, alive: false,
@ -399,10 +400,38 @@ describe('memberHelpers spawn-aware presence', () => {
isTeamProvisioning: false, isTeamProvisioning: false,
}) })
).toMatchObject({ ).toMatchObject({
presenceLabel: 'registered', presenceLabel: 'online',
launchVisualState: 'registered_only', launchVisualState: null,
launchStatusLabel: 'registered', launchStatusLabel: null,
dotClass: expect.stringContaining('bg-zinc-400'), dotClass: expect.stringContaining('bg-emerald-400'),
});
expect(
buildMemberLaunchPresentation({
member,
spawnStatus: 'waiting',
spawnLaunchState: 'confirmed_alive',
spawnLivenessSource: 'process',
spawnRuntimeAlive: true,
spawnBootstrapConfirmed: true,
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeAdvisory: undefined,
isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
})
).toMatchObject({
presenceLabel: 'online',
launchVisualState: null,
launchStatusLabel: null,
dotClass: expect.stringContaining('bg-emerald-400'),
}); });
}); });

View file

@ -114,6 +114,30 @@ describe('resolveMemberRuntimeSummary', () => {
); );
}); });
it('hides stale runtime memory when the spawn state is explicitly offline', () => {
const member = createMember({ model: 'gpt-5.4-mini' });
const spawnEntry = createSpawnEntry({
status: 'offline',
launchState: 'failed_to_start',
runtimeAlive: false,
bootstrapConfirmed: false,
});
const runtimeEntry = {
memberName: 'alice',
alive: true,
restartable: false,
providerId: 'opencode',
pid: 333,
pidSource: 'opencode_bridge',
rssBytes: 97.3 * 1024 * 1024,
updatedAt: '2026-04-24T12:00:00.000Z',
};
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry, runtimeEntry as never)).toBe(
'5.4 Mini · Medium · Codex'
);
});
it('keeps the persisted backend lane visible in the runtime summary', () => { it('keeps the persisted backend lane visible in the runtime summary', () => {
const member = createMember({ model: 'gpt-5.4-mini' }); const member = createMember({ model: 'gpt-5.4-mini' });