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,
spawnLivenessSource: spawn?.livenessSource,
spawnRuntimeAlive: spawn?.runtimeAlive,
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
spawnBootstrapStalled: spawn?.bootstrapStalled,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,

View file

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

View file

@ -228,6 +228,24 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
const logger = createLogger('IPC:teams');
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;
function isPlainObject(value: unknown): value is Record<string, unknown> {
@ -2473,7 +2491,11 @@ function buildMessageDeliveryText(
opts: {
actionMode?: AgentActionMode;
isLeadRecipient: boolean;
memberName?: string;
messageId?: string;
protocol?: VisibleDirectReplyProtocol;
replyRecipient?: string;
teamName?: string;
}
): string {
const hiddenBlocks: string[] = [];
@ -2482,22 +2504,49 @@ function buildMessageDeliveryText(
hiddenBlocks.push(actionModeBlock);
}
if (!opts.isLeadRecipient) {
const replyRecipient =
const rawReplyRecipient =
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
? opts.replyRecipient.trim()
: 'user';
const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`;
const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user';
const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient;
const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`;
const protocol = opts.protocol ?? 'send_message';
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: The destination must be exactly to="${replyRecipient}".`,
'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.',
];
hiddenBlocks.push(
[
AGENT_BLOCK_OPEN,
`You received a direct message from ${senderDescriptor} via the UI.`,
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
'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.',
...replyInstructionLines,
`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'
...(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: "Принял, сейчас уточню и вернусь с ответом.").',
'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
? payload.from.trim()
: 'user';
const isOpenCodeRecipient =
!isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName));
const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient;
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, {
actionMode,
isLeadRecipient,
memberName,
protocol: directReplyProtocol,
replyRecipient,
teamName: tn,
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
});
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
const result = await getTeamDataService().sendMessage(tn, {
member: memberName,
text: inboxText,
summary: payload.summary,
from: payload.from,
from: storedFrom,
actionMode,
source: 'user_sent',
taskRefs: validatedTaskRefs.value,
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
});
// Teammate inbox relay DISABLED (2026-03-23).

View file

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

View file

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

View file

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

View file

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

View file

@ -132,6 +132,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeEntry,
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 {
memberName,
state: 'running',
stateReason: spawn.runtimeDiagnostic ?? 'Spawn status is online',
stateReason: spawn.runtimeDiagnostic ?? 'Bootstrap confirmed',
source: 'spawn-status',
updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
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 {
const runtimePid = getFinitePid(runtime.runtimePid);
if (runtimePid != null) return `runtime pid ${runtimePid}`;

View file

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

View file

@ -11,6 +11,35 @@ import type {
TeamProviderId,
} 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(
providerId: TeamProviderId,
backendLabel: string | undefined
@ -101,10 +130,9 @@ export function resolveMemberRuntimeSummary(
configuredProvider,
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
);
const memorySuffix =
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0
? ` · ${formatBytes(runtimeEntry.rssBytes)}`
: '';
const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
: '';
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;

View file

@ -14,6 +14,7 @@ import type {
SendMessageResult,
TeamViewSnapshot,
TeamCreateRequest,
TeamProviderId,
TeamProvisioningProgress,
} from '@shared/types/team';
@ -217,7 +218,16 @@ describe('ipc teams handlers', () => {
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => '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' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined),
@ -269,6 +279,14 @@ describe('ipc teams handlers', () => {
pushLiveLeadProcessMessage: vi.fn(),
relayLeadInboxMessages: 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),
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
relayed: 0,
@ -348,6 +366,8 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.findLogsForTask.mockReset();
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
initializeTeamHandlers(
service as never,
@ -645,8 +665,63 @@ describe('ipc teams handlers', () => {
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 () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 1,
attempted: 1,
@ -699,7 +774,7 @@ describe('ipc teams handlers', () => {
});
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 0,
attempted: 1,
@ -734,7 +809,7 @@ describe('ipc teams handlers', () => {
});
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
relayed: 0,
attempted: 1,
@ -774,7 +849,7 @@ describe('ipc teams handlers', () => {
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
vi.useFakeTimers();
try {
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
new Promise(() => undefined)
);

View file

@ -583,9 +583,214 @@ describe('TeamProvisioningService', () => {
await expect(svc.warmup()).resolves.not.toThrow();
expect(spawnCli).toHaveBeenCalled();
});
});
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 () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
@ -12752,6 +12957,7 @@ describe('TeamProvisioningService', () => {
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
livenessKind: 'registered_only',
diagnostics: [
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
],
@ -12767,6 +12973,7 @@ describe('TeamProvisioningService', () => {
agentToolAccepted: true,
bootstrapConfirmed: true,
runtimeAlive: true,
livenessKind: 'confirmed_bootstrap',
});
const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
@ -12776,6 +12983,7 @@ describe('TeamProvisioningService', () => {
bootstrapConfirmed: true,
runtimeAlive: true,
runtimeSessionId: 'ses-tom',
livenessKind: 'confirmed_bootstrap',
});
});
@ -12884,6 +13092,74 @@ describe('TeamProvisioningService', () => {
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 () => {
allowConsoleLogs();
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', () => {
const rows = buildTeamRuntimeDisplayRows({
members: [{ name: 'alice' }],

View file

@ -385,6 +385,7 @@ describe('memberHelpers spawn-aware presence', () => {
spawnLaunchState: 'confirmed_alive',
spawnLivenessSource: 'process',
spawnRuntimeAlive: true,
spawnBootstrapConfirmed: true,
runtimeEntry: {
memberName: 'alice',
alive: false,
@ -399,10 +400,38 @@ describe('memberHelpers spawn-aware presence', () => {
isTeamProvisioning: false,
})
).toMatchObject({
presenceLabel: 'registered',
launchVisualState: 'registered_only',
launchStatusLabel: 'registered',
dotClass: expect.stringContaining('bg-zinc-400'),
presenceLabel: 'online',
launchVisualState: null,
launchStatusLabel: null,
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', () => {
const member = createMember({ model: 'gpt-5.4-mini' });