agent-ecosystem/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts
2026-04-29 22:43:20 +03:00

349 lines
14 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 { 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 = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;
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();
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 };
},
},
});
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,
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,
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 });
await activeService.sendMessageToTeam(
teamName,
[
`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.`,
`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}"].`,
'After that stop. Do not complete the task. Do not send a user-visible message.',
].join('\n')
);
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<string, unknown> | 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
);
});