agent-ecosystem/test/main/services/team/MemberWorkSyncCodex.live.test.ts

1529 lines
58 KiB
TypeScript

import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createMemberWorkSyncFeature,
type MemberWorkSyncFeatureFacade,
} from '../../../../src/features/member-work-sync/main';
import {
buildCodexTrustedProjectConfigOverrides,
buildCodexWorkspaceTrustSettingsArgs,
type WorkspaceTrustArgsOnlyPlanRequest,
type WorkspaceTrustCoordinator,
type WorkspaceTrustLaunchArgPatch,
type WorkspaceTrustLaunchArgTargetSurface,
} from '../../../../src/features/workspace-trust/main';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import {
assertExecutable,
FatalWaitError,
formatMemberWorkSyncDiagnostics,
formatProgressDump,
type MemberWorkSyncLiveControlServer,
readRuntimeTurnSettledProcessedMetas,
restoreEnv,
startMemberWorkSyncControlServer,
waitUntil,
} from './memberWorkSyncLiveHarness';
import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types';
vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({
NotificationManager: {
getInstance: () => ({
addTeamNotification: vi.fn(async () => undefined),
}),
},
}));
const hasCodexApiKey = Boolean(
process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim()
);
const allowConnectedChatGptAccount =
process.env.MEMBER_WORK_SYNC_CODEX_ALLOW_CONNECTED_ACCOUNT === '1';
const liveDescribe =
process.env.MEMBER_WORK_SYNC_CODEX_LIVE === '1' &&
(hasCodexApiKey || allowConnectedChatGptAccount)
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'gpt-5.4-mini';
const DEFAULT_EFFORT = 'low' as const;
const LIVE_CODEX_WORKSPACE_TRUST_TARGET_SURFACES: WorkspaceTrustLaunchArgTargetSurface[] = [
'primary_provider_args',
'cross_provider_member_args',
'provider_facts_probe',
'default_model_probe',
];
const VITEST_HOME_PREFIX = 'agent-teams-vitest-home-';
liveDescribe('Member work sync Codex live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
let previousCliPath: string | undefined;
let previousCliFlavor: string | undefined;
let previousControlUrl: string | undefined;
let previousCodexHome: string | undefined;
let previousCodexIgnoreUserConfig: string | undefined;
let codexHomeDir: string;
let ownsCodexHomeDir: boolean;
let codexAccountFeature: {
getSnapshot(): Promise<unknown>;
dispose(): Promise<void>;
} | null;
let providerConnectionService: {
setCodexAccountFeature(feature: { getSnapshot(): Promise<unknown> } | null): void;
} | null;
let svc: {
stopTeam(teamName: string): Promise<unknown>;
isTeamAlive(teamName: string): boolean;
hasProvisioningRun(teamName: string): boolean;
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void;
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void;
setRuntimeTurnSettledEnvironmentProvider(
provider:
| ((input: { provider: 'claude' | 'codex' | 'opencode' }) => Promise<Record<string, string> | null>)
| null
): void;
relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>;
createTeam(
request: Parameters<
InstanceType<
typeof import('../../../../src/main/services/team/TeamProvisioningService').TeamProvisioningService
>['createTeam']
>[0],
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<unknown>;
} | null;
let feature: MemberWorkSyncFeatureFacade | null;
let controlServer: MemberWorkSyncLiveControlServer | null;
let teamName: string | null;
const createLiveNudgeDeliveryWake = (activeService: NonNullable<typeof svc>) => ({
schedule: async (input: { teamName: string; memberName: string; delayMs?: number }) => {
const timer = setTimeout(() => {
void activeService
.relayInboxFileToLiveRecipient(input.teamName, input.memberName)
.catch(() => undefined);
}, Math.max(0, input.delayMs ?? 0));
timer.unref?.();
},
});
const relayInboxIfNotAlreadyConsumed = async (
activeService: NonNullable<typeof svc>,
memberName: string
): Promise<void> => {
const activeTeamName = teamName;
if (!activeTeamName) {
return;
}
const relay = await activeService.relayInboxFileToLiveRecipient(activeTeamName, memberName);
if (relay.relayed === 0) {
console.info(
`[MemberWorkSyncCodex.live] manual inbox relay returned 0 for ${activeTeamName}/${memberName}; waiting for watcher or wake delivery proof`
);
}
};
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-codex-live-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
previousCodexHome = process.env.CODEX_HOME;
previousCodexIgnoreUserConfig = process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG;
const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey();
if (shouldUseConnectedAccountHome) {
codexHomeDir = resolveConnectedCodexHome(previousCodexHome);
ownsCodexHomeDir = false;
await fs.access(codexHomeDir);
} else {
const codexHomeRoot = path.resolve('temp', 'member-work-sync-codex-live');
await fs.mkdir(codexHomeRoot, { recursive: true });
codexHomeDir = await fs.mkdtemp(path.join(codexHomeRoot, 'codex-home-'));
ownsCodexHomeDir = true;
}
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CODEX_HOME = codexHomeDir;
process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG = 'true';
codexAccountFeature = null;
providerConnectionService = null;
svc = null;
feature = null;
controlServer = null;
teamName = null;
});
afterEach(async () => {
if (svc && teamName) {
await svc.stopTeam(teamName).catch(() => undefined);
}
svc?.setControlApiBaseUrlResolver(null);
svc?.setRuntimeTurnSettledEnvironmentProvider(null);
providerConnectionService?.setCodexAccountFeature(null);
await feature?.dispose().catch(() => undefined);
await codexAccountFeature?.dispose().catch(() => undefined);
await controlServer?.close().catch(() => undefined);
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
restoreEnv('CODEX_HOME', previousCodexHome);
restoreEnv('CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG', previousCodexIgnoreUserConfig);
setClaudeBasePathOverride(null);
if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') {
console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`);
console.info(`[MemberWorkSyncCodex.live] preserved CODEX_HOME: ${codexHomeDir}`);
} else {
await fs.rm(tempDir, { recursive: true, force: true });
if (ownsCodexHomeDir) {
await fs.rm(codexHomeDir, { recursive: true, force: true });
}
}
});
it(
'lets a real Codex teammate report still-working for the current actionable agenda with active nudge guards',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const marker = `member-work-sync-codex-live-${Date.now()}`;
teamName = `member-work-sync-codex-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
const [
{ TeamProvisioningService },
{ TeamDataService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamDataService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const),
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
svc = new TeamProvisioningService();
const activeService = svc;
const teamDataService = new TeamDataService();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a task, follow task instructions exactly.',
'Do not call member_work_sync_status until a task instruction or member_work_sync_nudge provides exact teamName, memberName, and controlUrl.',
].join(' '),
members: [],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName =
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
config?.members?.[0]?.name?.trim() ||
'team-lead';
const task = await teamDataService.createTask(teamName, {
subject: `Member work sync live lease ${marker}`,
owner: memberName,
startImmediately: true,
prompt: [
`This is a live member-work-sync validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.',
'Call task_start for this task.',
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
`Only after member_work_sync_report is accepted, add one task comment containing exactly: ${marker}:still-working.`,
'After that stop. Do not send a user-visible message.',
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
const preRelayStatus = await feature.refreshStatus({ teamName, memberName });
expect(preRelayStatus.memberName).toBe(memberName);
expect(preRelayStatus.providerId).toBe('codex');
expect(preRelayStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true);
expect(preRelayStatus.shadow?.wouldNudge).toBe(true);
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
const status = await feature!.getStatus({ teamName: teamName!, memberName });
if (status.report?.accepted && status.report.state === 'still_working') {
return true;
}
const tasks = await new TeamTaskReader().getTasks(teamName!);
const currentTask = tasks.find((candidate) => candidate.id === task.id);
const hasMarkerComment = currentTask?.comments?.some((comment) =>
comment.text.includes(`${marker}:still-working`)
);
return Boolean(hasMarkerComment && status.report?.accepted);
}, 240_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const [finalStatus, metrics] = await Promise.all([
feature.getStatus({ teamName, memberName }),
feature.getMetrics({ teamName }),
]);
expect(finalStatus.state).toBe('still_working');
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'still_working',
});
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
return metas.some(
({ meta }) =>
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
'codex' &&
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
teamName
);
}, 60_000);
const postReportDispatch = await feature.dispatchDueNudges([teamName]);
expect(postReportDispatch.delivered).toBe(0);
expect(postReportDispatch.retryable).toBe(0);
expect(postReportDispatch.terminal).toBe(0);
expect(postReportDispatch.claimed).toBe(postReportDispatch.superseded);
expect(postReportDispatch.claimed).toBeLessThanOrEqual(1);
},
360_000
);
it(
'delivers a real work-sync nudge to a Codex teammate and accepts the follow-up report',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const marker = `member-work-sync-codex-nudge-${Date.now()}`;
teamName = `member-work-sync-codex-nudge-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex nudge live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
const [
{ TeamProvisioningService },
{ TeamDataService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamDataService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: 'chatgpt' as const,
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
svc = new TeamProvisioningService();
const activeService = svc;
const teamDataService = new TeamDataService();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a member_work_sync_nudge, do not complete the task.',
'For a member_work_sync_nudge, call member_work_sync_status first, then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds for the current agenda.',
'After reporting, stop without a user-visible message.',
].join(' '),
members: [],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName =
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
config?.members?.[0]?.name?.trim() ||
'team-lead';
await seedShadowReadyMetrics({ teamName, memberName });
const task = await teamDataService.createTask(teamName, {
subject: `Member work sync live nudge ${marker}`,
owner: memberName,
startImmediately: false,
prompt: [
`This is a live member-work-sync nudge validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.',
'Only report still_working if member-work-sync asks you to synchronize.',
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
await waitUntil(async () => {
const status = await feature!.refreshStatus({ teamName: teamName!, memberName });
if (!status.agenda.items.some((item) => item.taskId === task.id)) {
return false;
}
await feature!.dispatchDueNudges([teamName!]);
return true;
}, 60_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
const status = await feature!.getStatus({ teamName: teamName!, memberName });
return status.report?.accepted === true && status.report.state === 'still_working';
}, 240_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const finalStatus = await feature.getStatus({ teamName, memberName });
expect(finalStatus.state).toBe('still_working');
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'still_working',
});
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
return metas.some(
({ meta }) =>
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
'codex' &&
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
teamName
);
}, 60_000);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0,
delivered: 0,
});
},
420_000
);
it(
'wakes a real Codex teammate when runtime member meta omits provider metadata under noisy metrics',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const requestedMemberName = 'NickName';
const marker = `member-work-sync-codex-runtime-meta-${Date.now()}`;
teamName = `member-work-sync-codex-runtime-meta-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex runtime meta live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
await trustProjectInTempClaudeGlobalConfig({ claudeRoot: tempClaudeRoot, projectPath });
process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG = 'false';
if (ownsCodexHomeDir) {
await trustProjectInOwnedCodexHome({ codexHomeDir, projectPath });
}
const [
{ TeamProvisioningService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamTaskWriter },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamTaskWriter'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const),
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
const provisioningService = new TeamProvisioningService();
provisioningService.setWorkspaceTrustCoordinator(createCodexOnlyWorkspaceTrustCoordinator());
svc = provisioningService;
const activeService = provisioningService;
const taskReader = new TeamTaskReader();
const membersMetaStore = new TeamMembersMetaStore();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader,
kanbanManager: new TeamKanbanManager(),
membersMetaStore,
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a member_work_sync_nudge, do not complete the task.',
'For a member_work_sync_nudge, call member_work_sync_status first.',
'Then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds for the current agenda.',
`After member_work_sync_report is accepted, add one task comment containing exactly: ${marker}:still-working.`,
'After that stop without a user-visible message.',
].join(' '),
members: [
{
name: requestedMemberName,
role: 'developer',
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
},
],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName = config?.members
?.find((member) => sameMemberName(member.name, requestedMemberName))
?.name?.trim();
expect(memberName).toBeTruthy();
expect(
config?.members?.find((member) => sameMemberName(member.name, memberName!))
).toMatchObject({
providerId: 'codex',
});
await stripMemberProviderMetadataFromMembersMeta({
teamName,
memberName: memberName!,
fallbackRole: 'developer',
});
expect(
(await membersMetaStore.getMembers(teamName)).find((member) =>
sameMemberName(member.name, memberName!)
)
).toMatchObject({
name: memberName,
providerId: undefined,
providerBackendId: undefined,
model: undefined,
effort: undefined,
});
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const diagnostics = feature!.getQueueDiagnostics();
return diagnostics.queued === 0 && diagnostics.running === 0;
}, 60_000, 1_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
})
);
const createdAt = new Date().toISOString();
const taskId = `runtime-meta-${Date.now()}`;
const displayId = String(Date.now()).slice(-8);
await new TeamTaskWriter().createTask(teamName, {
id: taskId,
displayId,
subject: `Member work sync live runtime meta ${marker}`,
description: 'Verify native stale recovery when runtime member meta lacks provider fields.',
owner: memberName!,
createdBy: 'user',
status: 'in_progress',
projectPath,
createdAt,
updatedAt: createdAt,
});
feature.noteTeamChange({ type: 'task', teamName, taskId });
let agendaFingerprint = '';
await waitUntil(async () => {
const status = await feature!.refreshStatus({ teamName: teamName!, memberName: memberName! });
if (!status.agenda.items.some((item) => item.taskId === taskId)) {
return false;
}
expect(status).toMatchObject({
state: 'needs_sync',
providerId: 'codex',
diagnostics: expect.arrayContaining(['no_current_report']),
});
expect(status.agenda.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
taskId,
reason: 'owned_in_progress_task',
evidence: expect.objectContaining({ status: 'in_progress' }),
}),
])
);
agendaFingerprint = status.agenda.fingerprint;
return true;
}, 60_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
taskId,
})
);
await waitUntil(async () => {
const diagnostics = feature!.getQueueDiagnostics();
return diagnostics.queued === 0 && diagnostics.running === 0;
}, 30_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
taskId,
})
);
const stableStatus = await feature.refreshStatus({
teamName,
memberName: memberName!,
});
expect(stableStatus.providerId).toBe('codex');
expect(stableStatus.agenda.fingerprint).toBe(agendaFingerprint);
expect(
(await readInboxMessages(teamName, memberName!)).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
)
).toHaveLength(0);
await seedNativeStaleBlockingMetrics({
teamName,
memberName: memberName!,
agendaFingerprint,
});
feature.noteTeamChange({ type: 'task', teamName, taskId });
await waitUntil(async () => {
const diagnostics = feature!.getQueueDiagnostics();
return diagnostics.queued === 0 && diagnostics.running === 0;
}, 30_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
taskId,
})
);
expect((await feature.getStatus({ teamName, memberName: memberName! })).providerId).toBe(
'codex'
);
await waitUntil(async () => {
const nudges = (await readInboxMessages(teamName!, memberName!)).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
return nudges.length === 1;
}, 60_000, 1_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
taskId,
})
);
const metrics = await feature.getMetrics({ teamName });
expect(metrics.phase2Readiness.reasons).toContain('would_nudge_rate_high');
const journalPath = path.join(
getTeamsBasePath(),
teamName,
'members',
memberName!,
'.member-work-sync',
'journal.jsonl'
);
const journal = await fs.readFile(journalPath, 'utf8');
const nudgeOutcomes = journal
.trim()
.split('\n')
.map((line) => JSON.parse(line) as { event?: string; reason?: string })
.filter((event) => event.event === 'nudge_skipped' || event.event === 'nudge_delivered');
expect(nudgeOutcomes).toContainEqual(expect.objectContaining({ event: 'nudge_delivered' }));
expect(nudgeOutcomes.at(-1)).toMatchObject({ event: 'nudge_delivered' });
await relayInboxIfNotAlreadyConsumed(activeService, memberName!);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
const status = await feature!.getStatus({ teamName: teamName!, memberName: memberName! });
return status.report?.accepted === true && status.report.state === 'still_working';
}, 240_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName: memberName!,
taskId,
})
);
const finalStatus = await feature.getStatus({ teamName, memberName: memberName! });
expect(finalStatus.state).toBe('still_working');
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'still_working',
});
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
return metas.some(
({ meta }) =>
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
'codex' &&
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
teamName
);
}, 60_000);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
delivered: 0,
});
},
480_000
);
it(
'lets a real Codex teammate complete the task and report caught-up after the board clears',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const marker = `member-work-sync-codex-complete-${Date.now()}`;
teamName = `member-work-sync-codex-complete-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex complete live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
const [
{ TeamProvisioningService },
{ TeamDataService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamDataService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const),
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
svc = new TeamProvisioningService();
const activeService = svc;
const teamDataService = new TeamDataService();
const taskReader = new TeamTaskReader();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader,
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a task, follow task instructions exactly.',
'Use member_work_sync_status and member_work_sync_report whenever the task asks you to synchronize work state.',
].join(' '),
members: [],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName =
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
config?.members?.[0]?.name?.trim() ||
'team-lead';
await seedShadowReadyMetrics({ teamName, memberName });
const task = await teamDataService.createTask(teamName, {
subject: `Member work sync live completion ${marker}`,
owner: memberName,
startImmediately: true,
prompt: [
`This is a live member-work-sync completion validation task. Marker: ${marker}.`,
'Do not edit files.',
'Call task_start for this task.',
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
'After that, call task_complete for this task.',
`Then call member_work_sync_status again with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
'If the returned agenda has no items, call member_work_sync_report with state "caught_up", no taskIds, and the exact agendaFingerprint/reportToken returned by that second status call.',
`Only after the caught_up report is accepted, add one task comment containing exactly: ${marker}:completed-and-caught-up.`,
'After that stop. Do not send a user-visible message.',
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
await feature!.drainRuntimeTurnSettledEvents();
const [tasks, status] = await Promise.all([
taskReader.getTasks(teamName!),
feature!.refreshStatus({ teamName: teamName!, memberName }),
]);
const currentTask = tasks.find((candidate) => candidate.id === task.id);
const hasCompletionMarker = currentTask?.comments?.some((comment) =>
comment.text.includes(`${marker}:completed-and-caught-up`)
);
return Boolean(
currentTask?.status === 'completed' &&
hasCompletionMarker &&
status.state === 'caught_up' &&
status.agenda.items.length === 0 &&
status.report?.accepted === true &&
status.report.state === 'caught_up'
);
}, 300_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const [tasks, finalStatus, metrics] = await Promise.all([
taskReader.getTasks(teamName),
feature.getStatus({ teamName, memberName }),
feature.getMetrics({ teamName }),
]);
const completedTask = tasks.find((candidate) => candidate.id === task.id);
expect(completedTask?.status).toBe('completed');
expect(finalStatus.state).toBe('caught_up');
expect(finalStatus.agenda.items).toEqual([]);
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'caught_up',
});
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0,
delivered: 0,
});
},
480_000
);
});
async function readFatalRuntimeMessage(teamName: string): Promise<string | null> {
const sentMessagesPath = path.join(getTeamsBasePath(), teamName, 'sentMessages.json');
let raw: string;
try {
raw = await fs.readFile(sentMessagesPath, 'utf8');
} catch {
return null;
}
let messages: unknown;
try {
messages = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(messages)) {
return null;
}
for (const message of messages) {
if (!message || typeof message !== 'object') {
continue;
}
const text = (message as { text?: unknown }).text;
if (typeof text !== 'string') {
continue;
}
if (
text.includes('Codex native exec exited') ||
text.includes('Codex native error:') ||
text.includes('Codex native turn failed:')
) {
return text;
}
}
return null;
}
function hasLiveCodexApiKey(): boolean {
return Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim());
}
function resolveConnectedCodexHome(previousCodexHome: string | undefined): string {
const explicit = process.env.MEMBER_WORK_SYNC_CODEX_CONNECTED_HOME?.trim();
if (explicit) {
return path.resolve(explicit);
}
const previous = previousCodexHome?.trim();
if (previous) {
return path.resolve(previous);
}
return path.join(os.userInfo().homedir, '.codex');
}
async function trustProjectInOwnedCodexHome(input: {
codexHomeDir: string;
projectPath: string;
}): Promise<void> {
const [override] = buildCodexTrustedProjectConfigOverrides([input.projectPath], {
maxOverrides: 1,
});
if (!override) {
return;
}
await fs.mkdir(input.codexHomeDir, { recursive: true });
await fs.appendFile(path.join(input.codexHomeDir, 'config.toml'), `\n${override}\n`, 'utf8');
}
async function trustProjectInTempClaudeGlobalConfig(input: {
claudeRoot: string;
projectPath: string;
}): Promise<void> {
const projectRealPath = await fs.realpath(input.projectPath).catch(() => input.projectPath);
const projects = Object.fromEntries(
[...new Set([input.projectPath, projectRealPath])].map((projectPath) => [
projectPath,
{
allowedTools: [],
mcpContextUris: [],
mcpServers: {},
enabledMcpjsonServers: [],
disabledMcpjsonServers: [],
projectOnboardingSeenCount: 0,
hasClaudeMdExternalIncludesApproved: false,
hasClaudeMdExternalIncludesWarningShown: false,
hasTrustDialogAccepted: true,
},
])
);
const configPaths = [path.join(input.claudeRoot, '.claude.json')];
const homeDir = process.env.HOME?.trim();
if (homeDir && path.basename(homeDir).startsWith(VITEST_HOME_PREFIX)) {
configPaths.push(path.join(homeDir, '.claude.json'));
}
for (const configPath of configPaths) {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify({ projects }, null, 2)}\n`, 'utf8');
}
}
function createCodexOnlyWorkspaceTrustCoordinator(): WorkspaceTrustCoordinator {
return {
async planArgsOnly(request) {
return { launchArgPatches: buildLiveCodexWorkspaceTrustPatches(request) };
},
async planFull(request) {
return {
workspaces: request.workspaces,
launchArgPatches: buildLiveCodexWorkspaceTrustPatches(request),
};
},
async execute(plan) {
return {
id: 'member-work-sync-codex-live-workspace-trust',
provider: 'claude',
status: 'skipped',
workspaceIds: plan.workspaces.map((workspace) => workspace.id),
evidence: ['live test injects Codex native trusted-project settings'],
};
},
};
}
function buildLiveCodexWorkspaceTrustPatches(
request: WorkspaceTrustArgsOnlyPlanRequest
): WorkspaceTrustLaunchArgPatch[] {
if (
!request.featureFlags.enabled ||
!request.featureFlags.codexArgs ||
!request.providers.includes('codex')
) {
return [];
}
const configKeys = request.workspaces.flatMap((workspace) => [
workspace.configKeyCwd,
workspace.realCwd,
...(workspace.gitRootConfigKey ? [workspace.gitRootConfigKey] : []),
]);
const overrides = buildCodexTrustedProjectConfigOverrides(configKeys);
const args = buildCodexWorkspaceTrustSettingsArgs(overrides);
if (args.length === 0) {
return [];
}
const workspaceIds = request.workspaces.map((workspace) => workspace.id);
return (request.targetSurfaces ?? LIVE_CODEX_WORKSPACE_TRUST_TARGET_SURFACES).map((surface) => ({
id: `member-work-sync-codex-live-workspace-trust:${surface}`,
owner: 'workspace-trust',
targetProvider: 'codex',
targetSurface: surface,
dialect: 'claude-codex-runtime-settings',
args,
dedupeKey: `member-work-sync-codex-live-workspace-trust:${surface}:${overrides.join('|')}`,
sourceWorkspaceIds: workspaceIds,
reason: 'Trust the live e2e project for Codex native headless teammate startup.',
}));
}
function sameMemberName(left: string | undefined, right: string | undefined): boolean {
return left?.trim().toLowerCase() === right?.trim().toLowerCase();
}
async function stripMemberProviderMetadataFromMembersMeta(input: {
teamName: string;
memberName: string;
fallbackRole: string;
}): Promise<void> {
const metaPath = path.join(getTeamsBasePath(), input.teamName, 'members.meta.json');
const raw = await fs.readFile(metaPath, 'utf8').catch(() => '{"version":1,"members":[]}');
const parsed = JSON.parse(raw) as { providerBackendId?: unknown; members?: unknown };
const sourceMembers = Array.isArray(parsed.members) ? parsed.members : [];
let found = false;
const members = sourceMembers.flatMap((member): Record<string, unknown>[] => {
if (!member || typeof member !== 'object') {
return [];
}
const source = member as Record<string, unknown>;
const name = typeof source.name === 'string' ? source.name.trim() : '';
if (!name) {
return [];
}
if (!sameMemberName(name, input.memberName)) {
return [source];
}
found = true;
const stripped: Record<string, unknown> = { name };
for (const key of ['role', 'workflow', 'isolation', 'agentType', 'color', 'agentId', 'cwd']) {
if (typeof source[key] === 'string' && source[key].trim()) {
stripped[key] = source[key];
}
}
for (const key of ['joinedAt', 'removedAt']) {
if (typeof source[key] === 'number') {
stripped[key] = source[key];
}
}
return [stripped];
});
if (!found) {
members.push({
name: input.memberName,
role: input.fallbackRole,
agentType: 'general-purpose',
joinedAt: Date.now(),
});
}
const payload = {
version: 1,
...(typeof parsed.providerBackendId === 'string'
? { providerBackendId: parsed.providerBackendId }
: {}),
members,
};
await fs.writeFile(metaPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
async function seedShadowReadyMetrics(input: {
teamName: string;
memberName: string;
}): Promise<void> {
const metricsPath = path.join(
getTeamsBasePath(),
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
const startMs = Date.now() - 2 * 60 * 60_000;
await fs.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:seed',
actionableCount: 0,
evaluatedAt: new Date(startMs).toISOString(),
providerId: 'codex',
},
},
recentEvents: Array.from({ length: 24 }, (_, index) => ({
id: `seed-status-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'caught_up',
agendaFingerprint: `agenda:v1:seed-${index}`,
recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(),
actionableCount: 0,
providerId: 'codex',
})),
},
null,
2
)}\n`,
'utf8'
);
}
async function seedNativeStaleBlockingMetrics(input: {
teamName: string;
memberName: string;
agendaFingerprint: string;
}): Promise<void> {
const metricsPath = path.join(
getTeamsBasePath(),
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
const nowMs = Date.now();
const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString();
await fs.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
actionableCount: 1,
evaluatedAt: staleObservedAt,
providerId: 'codex',
},
},
recentEvents: [
{
id: 'native-stale-status',
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
recordedAt: staleObservedAt,
actionableCount: 1,
providerId: 'codex',
},
...Array.from({ length: 12 }, (_, index) => ({
id: `native-stale-would-nudge-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'would_nudge',
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(),
actionableCount: 1,
providerId: 'codex',
})),
],
},
null,
2
)}\n`,
'utf8'
);
}
async function readInboxMessages(teamName: string, memberName: string): Promise<
Array<{
messageId?: string;
messageKind?: string;
text: string;
read?: boolean;
}>
> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`);
const raw = await fs.readFile(inboxPath, 'utf8').catch(() => '[]');
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((message): message is Record<string, unknown> =>
Boolean(message) && typeof message === 'object'
)
.flatMap((message) => {
const text = typeof message.text === 'string' ? message.text : '';
if (!text) {
return [];
}
return [
{
...(typeof message.messageId === 'string' ? { messageId: message.messageId } : {}),
...(typeof message.messageKind === 'string'
? { messageKind: message.messageKind }
: {}),
text,
...(typeof message.read === 'boolean' ? { read: message.read } : {}),
},
];
});
}