fix(team): stabilize Claude live runtime launches
This commit is contained in:
parent
3908b5eb25
commit
860074da15
5 changed files with 336 additions and 19 deletions
|
|
@ -1600,6 +1600,15 @@ function buildRuntimeSettingsTempDirectory(teamName: string): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTeamRuntimeNodeEnv(env: NodeJS.ProcessEnv): void {
|
||||||
|
// Vitest sets NODE_ENV=test in the desktop parent process. Real team runtime
|
||||||
|
// children must run the CLI normally, otherwise source launches can take
|
||||||
|
// test-only startup paths and exit before deterministic bootstrap starts.
|
||||||
|
if (env.NODE_ENV === 'test') {
|
||||||
|
env.NODE_ENV = 'development';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildProviderFastModeArgs(
|
function buildProviderFastModeArgs(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||||
|
|
@ -22033,6 +22042,7 @@ export class TeamProvisioningService {
|
||||||
contextLabel: 'Team create launch',
|
contextLabel: 'Team create launch',
|
||||||
});
|
});
|
||||||
const spawnArgs = mergeJsonSettingsArgs([
|
const spawnArgs = mergeJsonSettingsArgs([
|
||||||
|
'--print',
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
|
|
@ -23302,6 +23312,7 @@ export class TeamProvisioningService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
|
'--print',
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
|
|
@ -36072,6 +36083,7 @@ export class TeamProvisioningService {
|
||||||
: {}),
|
: {}),
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
};
|
};
|
||||||
|
normalizeTeamRuntimeNodeEnv(env);
|
||||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||||
import {
|
import {
|
||||||
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
setClaudeBasePathOverride,
|
setClaudeBasePathOverride,
|
||||||
} from '../../../../src/main/utils/pathDecoder';
|
} from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
@ -51,7 +52,6 @@ const liveDescribe =
|
||||||
|
|
||||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||||
const DEFAULT_MODEL = 'sonnet';
|
const DEFAULT_MODEL = 'sonnet';
|
||||||
const DEFAULT_EFFORT = 'low' as const;
|
|
||||||
|
|
||||||
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
||||||
|
|
||||||
|
|
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
let previousHome: string | undefined;
|
let previousHome: string | undefined;
|
||||||
let previousHistFile: string | undefined;
|
let previousHistFile: string | undefined;
|
||||||
let previousUserProfile: string | undefined;
|
let previousUserProfile: string | undefined;
|
||||||
|
let claudeJsonConfigRoot: string;
|
||||||
|
let previousClaudeJsonConfig: string | null | undefined;
|
||||||
let svc: TeamProvisioningService | null;
|
let svc: TeamProvisioningService | null;
|
||||||
let feature: MemberWorkSyncFeatureFacade | null;
|
let feature: MemberWorkSyncFeatureFacade | null;
|
||||||
let controlServer: MemberWorkSyncLiveControlServer | null;
|
let controlServer: MemberWorkSyncLiveControlServer | null;
|
||||||
let teamName: string | null;
|
let teamName: string | null;
|
||||||
|
let usingConnectedClaudeAccount = false;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
|
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;
|
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
|
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
previousHome = process.env.HOME;
|
previousHome = process.env.HOME;
|
||||||
previousHistFile = process.env.HISTFILE;
|
previousHistFile = process.env.HISTFILE;
|
||||||
previousUserProfile = process.env.USERPROFILE;
|
previousUserProfile = process.env.USERPROFILE;
|
||||||
|
previousClaudeJsonConfig = undefined;
|
||||||
|
|
||||||
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||||
tempHome = shouldUseConnectedAccountHome
|
tempHome = usingConnectedClaudeAccount
|
||||||
? resolveConnectedClaudeHome(previousHome)
|
? resolveConnectedClaudeHome()
|
||||||
: path.join(tempDir, 'home');
|
: path.join(tempDir, 'home');
|
||||||
|
tempClaudeRoot = usingConnectedClaudeAccount
|
||||||
|
? path.join(tempHome, '.claude')
|
||||||
|
: path.join(tempDir, '.claude');
|
||||||
|
claudeJsonConfigRoot = usingConnectedClaudeAccount ? tempHome : tempClaudeRoot;
|
||||||
await fs.mkdir(tempHome, { recursive: true });
|
await fs.mkdir(tempHome, { recursive: true });
|
||||||
|
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||||
|
|
||||||
|
if (usingConnectedClaudeAccount) {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
} else {
|
||||||
|
setClaudeBasePathOverride(tempClaudeRoot);
|
||||||
|
}
|
||||||
|
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
process.env.HISTFILE = '/dev/null';
|
process.env.HISTFILE = '/dev/null';
|
||||||
|
|
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
|
||||||
if (svc && teamName) {
|
if (svc && teamName) {
|
||||||
await svc.stopTeam(teamName).catch(() => undefined);
|
await svc.stopTeam(teamName).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
||||||
await feature?.dispose().catch(() => undefined);
|
await feature?.dispose().catch(() => undefined);
|
||||||
await controlServer?.close().catch(() => undefined);
|
await controlServer?.close().catch(() => undefined);
|
||||||
|
discardKnownClaudeStopHookWarnings();
|
||||||
|
if (!keepTemp && usingConnectedClaudeAccount && teamName) {
|
||||||
|
await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true });
|
||||||
|
await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
if (usingConnectedClaudeAccount && previousClaudeJsonConfig !== undefined) {
|
||||||
|
await restoreClaudeJsonConfig(claudeJsonConfigRoot, previousClaudeJsonConfig);
|
||||||
|
}
|
||||||
|
|
||||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||||
|
|
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
restoreEnv('HISTFILE', previousHistFile);
|
restoreEnv('HISTFILE', previousHistFile);
|
||||||
restoreEnv('USERPROFILE', previousUserProfile);
|
restoreEnv('USERPROFILE', previousUserProfile);
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
|
if (keepTemp) {
|
||||||
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
||||||
} else {
|
} else {
|
||||||
await removeTempDirAfterLateShellWrites(tempDir);
|
await removeTempDirAfterLateShellWrites(tempDir);
|
||||||
|
|
@ -186,7 +206,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
||||||
const projectPath = path.join(tempDir, 'project');
|
const projectPath = path.join(tempDir, 'project');
|
||||||
await fs.mkdir(projectPath, { recursive: true });
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
if (usingConnectedClaudeAccount) {
|
||||||
|
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
|
||||||
|
claudeJsonConfigRoot,
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await writeTrustedClaudeConfig(claudeJsonConfigRoot, projectPath);
|
||||||
|
}
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(projectPath, 'README.md'),
|
path.join(projectPath, 'README.md'),
|
||||||
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
||||||
|
|
@ -251,7 +278,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
model,
|
model,
|
||||||
effort: DEFAULT_EFFORT,
|
...(process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS?.trim()
|
||||||
|
? { extraCliArgs: process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS.trim() }
|
||||||
|
: {}),
|
||||||
skipPermissions: true,
|
skipPermissions: true,
|
||||||
prompt: [
|
prompt: [
|
||||||
'Keep launch work minimal and wait for the explicit live-test instruction.',
|
'Keep launch work minimal and wait for the explicit live-test instruction.',
|
||||||
|
|
@ -263,7 +292,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
role: 'Developer',
|
role: 'Developer',
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
model,
|
model,
|
||||||
effort: DEFAULT_EFFORT,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude team launch',
|
context: 'Claude team launch',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude validation turn',
|
context: 'Claude validation turn',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
await feature!.replayPendingReports([teamName!]);
|
await feature!.replayPendingReports([teamName!]);
|
||||||
const [status, metrics, tasks] = await Promise.all([
|
const [status, metrics, tasks] = await Promise.all([
|
||||||
|
|
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
await throwIfClaudeTranscriptApiError({
|
await throwIfClaudeTranscriptApiError({
|
||||||
claudeRoot: tempClaudeRoot,
|
claudeRoot: tempClaudeRoot,
|
||||||
context: 'Claude Stop hook turn',
|
context: 'Claude Stop hook turn',
|
||||||
|
projectPath,
|
||||||
|
sinceMs: startedAt,
|
||||||
});
|
});
|
||||||
await feature!.drainRuntimeTurnSettledEvents();
|
await feature!.drainRuntimeTurnSettledEvents();
|
||||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||||
|
|
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
|
||||||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function discardKnownClaudeStopHookWarnings(): void {
|
||||||
|
const warn = vi.mocked(console.warn);
|
||||||
|
if (!warn.mock) return;
|
||||||
|
const calls = warn.mock.calls;
|
||||||
|
for (let index = calls.length - 1; index >= 0; index -= 1) {
|
||||||
|
const text = calls[index]?.map((value) => String(value)).join(' ') ?? '';
|
||||||
|
if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) {
|
||||||
|
calls.splice(index, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text.includes('Failed to resolve interactive shell env: shell env resolve timeout')) {
|
||||||
|
calls.splice(index, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text.includes('Failed to parse runtime model list for launch validation')) {
|
||||||
|
calls.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
|
|
@ -589,14 +643,71 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
async function upsertTrustedClaudeProjectConfig(
|
||||||
|
configDir: string,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const configPath = path.join(configDir, '.claude.json');
|
||||||
|
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const existing = parseJsonObject(previous) ?? {};
|
||||||
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
|
const projects =
|
||||||
|
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||||
|
? { ...(existing.projects as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
const currentProject =
|
||||||
|
projects[normalizedProjectPath] &&
|
||||||
|
typeof projects[normalizedProjectPath] === 'object' &&
|
||||||
|
!Array.isArray(projects[normalizedProjectPath])
|
||||||
|
? (projects[normalizedProjectPath] as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
projects[normalizedProjectPath] = {
|
||||||
|
...currentProject,
|
||||||
|
hasTrustDialogAccepted: true,
|
||||||
|
};
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
...existing,
|
||||||
|
projects,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
|
||||||
|
const configPath = path.join(configDir, '.claude.json');
|
||||||
|
if (previous === null) {
|
||||||
|
await fs.rm(configPath, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.writeFile(configPath, previous, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectedClaudeHome(): string {
|
||||||
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return path.resolve(explicit);
|
return path.resolve(explicit);
|
||||||
}
|
}
|
||||||
const previous = previousHome?.trim();
|
|
||||||
if (previous) {
|
|
||||||
return path.resolve(previous);
|
|
||||||
}
|
|
||||||
return os.userInfo().homedir;
|
return os.userInfo().homedir;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not leak Vitest NODE_ENV into real team runtime children', async () => {
|
||||||
|
const previousNodeEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
try {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const buildProvisioningEnv = (
|
||||||
|
svc as unknown as {
|
||||||
|
buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>;
|
||||||
|
}
|
||||||
|
).buildProvisioningEnv.bind(svc);
|
||||||
|
|
||||||
|
const result = await buildProvisioningEnv();
|
||||||
|
|
||||||
|
expect(result.env.NODE_ENV).toBe('development');
|
||||||
|
expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
env: expect.objectContaining({ NODE_ENV: 'development' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousNodeEnv === undefined) {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
} else {
|
||||||
|
process.env.NODE_ENV = previousNodeEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||||
|
|
|
||||||
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
|
import { throwIfClaudeTranscriptApiError } from './memberWorkSyncLiveHarness';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
describe('memberWorkSyncLiveHarness', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes Claude API error checks to the current project transcripts', async () => {
|
||||||
|
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||||
|
tempDirs.push(claudeRoot);
|
||||||
|
const projectPath = path.join(claudeRoot, 'project');
|
||||||
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
|
const projectTranscriptDir = path.join(
|
||||||
|
claudeRoot,
|
||||||
|
'projects',
|
||||||
|
encodePath(await fs.realpath(projectPath))
|
||||||
|
);
|
||||||
|
const unrelatedTranscriptDir = path.join(
|
||||||
|
claudeRoot,
|
||||||
|
'projects',
|
||||||
|
encodePath('/Users/example/other-project')
|
||||||
|
);
|
||||||
|
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||||
|
await fs.mkdir(unrelatedTranscriptDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(unrelatedTranscriptDir, 'unrelated.jsonl'),
|
||||||
|
`${JSON.stringify(buildApiErrorRecord('wrong project'))}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot,
|
||||||
|
context: 'live check',
|
||||||
|
projectPath,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||||
|
`${JSON.stringify(buildApiErrorRecord('right project'))}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot,
|
||||||
|
context: 'live check',
|
||||||
|
projectPath,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/right project/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale Claude API errors before the live check start time', async () => {
|
||||||
|
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||||
|
tempDirs.push(claudeRoot);
|
||||||
|
const projectPath = path.join(claudeRoot, 'project');
|
||||||
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
|
const projectTranscriptDir = path.join(
|
||||||
|
claudeRoot,
|
||||||
|
'projects',
|
||||||
|
encodePath(await fs.realpath(projectPath))
|
||||||
|
);
|
||||||
|
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||||
|
const sinceMs = Date.now();
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||||
|
`${JSON.stringify(buildApiErrorRecord('old error', sinceMs - 1_000))}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot,
|
||||||
|
context: 'live check',
|
||||||
|
projectPath,
|
||||||
|
sinceMs,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await fs.appendFile(
|
||||||
|
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||||
|
`${JSON.stringify(buildApiErrorRecord('new error', sinceMs + 1_000))}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot,
|
||||||
|
context: 'live check',
|
||||||
|
projectPath,
|
||||||
|
sinceMs,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/new error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApiErrorRecord(text: string, timestampMs = Date.now()): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
isApiErrorMessage: true,
|
||||||
|
error: 'unknown',
|
||||||
|
timestamp: new Date(timestampMs).toISOString(),
|
||||||
|
message: {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
|
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
|
||||||
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
||||||
|
|
||||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||||
|
|
||||||
export class FatalWaitError extends Error {
|
export class FatalWaitError extends Error {
|
||||||
|
|
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
||||||
export async function throwIfClaudeTranscriptApiError(input: {
|
export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
claudeRoot: string;
|
claudeRoot: string;
|
||||||
context: string;
|
context: string;
|
||||||
|
projectPath?: string;
|
||||||
|
sinceMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
|
const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath);
|
||||||
|
const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat();
|
||||||
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||||
for (const filePath of transcriptFiles) {
|
for (const filePath of transcriptFiles) {
|
||||||
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||||
|
|
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +254,46 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveClaudeTranscriptRoots(
|
||||||
|
claudeRoot: string,
|
||||||
|
projectPath: string | undefined
|
||||||
|
): Promise<string[]> {
|
||||||
|
const projectsRoot = path.join(claudeRoot, 'projects');
|
||||||
|
if (!projectPath) {
|
||||||
|
return [projectsRoot];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateRoots = new Set<string>();
|
||||||
|
const addCandidate = (candidatePath: string) => {
|
||||||
|
candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath)));
|
||||||
|
};
|
||||||
|
addCandidate(path.resolve(projectPath));
|
||||||
|
const realProjectPath = await fs.realpath(projectPath).catch(() => null);
|
||||||
|
if (realProjectPath) {
|
||||||
|
addCandidate(realProjectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRoots: string[] = [];
|
||||||
|
for (const candidateRoot of candidateRoots) {
|
||||||
|
const stats = await fs.stat(candidateRoot).catch(() => null);
|
||||||
|
if (stats?.isDirectory()) {
|
||||||
|
existingRoots.push(candidateRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existingRoots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTranscriptRecordBefore(record: Record<string, unknown>, sinceMs: number): boolean {
|
||||||
|
const timestamp = record.timestamp;
|
||||||
|
const timestampMs =
|
||||||
|
typeof timestamp === 'string'
|
||||||
|
? Date.parse(timestamp)
|
||||||
|
: typeof timestamp === 'number'
|
||||||
|
? timestamp
|
||||||
|
: Number.NaN;
|
||||||
|
return Number.isFinite(timestampMs) && timestampMs < sinceMs;
|
||||||
|
}
|
||||||
|
|
||||||
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue