602 lines
24 KiB
TypeScript
602 lines
24 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
import { afterAll, 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,
|
|
type MemberWorkSyncLiveControlServer,
|
|
readRuntimeTurnSettledProcessedMetas,
|
|
restoreEnv,
|
|
startMemberWorkSyncControlServer,
|
|
throwIfClaudeTranscriptApiError,
|
|
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 allowConnectedClaudeAccount =
|
|
process.env.MEMBER_WORK_SYNC_CLAUDE_ALLOW_CONNECTED_ACCOUNT === '1';
|
|
const liveDescribe =
|
|
process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' &&
|
|
(hasLiveAnthropicApiKey() || allowConnectedClaudeAccount)
|
|
? describe
|
|
: describe.skip;
|
|
|
|
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
|
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<ClaudeStopHookLiveScenarioContext>): string[];
|
|
}
|
|
|
|
function hasAcceptedReportForScenario(input: {
|
|
metrics: Awaited<ReturnType<MemberWorkSyncFeatureFacade['getMetrics']>>;
|
|
memberName: string;
|
|
expectedState: ClaudeStopHookLiveScenarioState;
|
|
}): boolean {
|
|
return input.metrics.recentEvents.some(
|
|
(event) =>
|
|
event.kind === 'report_accepted' &&
|
|
event.memberName === input.memberName &&
|
|
event.reportState === input.expectedState
|
|
);
|
|
}
|
|
|
|
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 previousDisableAppBootstrap: string | undefined;
|
|
let previousDisableRuntimeBootstrap: string | undefined;
|
|
let previousHome: string | undefined;
|
|
let previousHistFile: 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');
|
|
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;
|
|
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
|
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
|
previousHome = process.env.HOME;
|
|
previousHistFile = process.env.HISTFILE;
|
|
previousUserProfile = process.env.USERPROFILE;
|
|
|
|
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
|
tempHome = shouldUseConnectedAccountHome
|
|
? resolveConnectedClaudeHome(previousHome)
|
|
: path.join(tempDir, 'home');
|
|
await fs.mkdir(tempHome, { recursive: true });
|
|
|
|
process.env.HOME = tempHome;
|
|
process.env.HISTFILE = '/dev/null';
|
|
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';
|
|
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_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
|
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
|
restoreEnv('HOME', previousHome);
|
|
restoreEnv('HISTFILE', previousHistFile);
|
|
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 removeTempDirAfterLateShellWrites(tempDir);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
|
|
return;
|
|
}
|
|
await cleanupScopedClaudeStopHookLiveTempDirs();
|
|
});
|
|
|
|
async function runClaudeStopHookLiveScenario(
|
|
scenario: ClaudeStopHookLiveScenario
|
|
): Promise<void> {
|
|
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 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 writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
|
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!],
|
|
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)
|
|
);
|
|
|
|
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 throwIfClaudeTranscriptApiError({
|
|
claudeRoot: tempClaudeRoot,
|
|
context: 'Claude team launch',
|
|
});
|
|
|
|
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);
|
|
|
|
const processedMetasBeforeValidation = await readRuntimeTurnSettledProcessedMetas(
|
|
getTeamsBasePath()
|
|
);
|
|
const processedMetaPathsBeforeValidation = new Set(
|
|
processedMetasBeforeValidation.map(({ filePath }) => filePath)
|
|
);
|
|
const validationSentAt = Date.now();
|
|
|
|
await activeService.sendMessageToTeam(
|
|
teamName,
|
|
scenario
|
|
.buildInstructionLines({
|
|
...taskPromptContext,
|
|
taskId: task.id,
|
|
})
|
|
.join('\n')
|
|
);
|
|
|
|
await waitUntil(async () => {
|
|
await throwIfClaudeTranscriptApiError({
|
|
claudeRoot: tempClaudeRoot,
|
|
context: 'Claude validation turn',
|
|
});
|
|
await feature!.replayPendingReports([teamName!]);
|
|
const [status, metrics, tasks] = await Promise.all([
|
|
feature!.getStatus({ teamName: teamName!, memberName }),
|
|
feature!.getMetrics({ teamName: teamName! }),
|
|
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)
|
|
);
|
|
const reportAccepted =
|
|
(status.report?.accepted === true && status.report.state === scenario.expectedState) ||
|
|
hasAcceptedReportForScenario({
|
|
metrics,
|
|
memberName,
|
|
expectedState: scenario.expectedState,
|
|
});
|
|
return Boolean(
|
|
hasMarkerComment &&
|
|
currentTask?.status === scenario.expectedTaskStatus &&
|
|
reportAccepted
|
|
);
|
|
}, 300_000, 2_000, async () =>
|
|
formatMemberWorkSyncDiagnostics({
|
|
feature: feature!,
|
|
teamName: teamName!,
|
|
memberName,
|
|
taskId: task.id,
|
|
})
|
|
);
|
|
|
|
const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled;
|
|
await waitUntil(async () => {
|
|
await throwIfClaudeTranscriptApiError({
|
|
claudeRoot: tempClaudeRoot,
|
|
context: 'Claude Stop hook turn',
|
|
});
|
|
await feature!.drainRuntimeTurnSettledEvents();
|
|
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
|
return metas.some(({ filePath, meta }) => {
|
|
const event = meta.event as Record<string, unknown> | undefined;
|
|
const recordedAt =
|
|
typeof event?.recordedAt === 'string' ? Date.parse(event.recordedAt) : Number.NaN;
|
|
return (
|
|
!processedMetaPathsBeforeValidation.has(filePath) &&
|
|
meta.outcome === 'enqueued' &&
|
|
meta.teamName === teamName &&
|
|
meta.memberName === memberName &&
|
|
event?.provider === 'claude' &&
|
|
Number.isFinite(recordedAt) &&
|
|
recordedAt >= validationSentAt
|
|
);
|
|
});
|
|
}, 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 }),
|
|
]);
|
|
const finalReportAccepted =
|
|
(finalStatus.report?.accepted === true &&
|
|
finalStatus.report.state === scenario.expectedState) ||
|
|
hasAcceptedReportForScenario({
|
|
metrics,
|
|
memberName,
|
|
expectedState: scenario.expectedState,
|
|
});
|
|
expect(finalReportAccepted).toBe(true);
|
|
if (finalStatus.state !== 'inactive') {
|
|
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(
|
|
hasAcceptedReportForScenario({
|
|
metrics,
|
|
memberName,
|
|
expectedState: scenario.expectedState,
|
|
})
|
|
).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 "${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, finish the turn with exactly: ${marker}:hook-settled.`,
|
|
],
|
|
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 "${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 "${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}"].`,
|
|
`Do not complete the task. After that, finish the turn with exactly: ${marker}:hook-settled.`,
|
|
],
|
|
}),
|
|
420_000
|
|
);
|
|
|
|
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, finish the turn with exactly: ${marker}:hook-settled.`,
|
|
],
|
|
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, finish the turn with exactly: ${marker}:hook-settled.`,
|
|
],
|
|
}),
|
|
420_000
|
|
);
|
|
});
|
|
|
|
async function removeTempDirAfterLateShellWrites(tempDir: string): Promise<void> {
|
|
// Claude Code can leave child shells that write ~/.zsh_history just after stopTeam cleanup.
|
|
// Bounded repeated passes keep live tests from leaving tiny recreated HOME directories behind.
|
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
await removeTempDirBestEffort(tempDir);
|
|
if (attempt < 5) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function removeTempDirBestEffort(tempDir: string): Promise<void> {
|
|
try {
|
|
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
} catch (error) {
|
|
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
|
if (code === 'ENOENT') {
|
|
return;
|
|
}
|
|
// Live Claude processes can briefly recreate files under the temp HOME while
|
|
// the test harness is tearing down. The repeated outer cleanup loop handles
|
|
// those late writes, so cleanup must not turn an already-finished live e2e
|
|
// assertion into a false failure.
|
|
}
|
|
}
|
|
|
|
async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
|
|
const tmpRoot = os.tmpdir();
|
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
|
try {
|
|
entries = await fs.readdir(tmpRoot, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith('member-work-sync-claude-stop-live-'))
|
|
.map((entry) => removeTempDirBestEffort(path.join(tmpRoot, entry.name)))
|
|
);
|
|
if (attempt < 5) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasLiveAnthropicApiKey(): boolean {
|
|
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
|
}
|
|
|
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
|
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
|
const config: {
|
|
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
|
customApiKeyResponses?: { approved: string[]; rejected: string[] };
|
|
} = {
|
|
projects: {
|
|
[normalizedProjectPath]: {
|
|
hasTrustDialogAccepted: true,
|
|
},
|
|
},
|
|
};
|
|
if (approvedApiKeySuffix) {
|
|
config.customApiKeyResponses = {
|
|
approved: [approvedApiKeySuffix],
|
|
rejected: [],
|
|
};
|
|
}
|
|
await fs.writeFile(
|
|
path.join(configDir, '.claude.json'),
|
|
`${JSON.stringify(config, null, 2)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
|
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
|
if (explicit) {
|
|
return path.resolve(explicit);
|
|
}
|
|
const previous = previousHome?.trim();
|
|
if (previous) {
|
|
return path.resolve(previous);
|
|
}
|
|
return os.userInfo().homedir;
|
|
}
|