fix(team): stabilize mixed launch runtime states
This commit is contained in:
parent
d20fe2a538
commit
cde85c0396
16 changed files with 634 additions and 55 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue