diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 7a28bc3b..e6ff0e4f 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -49,6 +49,26 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_ const DEFAULT_MODEL = 'sonnet'; const DEFAULT_EFFORT = 'low' as const; +type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up'; + +interface ClaudeStopHookLiveScenarioContext { + marker: string; + memberName: string; + teamName: string; + controlUrl: string; + taskId?: string; +} + +interface ClaudeStopHookLiveScenario { + markerSuffix: string; + subjectPrefix: string; + expectedState: ClaudeStopHookLiveScenarioState; + expectedTaskStatus: 'in_progress' | 'completed'; + expectedMarkerText(marker: string): string; + buildTaskPromptLines(context: ClaudeStopHookLiveScenarioContext): string[]; + buildInstructionLines(context: Required): string[]; +} + liveDescribe('Member work sync Claude Stop hook live e2e', () => { let tempDir: string; let tempClaudeRoot: string; @@ -124,226 +144,293 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { } }); - 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!); + async function runClaudeStopHookLiveScenario( + scenario: ClaudeStopHookLiveScenario + ): Promise { + 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' - ); + const model = process.env.MEMBER_WORK_SYNC_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; + const startedAt = Date.now(); + const marker = `member-work-sync-claude-stop-live-${scenario.markerSuffix}-${startedAt}`; + const memberName = 'alice'; + teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`; + 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(); - const configReader = new TeamConfigReader(); - const membersMetaStore = new TeamMembersMetaStore(); - feature = createMemberWorkSyncFeature({ - teamsBasePath: getTeamsBasePath(), - configReader, - taskReader: new TeamTaskReader(), - kanbanManager: new TeamKanbanManager(), - membersMetaStore, - isTeamActive: (name) => - activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), - listLifecycleActiveTeamNames: async () => [teamName!], - nudgeSideEffectsEnabled: false, - queueQuietWindowMs: 500, - // Native Claude teammates are registered by the real lead process, but in this - // headless harness their bootstrap turn can finish before there is a durable - // member process to prompt. The live assertion below still uses a real Claude - // process, real MCP calls, and a real Stop hook payload; this seam keeps the - // test focused on hook ingestion instead of tmux liveness. - runtimeTurnSettledTargetResolver: { - resolve: async (event) => { - if (event.provider !== 'claude') { - return { ok: false, reason: 'unsupported_provider' }; - } - if (!teamName) { - return { ok: false, reason: 'missing_team' }; - } - const config = await configReader.getConfig(teamName); - const leadSessionId = config?.leadSessionId?.trim(); - if (!leadSessionId || event.sessionId !== leadSessionId) { - return { ok: false, reason: 'no_matching_member_session' }; - } - return { ok: true, teamName, memberName }; - }, + svc = new TeamProvisioningService(); + const activeService = svc; + const teamDataService = new TeamDataService(); + const configReader = new TeamConfigReader(); + const membersMetaStore = new TeamMembersMetaStore(); + feature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader, + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore, + isTeamActive: (name) => + activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), + listLifecycleActiveTeamNames: async () => [teamName!], + nudgeSideEffectsEnabled: false, + queueQuietWindowMs: 500, + // Native Claude teammates are registered by the real lead process, but in this + // headless harness their bootstrap turn can finish before there is a durable + // member process to prompt. These live assertions still use a real Claude + // process, real MCP calls, and real Stop hook payloads; this seam keeps the + // tests focused on hook ingestion instead of tmux liveness. + runtimeTurnSettledTargetResolver: { + resolve: async (event) => { + if (event.provider !== 'claude') { + return { ok: false, reason: 'unsupported_provider' }; + } + if (!teamName) { + return { ok: false, reason: 'missing_team' }; + } + const config = await configReader.getConfig(teamName); + const leadSessionId = config?.leadSessionId?.trim(); + if (!leadSessionId || event.sessionId !== leadSessionId) { + return { ok: false, reason: 'no_matching_member_session' }; + } + return { ok: true, teamName, memberName }; }, - }); - activeService.setTeamChangeEmitter((event: TeamChangeEvent) => - feature!.noteTeamChange(event) - ); - activeService.setRuntimeTurnSettledHookSettingsProvider((input) => - feature!.buildRuntimeTurnSettledHookSettings(input) - ); + }, + }); + 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' - ); + 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, - effort: DEFAULT_EFFORT, - skipPermissions: true, - prompt: [ - 'Keep launch work minimal and wait for the explicit live-test instruction.', - 'Do not inspect tasks or send messages until the next user turn.', - ].join(' '), - members: [ - { - name: memberName, - role: 'Developer', - providerId: 'anthropic', - model, - effort: DEFAULT_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); - - 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, + const progressEvents: TeamProvisioningProgress[] = []; + await activeService.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'anthropic', + model, + effort: DEFAULT_EFFORT, + skipPermissions: true, prompt: [ + 'Keep launch work minimal and wait for the explicit live-test instruction.', + 'Do not inspect tasks or send messages until the next user turn.', + ].join(' '), + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'anthropic', + model, + effort: DEFAULT_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); + + 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 taskPromptContext: ClaudeStopHookLiveScenarioContext = { + marker, + memberName, + teamName, + controlUrl: controlServer.baseUrl, + }; + const task = await teamDataService.createTask(teamName, { + subject: `${scenario.subjectPrefix} ${marker}`, + owner: memberName, + startImmediately: true, + prompt: scenario.buildTaskPromptLines(taskPromptContext).join('\n'), + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + + 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 activeService.sendMessageToTeam( + teamName, + scenario + .buildInstructionLines({ + ...taskPromptContext, + taskId: task.id, + }) + .join('\n') + ); + + await waitUntil(async () => { + await feature!.replayPendingReports([teamName!]); + const [status, tasks] = await Promise.all([ + feature!.getStatus({ teamName: teamName!, memberName }), + new TeamTaskReader().getTasks(teamName!), + ]); + const currentTask = tasks.find((candidate) => candidate.id === task.id); + const expectedMarker = scenario.expectedMarkerText(marker); + const hasMarkerComment = currentTask?.comments?.some( + (comment) => comment.author === memberName && comment.text.includes(expectedMarker) + ); + return Boolean( + hasMarkerComment && + currentTask?.status === scenario.expectedTaskStatus && + status.report?.accepted && + status.report.state === scenario.expectedState + ); + }, 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(scenario.expectedState); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: scenario.expectedState, + }); + if (scenario.expectedState === 'caught_up') { + expect(finalStatus.agenda.items).toHaveLength(0); + } else { + expect(finalStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true); + } + expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + } + + it( + 'launches a real Claude teammate, accepts a still-working report, and ingests its Stop hook event', + async () => + runClaudeStopHookLiveScenario({ + markerSuffix: 'lease', + subjectPrefix: 'Member work sync Claude Stop hook live lease', + expectedState: 'still_working', + expectedTaskStatus: 'in_progress', + expectedMarkerText: (marker) => `${marker}:still-working`, + buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [ `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.`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", 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 }); - await activeService.sendMessageToTeam( - teamName, - [ + ], + buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ `Live member-work-sync validation instruction. Marker: ${marker}.`, `Use the board MCP tools as member "${memberName}" for this validation.`, - `Call task_get for taskId "${task.id}", then task_start.`, + `Call task_get for taskId "${taskId}", then task_start.`, `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 taskIds ["${task.id}"].`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and taskIds ["${taskId}"].`, 'After that stop. Do not complete the task. Do not send a user-visible message.', - ].join('\n') - ); + ], + }), + 420_000 + ); - 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, - }); - }, + it( + 'launches a real Claude teammate, completes work, reports caught-up, and ingests its Stop hook event', + async () => + runClaudeStopHookLiveScenario({ + markerSuffix: 'caught-up', + subjectPrefix: 'Member work sync Claude Stop hook live caught-up', + expectedState: 'caught_up', + expectedTaskStatus: 'completed', + expectedMarkerText: (marker) => `${marker}:completed`, + buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [ + `This is a live member-work-sync caught-up validation task. Marker: ${marker}.`, + 'Do not edit files.', + 'Call task_start for this task.', + `Add one task comment containing exactly: ${marker}:completed.`, + 'Then call task_complete for this task.', + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, + 'After that stop. Do not send a user-visible message.', + ], + buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ + `Live member-work-sync caught-up validation instruction. Marker: ${marker}.`, + `Use the board MCP tools as member "${memberName}" for this validation.`, + `Call task_get for taskId "${taskId}", then task_start.`, + `Add one task comment containing exactly: ${marker}:completed.`, + `Call task_complete for taskId "${taskId}".`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, + 'After that stop. Do not send a user-visible message.', + ], + }), 420_000 ); });