test: stabilize claude work sync live e2e

This commit is contained in:
777genius 2026-04-29 18:52:12 +03:00
parent 4d0cce601a
commit 7cb6cddee8
2 changed files with 51 additions and 12 deletions

View file

@ -18,6 +18,7 @@ import {
type MemberWorkSyncReconcileContext, type MemberWorkSyncReconcileContext,
RuntimeTurnSettledIngestor, RuntimeTurnSettledIngestor,
type RuntimeTurnSettledDrainSummary, type RuntimeTurnSettledDrainSummary,
type RuntimeTurnSettledTargetResolverPort,
} from '../../core/application'; } from '../../core/application';
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
@ -104,6 +105,7 @@ export function createMemberWorkSyncFeature(deps: {
listLifecycleActiveTeamNames?: () => Promise<string[]>; listLifecycleActiveTeamNames?: () => Promise<string[]>;
nudgeSideEffectsEnabled?: boolean; nudgeSideEffectsEnabled?: boolean;
queueQuietWindowMs?: number; queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
logger?: MemberWorkSyncLoggerPort; logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade { }): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter(); const clock = new SystemClockAdapter();
@ -126,10 +128,12 @@ export function createMemberWorkSyncFeature(deps: {
paths: runtimeTurnSettledSpoolPaths, paths: runtimeTurnSettledSpoolPaths,
}); });
const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash); const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash);
const runtimeTurnSettledTargetResolver = new TeamRuntimeTurnSettledTargetResolver({ const runtimeTurnSettledTargetResolver =
teamSource: deps.configReader, deps.runtimeTurnSettledTargetResolver ??
membersMetaStore: deps.membersMetaStore, new TeamRuntimeTurnSettledTargetResolver({
}); teamSource: deps.configReader,
membersMetaStore: deps.membersMetaStore,
});
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
const busySignal = new MemberWorkSyncToolActivityBusySignal(); const busySignal = new MemberWorkSyncToolActivityBusySignal();

View file

@ -46,7 +46,8 @@ const liveDescribe =
: describe.skip; : describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_MODEL = 'haiku'; const DEFAULT_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;
liveDescribe('Member work sync Claude Stop hook live e2e', () => { liveDescribe('Member work sync Claude Stop hook live e2e', () => {
let tempDir: string; let tempDir: string;
@ -145,17 +146,40 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
svc = new TeamProvisioningService(); svc = new TeamProvisioningService();
const activeService = svc; const activeService = svc;
const teamDataService = new TeamDataService(); const teamDataService = new TeamDataService();
const configReader = new TeamConfigReader();
const membersMetaStore = new TeamMembersMetaStore();
feature = createMemberWorkSyncFeature({ feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(), configReader,
taskReader: new TeamTaskReader(), taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(), kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(), membersMetaStore,
isTeamActive: (name) => isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!], listLifecycleActiveTeamNames: async () => [teamName!],
nudgeSideEffectsEnabled: false, nudgeSideEffectsEnabled: false,
queueQuietWindowMs: 500, 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) => activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event) feature!.noteTeamChange(event)
@ -180,11 +204,11 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
cwd: projectPath, cwd: projectPath,
providerId: 'anthropic', providerId: 'anthropic',
model, model,
effort: DEFAULT_EFFORT,
skipPermissions: true, skipPermissions: true,
prompt: [ prompt: [
'Keep launch work minimal.', 'Keep launch work minimal and wait for the explicit live-test instruction.',
'If you receive a task, follow task instructions exactly.', 'Do not inspect tasks or send messages until the next user turn.',
'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.',
].join(' '), ].join(' '),
members: [ members: [
{ {
@ -192,6 +216,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
role: 'Developer', role: 'Developer',
providerId: 'anthropic', providerId: 'anthropic',
model, model,
effort: DEFAULT_EFFORT,
}, },
], ],
}, },
@ -232,8 +257,18 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
].join('\n'), ].join('\n'),
}); });
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); await activeService.sendMessageToTeam(
expect(relay.relayed).toBeGreaterThan(0); 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 () => { await waitUntil(async () => {
const status = await feature!.getStatus({ teamName: teamName!, memberName }); const status = await feature!.getStatus({ teamName: teamName!, memberName });