test: add member work sync live e2e coverage
This commit is contained in:
parent
05d9b5a9b1
commit
4d0cce601a
4 changed files with 815 additions and 0 deletions
|
|
@ -103,6 +103,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
);
|
||||
});
|
||||
284
test/main/services/team/MemberWorkSyncCodex.live.test.ts
Normal file
284
test/main/services/team/MemberWorkSyncCodex.live.test.ts
Normal file
|
|
@ -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<unknown>;
|
||||
isTeamAlive(teamName: string): boolean;
|
||||
hasProvisioningRun(teamName: string): boolean;
|
||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void;
|
||||
setControlApiBaseUrlResolver(resolver: (() => Promise<string | 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<unknown>;
|
||||
} | 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
|
||||
);
|
||||
});
|
||||
215
test/main/services/team/memberWorkSyncLiveHarness.ts
Normal file
215
test/main/services/team/memberWorkSyncLiveHarness.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
export async function startMemberWorkSyncControlServer(
|
||||
feature: MemberWorkSyncFeatureFacade
|
||||
): Promise<MemberWorkSyncLiveControlServer> {
|
||||
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<void>((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<void>((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<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
export async function waitUntil(
|
||||
predicate: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
pollMs = 2_000,
|
||||
getDiagnostics?: () => Promise<string>
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
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<string, unknown>;
|
||||
}>
|
||||
> {
|
||||
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<string, unknown> };
|
||||
})
|
||||
);
|
||||
return metas.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
||||
}
|
||||
|
||||
async function readRequestJson(request: http.IncomingMessage): Promise<unknown> {
|
||||
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));
|
||||
}
|
||||
Loading…
Reference in a new issue