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 { getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; import { assertExecutable, FatalWaitError, formatMemberWorkSyncDiagnostics, formatProgressDump, readRuntimeTurnSettledProcessedMetas, restoreEnv, startMemberWorkSyncControlServer, type MemberWorkSyncLiveControlServer, 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'; const DEFAULT_MODEL = 'gpt-5.4-mini'; const DEFAULT_EFFORT = 'low' as const; 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; dispose(): Promise; } | null; let providerConnectionService: { setCodexAccountFeature(feature: { getSnapshot(): Promise } | null): void; } | null; let svc: { stopTeam(teamName: string): Promise; isTeamAlive(teamName: string): boolean; hasProvisioningRun(teamName: string): boolean; setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void; setControlApiBaseUrlResolver(resolver: (() => Promise) | null): void; setRuntimeTurnSettledEnvironmentProvider( provider: | ((input: { provider: 'claude' | 'codex' | 'opencode' }) => Promise | 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; } | null; let feature: MemberWorkSyncFeatureFacade | null; let controlServer: MemberWorkSyncLiveControlServer | null; let teamName: string | null; 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!], }); 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); const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); expect(relay.relayed).toBeGreaterThan(0); 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); await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ claimed: 0, delivered: 0, }); }, 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, }); 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!.getStatus({ teamName: teamName!, memberName }); if (!status.agenda.items.some((item) => item.taskId === task.id)) { return false; } const inbox = await readInboxMessages(teamName!, memberName); return inbox.some( (message) => message.messageKind === 'member_work_sync_nudge' && typeof message.messageId === 'string' && message.text.includes('Work sync check') ); }, 60_000, 500, async () => formatMemberWorkSyncDiagnostics({ feature: feature!, teamName: teamName!, memberName, taskId: task.id, }) ); const inbox = await readInboxMessages(teamName, memberName); const nudge = inbox.find((message) => message.messageKind === 'member_work_sync_nudge'); expect(nudge?.messageId).toBeTruthy(); const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); expect(relay.relayed).toBeGreaterThan(0); 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( '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, }); 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 }); const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); expect(relay.relayed).toBeGreaterThan(0); 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 { 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 seedShadowReadyMetrics(input: { teamName: string; memberName: string; }): Promise { 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 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 => 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 } : {}), }, ]; }); }