From 4d0cce601ac9d821cf3e8ed41cdf03efbe3b6ff5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 18:30:36 +0300 Subject: [PATCH] test: add member work sync live e2e coverage --- .../createMemberWorkSyncFeature.ts | 2 + .../MemberWorkSyncClaudeStopHook.live.test.ts | 314 ++++++++++++++++++ .../team/MemberWorkSyncCodex.live.test.ts | 284 ++++++++++++++++ .../team/memberWorkSyncLiveHarness.ts | 215 ++++++++++++ 4 files changed, 815 insertions(+) create mode 100644 test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts create mode 100644 test/main/services/team/MemberWorkSyncCodex.live.test.ts create mode 100644 test/main/services/team/memberWorkSyncLiveHarness.ts diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 5e6d1393..a917f583 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -103,6 +103,7 @@ export function createMemberWorkSyncFeature(deps: { isTeamActive?: (teamName: string) => Promise | boolean; listLifecycleActiveTeamNames?: () => Promise; nudgeSideEffectsEnabled?: boolean; + queueQuietWindowMs?: number; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -166,6 +167,7 @@ export function createMemberWorkSyncFeature(deps: { } }, isTeamActive: deps.isTeamActive ?? (() => true), + ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), logger: deps.logger, }); const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts new file mode 100644 index 00000000..14f98299 --- /dev/null +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -0,0 +1,314 @@ +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 { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; +import { TeamKanbanManager } from '../../../../src/main/services/team/TeamKanbanManager'; +import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { + assertExecutable, + 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 liveDescribe = + process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' && + Boolean(process.env.ANTHROPIC_API_KEY?.trim()) + ? describe + : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const DEFAULT_MODEL = 'haiku'; + +liveDescribe('Member work sync Claude Stop hook live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let tempHome: string; + let previousCliPath: string | undefined; + let previousCliFlavor: string | undefined; + let previousControlUrl: string | undefined; + let previousNudgeFlag: string | undefined; + let previousDisableAppBootstrap: string | undefined; + let previousDisableRuntimeBootstrap: string | undefined; + let previousHome: string | undefined; + let previousUserProfile: string | undefined; + let svc: TeamProvisioningService | 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-claude-stop-live-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + tempHome = path.join(tempDir, 'home'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + await fs.mkdir(tempHome, { 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; + previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; + previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + previousHome = process.env.HOME; + previousUserProfile = process.env.USERPROFILE; + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + 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.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; + delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; + + svc = null; + feature = null; + controlServer = null; + teamName = null; + }); + + afterEach(async () => { + if (svc && teamName) { + await svc.stopTeam(teamName).catch(() => undefined); + } + svc?.setTeamChangeEmitter(null); + svc?.setControlApiBaseUrlResolver(null); + svc?.setRuntimeTurnSettledHookSettingsProvider(null); + await feature?.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('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); + restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); + restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); + restoreEnv('HOME', previousHome); + restoreEnv('USERPROFILE', previousUserProfile); + setClaudeBasePathOverride(null); + if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') { + console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it( + 'launches a real Claude teammate, accepts its work-sync report, and ingests its Stop hook event', + 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_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; + const marker = `member-work-sync-claude-stop-live-${Date.now()}`; + const memberName = 'alice'; + teamName = `member-work-sync-claude-stop-${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 Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + 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!], + nudgeSideEffectsEnabled: false, + queueQuietWindowMs: 500, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + activeService.setRuntimeTurnSettledHookSettingsProvider((input) => + feature!.buildRuntimeTurnSettledHookSettings(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: 'anthropic', + model, + skipPermissions: true, + prompt: [ + 'Keep launch work minimal.', + 'If you receive a task, follow task instructions exactly.', + 'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.', + ].join(' '), + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'anthropic', + model, + }, + ], + }, + (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); + + await expect( + fs.stat( + path.join( + getTeamsBasePath(), + '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh' + ) + ) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + + const task = await teamDataService.createTask(teamName, { + subject: `Member work sync Claude Stop hook 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.', + `Add one task comment containing exactly: ${marker}:still-working.`, + `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 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 status = await feature!.getStatus({ teamName: teamName!, memberName }); + return ( + status.memberName === memberName && + status.providerId === 'anthropic' && + status.agenda.items.some((item) => item.taskId === task.id) && + status.shadow?.wouldNudge === true + ); + }, 30_000); + + await waitUntil(async () => { + 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); + }, 300_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled; + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some(({ meta }) => { + const event = meta.event as Record | undefined; + return ( + meta.outcome === 'enqueued' && + meta.teamName === teamName && + meta.memberName === memberName && + event?.provider === 'claude' + ); + }); + }, 180_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + await waitUntil( + async () => feature!.getQueueDiagnostics().reconciled > beforeTurnSettledReconciled, + 30_000, + 500 + ); + + 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 expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 420_000 + ); +}); diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts new file mode 100644 index 00000000..9664a531 --- /dev/null +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -0,0 +1,284 @@ +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, + formatMemberWorkSyncDiagnostics, + formatProgressDump, + 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 previousNudgeFlag: string | undefined; + 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; + 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; + previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; + + 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.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; + + svc = null; + feature = null; + controlServer = null; + teamName = null; + }); + + afterEach(async () => { + if (svc && teamName) { + await svc.stopTeam(teamName).catch(() => undefined); + } + svc?.setControlApiBaseUrlResolver(null); + await feature?.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('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); + setClaudeBasePathOverride(null); + if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') { + console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it( + 'lets a real Codex teammate report still-working for the current actionable agenda without automatic nudges', + 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 }, + ] = 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'), + ]); + + 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!], + nudgeSideEffectsEnabled: false, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + 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.', + 'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.', + ].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.', + `Add one task comment containing exactly: ${marker}:still-working.`, + `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 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 status = await feature!.getStatus({ teamName: teamName!, memberName }); + return ( + status.memberName === memberName && + status.providerId === 'codex' && + status.agenda.items.some((item) => item.taskId === task.id) && + status.shadow?.wouldNudge === true + ); + }, 30_000); + + await waitUntil(async () => { + 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 expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 360_000 + ); +}); diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts new file mode 100644 index 00000000..7696361c --- /dev/null +++ b/test/main/services/team/memberWorkSyncLiveHarness.ts @@ -0,0 +1,215 @@ +import { constants as fsConstants, promises as fs } from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts'; +import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +export interface MemberWorkSyncLiveControlServer { + baseUrl: string; + close(): Promise; +} + +export async function startMemberWorkSyncControlServer( + feature: MemberWorkSyncFeatureFacade +): Promise { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + if ( + request.method === 'GET' && + parts.length === 5 && + parts[0] === 'api' && + parts[1] === 'teams' && + parts[3] === 'member-work-sync' + ) { + const payload = await feature.getStatus({ + teamName: parts[2], + memberName: parts[4], + }); + sendJson(response, 200, payload); + return; + } + if ( + request.method === 'POST' && + parts.length === 5 && + parts[0] === 'api' && + parts[1] === 'teams' && + parts[3] === 'member-work-sync' && + parts[4] === 'report' + ) { + const body = (await readRequestJson(request)) as MemberWorkSyncReportRequest; + const payload = await feature.report({ + ...body, + teamName: parts[2], + source: 'mcp', + }); + sendJson( + response, + payload.accepted ? 200 : 400, + payload.accepted ? payload : { error: payload.code } + ); + return; + } + sendJson(response, 404, { error: `Unhandled ${request.method} ${url.pathname}` }); + } catch (error) { + sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); + } + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind member work sync control server'); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +export function restoreEnv(name: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } +} + +export async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.X_OK); +} + +export async function waitUntil( + predicate: () => Promise, + timeoutMs: number, + pollMs = 2_000, + getDiagnostics?: () => Promise +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) { + return; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + const suffix = + lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : ''; + const diagnostics = getDiagnostics ? `\n${await getDiagnostics().catch(String)}` : ''; + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${diagnostics}`); +} + +export function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string { + return progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); +} + +export async function formatMemberWorkSyncDiagnostics(input: { + feature: MemberWorkSyncFeatureFacade; + teamName: string; + memberName: string; + taskId?: string; +}): Promise { + const [{ TeamTaskReader }] = await Promise.all([ + import('../../../../src/main/services/team/TeamTaskReader'), + ]); + const [status, metrics, tasks] = await Promise.all([ + input.feature.getStatus({ teamName: input.teamName, memberName: input.memberName }), + input.feature.getMetrics({ teamName: input.teamName }), + input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]), + ]); + const task = input.taskId + ? tasks.find((candidate) => candidate.id === input.taskId) + : undefined; + return [ + 'Member work sync live diagnostics:', + JSON.stringify( + { + state: status.state, + diagnostics: status.diagnostics, + agendaFingerprint: status.agenda.fingerprint, + agendaItems: status.agenda.items.map((item) => ({ + taskId: item.taskId, + subject: item.subject, + assignee: item.assignee, + kind: item.kind, + })), + report: status.report, + shadow: status.shadow, + queue: input.feature.getQueueDiagnostics(), + comments: task?.comments?.map((comment) => ({ + author: comment.author, + text: comment.text, + })), + recentEvents: metrics.recentEvents.slice(-12), + }, + null, + 2 + ), + ].join('\n'); +} + +export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise< + Array<{ + filePath: string; + meta: Record; + }> +> { + const processedDir = path.join( + teamsBasePath, + '.member-work-sync', + 'runtime-hooks', + 'processed' + ); + const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []); + const metas = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.meta.json')) + .map(async (entry) => { + const filePath = path.join(processedDir, entry.name); + const raw = await fs.readFile(filePath, 'utf8'); + return { filePath, meta: JSON.parse(raw) as Record }; + }) + ); + return metas.sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +async function readRequestJson(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString('utf8').trim(); + return raw ? JSON.parse(raw) : {}; +} + +function sendJson(response: http.ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify(payload)); +}