fix(team): keep codex member work visible during recovery
This commit is contained in:
parent
e5ace8c7cb
commit
f19ed93d4b
10 changed files with 535 additions and 61 deletions
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.37",
|
"version": "0.0.38",
|
||||||
"sourceRef": "v0.0.37",
|
"sourceRef": "v0.0.38",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/agent-teams-ai",
|
"releaseRepository": "777genius/agent-teams-ai",
|
||||||
"releaseTag": "v2.0.0",
|
"releaseTag": "v2.0.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.37.tar.gz",
|
"file": "agent-teams-runtime-darwin-arm64-v0.0.38.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"darwin-x64": {
|
||||||
"file": "agent-teams-runtime-darwin-x64-v0.0.37.tar.gz",
|
"file": "agent-teams-runtime-darwin-x64-v0.0.38.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"linux-x64": {
|
||||||
"file": "agent-teams-runtime-linux-x64-v0.0.37.tar.gz",
|
"file": "agent-teams-runtime-linux-x64-v0.0.38.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.37.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.38.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasWorkSyncActiveRuntime,
|
||||||
|
isRuntimeEntryActiveForWorkSync,
|
||||||
|
} from '../memberWorkSyncTeamActivity';
|
||||||
|
|
||||||
|
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
function createRuntimeEntry(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
|
||||||
|
return {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: true,
|
||||||
|
restartable: true,
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
|
pid: 46773,
|
||||||
|
updatedAt: '2026-05-18T19:44:48.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeSnapshot(
|
||||||
|
members: Record<string, TeamAgentRuntimeEntry>
|
||||||
|
): TeamAgentRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'signal-ops-6',
|
||||||
|
updatedAt: '2026-05-18T19:44:48.000Z',
|
||||||
|
runId: null,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('member work sync team activity', () => {
|
||||||
|
it('treats a verified runtime process as active', () => {
|
||||||
|
expect(isRuntimeEntryActiveForWorkSync(createRuntimeEntry())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a confirmed bootstrap runtime entry as active', () => {
|
||||||
|
expect(
|
||||||
|
isRuntimeEntryActiveForWorkSync(
|
||||||
|
createRuntimeEntry({
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
runtimeLastSeenAt: '2026-05-18T19:44:47.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat inactive liveness diagnostics as active by themselves', () => {
|
||||||
|
for (const livenessKind of [
|
||||||
|
'permission_blocked',
|
||||||
|
'runtime_process_candidate',
|
||||||
|
'shell_only',
|
||||||
|
'registered_only',
|
||||||
|
'stale_metadata',
|
||||||
|
'not_found',
|
||||||
|
] as const) {
|
||||||
|
expect(isRuntimeEntryActiveForWorkSync(createRuntimeEntry({ livenessKind }))).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a runtime candidate as active until it is alive', () => {
|
||||||
|
expect(
|
||||||
|
isRuntimeEntryActiveForWorkSync(
|
||||||
|
createRuntimeEntry({
|
||||||
|
alive: false,
|
||||||
|
livenessKind: 'runtime_process_candidate',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects an active runtime among stale members', () => {
|
||||||
|
expect(
|
||||||
|
hasWorkSyncActiveRuntime(
|
||||||
|
createRuntimeSnapshot({
|
||||||
|
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||||
|
bob: createRuntimeEntry({ memberName: 'bob', livenessKind: 'runtime_process' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when no member has active runtime evidence', () => {
|
||||||
|
expect(
|
||||||
|
hasWorkSyncActiveRuntime(
|
||||||
|
createRuntimeSnapshot({
|
||||||
|
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||||
|
bob: createRuntimeEntry({
|
||||||
|
memberName: 'bob',
|
||||||
|
alive: false,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing snapshots as inactive', () => {
|
||||||
|
expect(hasWorkSyncActiveRuntime(null)).toBe(false);
|
||||||
|
expect(hasWorkSyncActiveRuntime(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
type RuntimeLivenessKind = NonNullable<TeamAgentRuntimeEntry['livenessKind']>;
|
||||||
|
|
||||||
|
const WORK_SYNC_INACTIVE_LIVENESS_KINDS = new Set<RuntimeLivenessKind>([
|
||||||
|
'permission_blocked',
|
||||||
|
'runtime_process_candidate',
|
||||||
|
'shell_only',
|
||||||
|
'registered_only',
|
||||||
|
'stale_metadata',
|
||||||
|
'not_found',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isRuntimeEntryActiveForWorkSync(
|
||||||
|
entry: Pick<TeamAgentRuntimeEntry, 'alive' | 'livenessKind'> | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (entry?.alive !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!entry.livenessKind) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorkSyncActiveRuntime(
|
||||||
|
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return Object.values(snapshot?.members ?? {}).some(isRuntimeEntryActiveForWorkSync);
|
||||||
|
}
|
||||||
|
|
@ -8,3 +8,7 @@ export {
|
||||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||||
createMemberWorkSyncFeature,
|
createMemberWorkSyncFeature,
|
||||||
} from './composition/createMemberWorkSyncFeature';
|
} from './composition/createMemberWorkSyncFeature';
|
||||||
|
export {
|
||||||
|
hasWorkSyncActiveRuntime,
|
||||||
|
isRuntimeEntryActiveForWorkSync,
|
||||||
|
} from './composition/memberWorkSyncTeamActivity';
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import {
|
import {
|
||||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||||
createMemberWorkSyncFeature,
|
createMemberWorkSyncFeature,
|
||||||
|
hasWorkSyncActiveRuntime,
|
||||||
type MemberWorkSyncFeatureFacade,
|
type MemberWorkSyncFeatureFacade,
|
||||||
registerMemberWorkSyncIpc,
|
registerMemberWorkSyncIpc,
|
||||||
removeMemberWorkSyncIpc,
|
removeMemberWorkSyncIpc,
|
||||||
|
|
@ -1780,27 +1781,55 @@ async function initializeServices(): Promise<void> {
|
||||||
logger: createLogger('Feature:RecentProjects'),
|
logger: createLogger('Feature:RecentProjects'),
|
||||||
});
|
});
|
||||||
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
||||||
|
const memberWorkSyncLogger = createLogger('Feature:MemberWorkSync');
|
||||||
|
const hasMemberWorkSyncRuntimeActivity = async (teamName: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const snapshot = await teamProvisioningService.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
return hasWorkSyncActiveRuntime(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
memberWorkSyncLogger.warn('member work sync runtime activity check failed', {
|
||||||
|
teamName,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const isTeamActiveForMemberWorkSync = async (teamName: string): Promise<boolean> => {
|
||||||
|
if (
|
||||||
|
teamProvisioningService.isTeamAlive(teamName) ||
|
||||||
|
teamProvisioningService.hasProvisioningRun(teamName)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||||
|
};
|
||||||
|
const canDispatchMemberWorkSyncNudges = async (teamName: string): Promise<boolean> => {
|
||||||
|
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||||
|
};
|
||||||
|
const listMemberWorkSyncLifecycleActiveTeamNames = async (): Promise<string[]> => {
|
||||||
|
const activeTeamNames: string[] = [];
|
||||||
|
for (const team of await teamDataService.listTeams()) {
|
||||||
|
if (team.deletedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await isTeamActiveForMemberWorkSync(team.teamName)) {
|
||||||
|
activeTeamNames.push(team.teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return activeTeamNames;
|
||||||
|
};
|
||||||
memberWorkSyncFeature = createMemberWorkSyncFeature({
|
memberWorkSyncFeature = createMemberWorkSyncFeature({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
configReader: new TeamConfigReader(),
|
configReader: new TeamConfigReader(),
|
||||||
taskReader: new TeamTaskReader(),
|
taskReader: new TeamTaskReader(),
|
||||||
kanbanManager: new TeamKanbanManager(),
|
kanbanManager: new TeamKanbanManager(),
|
||||||
membersMetaStore: new TeamMembersMetaStore(),
|
membersMetaStore: new TeamMembersMetaStore(),
|
||||||
isTeamActive: (teamName) =>
|
isTeamActive: isTeamActiveForMemberWorkSync,
|
||||||
teamProvisioningService.isTeamAlive(teamName) ||
|
canDispatchNudges: canDispatchMemberWorkSyncNudges,
|
||||||
teamProvisioningService.hasProvisioningRun(teamName),
|
listLifecycleActiveTeamNames: listMemberWorkSyncLifecycleActiveTeamNames,
|
||||||
canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName),
|
|
||||||
listLifecycleActiveTeamNames: async () => {
|
|
||||||
const teams = await teamDataService.listTeams();
|
|
||||||
return teams
|
|
||||||
.filter(
|
|
||||||
(team) =>
|
|
||||||
!team.deletedAt &&
|
|
||||||
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
|
||||||
teamProvisioningService.hasProvisioningRun(team.teamName))
|
|
||||||
)
|
|
||||||
.map((team) => team.teamName);
|
|
||||||
},
|
|
||||||
extraBusySignals: [
|
extraBusySignals: [
|
||||||
{
|
{
|
||||||
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
|
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||||
|
|
@ -1984,7 +2013,7 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logger: createLogger('Feature:MemberWorkSync'),
|
logger: memberWorkSyncLogger,
|
||||||
});
|
});
|
||||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||||
memberWorkSyncFeature
|
memberWorkSyncFeature
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
isOpenCodeRelaunchActionable,
|
isOpenCodeRelaunchActionable,
|
||||||
|
shouldDisplayMemberCurrentTask,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
|
@ -650,8 +651,18 @@ export const MemberCard = memo(function MemberCard({
|
||||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||||
);
|
);
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
|
const showTaskActivity = shouldDisplayMemberCurrentTask({
|
||||||
|
member,
|
||||||
|
isTeamAlive,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
|
});
|
||||||
|
const visibleCurrentTask = showTaskActivity ? currentTask : null;
|
||||||
|
const visibleReviewTask = showTaskActivity ? reviewTask : null;
|
||||||
const presentationMember =
|
const presentationMember =
|
||||||
member.currentTaskId && !currentTask
|
member.currentTaskId && !visibleCurrentTask
|
||||||
? {
|
? {
|
||||||
...member,
|
...member,
|
||||||
currentTaskId: null,
|
currentTaskId: null,
|
||||||
|
|
@ -716,11 +727,11 @@ export const MemberCard = memo(function MemberCard({
|
||||||
workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.',
|
workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.',
|
||||||
member.gitBranch ? `Branch: ${member.gitBranch}` : null,
|
member.gitBranch ? `Branch: ${member.gitBranch}` : null,
|
||||||
].filter((line): line is string => Boolean(line));
|
].filter((line): line is string => Boolean(line));
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = visibleCurrentTask ?? visibleReviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = visibleCurrentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(visibleCurrentTask.id)}`
|
||||||
: reviewTask
|
: visibleReviewTask
|
||||||
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
? `Reviewing task: #${deriveTaskDisplayId(visibleReviewTask.id)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const runtimeTelemetryTitle = buildRuntimeTelemetryTitle(runtimeEntry);
|
const runtimeTelemetryTitle = buildRuntimeTelemetryTitle(runtimeEntry);
|
||||||
const showRuntimeTelemetryTooltip = Boolean(runtimeTelemetryTitle);
|
const showRuntimeTelemetryTooltip = Boolean(runtimeTelemetryTitle);
|
||||||
|
|
@ -1060,9 +1071,9 @@ export const MemberCard = memo(function MemberCard({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{currentTask ? (
|
{visibleCurrentTask ? (
|
||||||
<CurrentTaskIndicator
|
<CurrentTaskIndicator
|
||||||
task={currentTask}
|
task={visibleCurrentTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
activityLabel="working on"
|
activityLabel="working on"
|
||||||
activityTimer={currentTaskTimer}
|
activityTimer={currentTaskTimer}
|
||||||
|
|
@ -1070,9 +1081,9 @@ export const MemberCard = memo(function MemberCard({
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{reviewTask ? (
|
{visibleReviewTask ? (
|
||||||
<CurrentTaskIndicator
|
<CurrentTaskIndicator
|
||||||
task={reviewTask}
|
task={visibleReviewTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
activityLabel={reviewTaskTimer ? 'reviewing' : 'review requested'}
|
activityLabel={reviewTaskTimer ? 'reviewing' : 'review requested'}
|
||||||
activityTimer={reviewTaskTimer}
|
activityTimer={reviewTaskTimer}
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
const showCopyDiagnostics =
|
const showCopyDiagnostics =
|
||||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
const reviewTaskCandidate: TeamTaskWithKanban | null = tasks
|
||||||
? (tasks.find(
|
? (tasks.find(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.reviewer === member.name &&
|
task.reviewer === member.name &&
|
||||||
|
|
@ -211,6 +211,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
getTeamTaskWorkflowColumn(task) === 'review'
|
getTeamTaskWorkflowColumn(task) === 'review'
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const reviewTask =
|
||||||
|
reviewTaskCandidate &&
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member,
|
||||||
|
isTeamAlive,
|
||||||
|
spawnStatus: spawnEntry?.status,
|
||||||
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
|
})
|
||||||
|
? reviewTaskCandidate
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard openDelay={300} closeDelay={200}>
|
<HoverCard openDelay={300} closeDelay={200}>
|
||||||
|
|
|
||||||
|
|
@ -769,32 +769,18 @@ export const MemberList = memo(function MemberList({
|
||||||
|
|
||||||
const isMemberActivityTimerRunning = useCallback(
|
const isMemberActivityTimerRunning = useCallback(
|
||||||
(
|
(
|
||||||
|
member: ResolvedTeamMember,
|
||||||
spawnEntry: MemberSpawnStatusEntry | undefined,
|
spawnEntry: MemberSpawnStatusEntry | undefined,
|
||||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (isTeamAlive === false) return false;
|
return shouldDisplayMemberCurrentTask({
|
||||||
if (
|
member,
|
||||||
spawnEntry?.status === 'offline' ||
|
isTeamAlive,
|
||||||
spawnEntry?.status === 'error' ||
|
spawnStatus: spawnEntry?.status,
|
||||||
spawnEntry?.status === 'skipped'
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
) {
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
return false;
|
runtimeEntry,
|
||||||
}
|
});
|
||||||
if (spawnEntry?.runtimeAlive === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (runtimeEntry?.alive === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
runtimeEntry?.livenessKind === 'shell_only' ||
|
|
||||||
runtimeEntry?.livenessKind === 'registered_only' ||
|
|
||||||
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
|
||||||
runtimeEntry?.livenessKind === 'not_found'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
[isTeamAlive]
|
[isTeamAlive]
|
||||||
);
|
);
|
||||||
|
|
@ -827,7 +813,7 @@ export const MemberList = memo(function MemberList({
|
||||||
for (const member of activeMembers) {
|
for (const member of activeMembers) {
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||||
const running = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
|
const running = isMemberActivityTimerRunning(member, spawnEntry, runtimeEntry);
|
||||||
const currentTaskCandidate = member.currentTaskId
|
const currentTaskCandidate = member.currentTaskId
|
||||||
? (taskMap.get(member.currentTaskId) ?? null)
|
? (taskMap.get(member.currentTaskId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -948,8 +934,23 @@ export const MemberList = memo(function MemberList({
|
||||||
: null;
|
: null;
|
||||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
const reviewTask =
|
const reviewTask =
|
||||||
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
reviewCandidate &&
|
||||||
const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
|
reviewCandidate.id !== currentTask?.id &&
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member,
|
||||||
|
isTeamAlive,
|
||||||
|
spawnStatus: spawnEntry?.status,
|
||||||
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
|
})
|
||||||
|
? reviewCandidate
|
||||||
|
: null;
|
||||||
|
const activityTimerRunning = isMemberActivityTimerRunning(
|
||||||
|
member,
|
||||||
|
spawnEntry,
|
||||||
|
runtimeEntry
|
||||||
|
);
|
||||||
const currentTaskTimer = withActivityTimerRunId(
|
const currentTaskTimer = withActivityTimerRunId(
|
||||||
currentTask
|
currentTask
|
||||||
? deriveWorkActivityTimerAnchor(currentTask, {
|
? deriveWorkActivityTimerAnchor(currentTask, {
|
||||||
|
|
|
||||||
172
src/renderer/utils/__tests__/memberHelpers.test.ts
Normal file
172
src/renderer/utils/__tests__/memberHelpers.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildMemberLaunchPresentation, shouldDisplayMemberCurrentTask } from '../memberHelpers';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberLaunchState,
|
||||||
|
MemberSpawnStatus,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
function createMember(overrides: Partial<ResolvedTeamMember> = {}): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
name: 'alice',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: 'task-1',
|
||||||
|
taskCount: 1,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
role: 'developer',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLiveRuntime(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
|
||||||
|
return {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: true,
|
||||||
|
restartable: true,
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
|
pid: 12345,
|
||||||
|
rssBytes: 128 * 1024 * 1024,
|
||||||
|
updatedAt: '2026-05-18T19:45:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfirmedCodexSpawn(): {
|
||||||
|
spawnStatus: MemberSpawnStatus;
|
||||||
|
spawnLaunchState: MemberLaunchState;
|
||||||
|
spawnRuntimeAlive: boolean;
|
||||||
|
spawnBootstrapConfirmed: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
spawnBootstrapConfirmed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('member runtime presentation', () => {
|
||||||
|
it('hides Codex native task activity when no spawn or runtime snapshot has loaded', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember(),
|
||||||
|
isTeamAlive: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Codex native task activity when confirmed spawn state has no live runtime evidence', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember(),
|
||||||
|
isTeamAlive: true,
|
||||||
|
...createConfirmedCodexSpawn(),
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Codex native task activity visible when the runtime process is live', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember(),
|
||||||
|
isTeamAlive: true,
|
||||||
|
...createConfirmedCodexSpawn(),
|
||||||
|
runtimeEntry: createLiveRuntime(),
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Codex native task activity for runtime process candidates without verified process evidence', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember(),
|
||||||
|
isTeamAlive: true,
|
||||||
|
...createConfirmedCodexSpawn(),
|
||||||
|
runtimeEntry: createLiveRuntime({
|
||||||
|
livenessKind: 'runtime_process_candidate',
|
||||||
|
rssBytes: undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks stale confirmed Codex native spawn state as non-green runtime status', () => {
|
||||||
|
const presentation = buildMemberLaunchPresentation({
|
||||||
|
member: createMember(),
|
||||||
|
spawnLivenessSource: 'heartbeat',
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
...createConfirmedCodexSpawn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(presentation.launchVisualState).toBe('stale_runtime');
|
||||||
|
expect(presentation.presenceLabel).toBe('stale runtime');
|
||||||
|
expect(presentation.dotClass).toContain('bg-red-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks Codex native members without runtime snapshots as stale after launch settles', () => {
|
||||||
|
const presentation = buildMemberLaunchPresentation({
|
||||||
|
member: createMember(),
|
||||||
|
spawnStatus: undefined,
|
||||||
|
spawnLaunchState: undefined,
|
||||||
|
spawnRuntimeAlive: undefined,
|
||||||
|
spawnBootstrapConfirmed: undefined,
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(presentation.launchVisualState).toBe('stale_runtime');
|
||||||
|
expect(presentation.dotClass).toContain('bg-red-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Codex native activity until runtime evidence arrives', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember(),
|
||||||
|
isTeamAlive: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a global launch settling state keep stale Codex native status green', () => {
|
||||||
|
const presentation = buildMemberLaunchPresentation({
|
||||||
|
member: createMember(),
|
||||||
|
spawnLivenessSource: 'heartbeat',
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
...createConfirmedCodexSpawn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(presentation.launchVisualState).toBe('stale_runtime');
|
||||||
|
expect(presentation.dotClass).toContain('bg-red-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require runtime evidence for non-Codex teammates', () => {
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: createMember({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
providerBackendId: undefined,
|
||||||
|
}),
|
||||||
|
isTeamAlive: true,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -935,10 +935,13 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentRuntimeOfflineVisualState(
|
function getCurrentRuntimeOfflineVisualState(
|
||||||
|
member: ResolvedTeamMember,
|
||||||
runtimeEntry: TeamAgentRuntimeEntry | undefined,
|
runtimeEntry: TeamAgentRuntimeEntry | undefined,
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState: MemberLaunchState | undefined,
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
spawnRuntimeAlive: boolean | undefined
|
spawnRuntimeAlive: boolean | undefined,
|
||||||
|
spawnBootstrapConfirmed: boolean | undefined,
|
||||||
|
isTeamProvisioning: boolean | undefined
|
||||||
): MemberLaunchVisualState {
|
): MemberLaunchVisualState {
|
||||||
if (runtimeEntry?.livenessKind === 'registered_only') {
|
if (runtimeEntry?.livenessKind === 'registered_only') {
|
||||||
return 'registered_only';
|
return 'registered_only';
|
||||||
|
|
@ -963,9 +966,109 @@ function getCurrentRuntimeOfflineVisualState(
|
||||||
) {
|
) {
|
||||||
return 'stale_runtime';
|
return 'stale_runtime';
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
shouldTreatCodexNativeRuntimeAsOffline({
|
||||||
|
member,
|
||||||
|
runtimeEntry,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
isTeamProvisioning,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return 'stale_runtime';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCodexNativeProcessTeammate(member: ResolvedTeamMember): boolean {
|
||||||
|
if (isLeadMember(member)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
member.providerId === 'codex' &&
|
||||||
|
(member.providerBackendId == null || member.providerBackendId === 'codex-native')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLiveRuntimeProcessEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean {
|
||||||
|
if (runtimeEntry?.alive !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
runtimeEntry.livenessKind == null ||
|
||||||
|
runtimeEntry.livenessKind === 'runtime_process' ||
|
||||||
|
runtimeEntry.livenessKind === 'confirmed_bootstrap'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSpawnRuntimeLiveClaim({
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
}: {
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
spawnRuntimeAlive?: boolean;
|
||||||
|
spawnBootstrapConfirmed?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
spawnStatus === 'online' ||
|
||||||
|
spawnLaunchState === 'confirmed_alive' ||
|
||||||
|
spawnRuntimeAlive === true ||
|
||||||
|
spawnBootstrapConfirmed === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldTreatCodexNativeRuntimeAsOffline({
|
||||||
|
member,
|
||||||
|
runtimeEntry,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
isTeamProvisioning,
|
||||||
|
}: {
|
||||||
|
member: ResolvedTeamMember;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
spawnRuntimeAlive?: boolean;
|
||||||
|
spawnBootstrapConfirmed?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
if (!isCodexNativeProcessTeammate(member)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasLiveRuntimeProcessEvidence(runtimeEntry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isTeamProvisioning === true &&
|
||||||
|
runtimeEntry == null &&
|
||||||
|
!hasSpawnRuntimeLiveClaim({
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
runtimeEntry != null ||
|
||||||
|
hasSpawnRuntimeLiveClaim({
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
}) ||
|
||||||
|
spawnStatus == null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldDisplayMemberCurrentTask({
|
export function shouldDisplayMemberCurrentTask({
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -1011,6 +1114,9 @@ export function shouldDisplayMemberCurrentTask({
|
||||||
if (spawnRuntimeAlive === false) {
|
if (spawnRuntimeAlive === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isCodexNativeProcessTeammate(member) && !hasLiveRuntimeProcessEvidence(runtimeEntry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1205,10 +1311,13 @@ export function buildMemberLaunchPresentation({
|
||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
}): MemberLaunchPresentation {
|
}): MemberLaunchPresentation {
|
||||||
const currentRuntimeOfflineVisualState = getCurrentRuntimeOfflineVisualState(
|
const currentRuntimeOfflineVisualState = getCurrentRuntimeOfflineVisualState(
|
||||||
|
member,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
|
isTeamProvisioning
|
||||||
);
|
);
|
||||||
const hasConfirmedSpawnLaunch =
|
const hasConfirmedSpawnLaunch =
|
||||||
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue