fix(team): stabilize Claude live runtime launches

This commit is contained in:
777genius 2026-05-20 02:43:28 +03:00
parent 3908b5eb25
commit 860074da15
5 changed files with 336 additions and 19 deletions

View file

@ -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(
providerId: TeamProviderId,
launchIdentity?: ProviderModelLaunchIdentity | null
@ -22033,6 +22042,7 @@ export class TeamProvisioningService {
contextLabel: 'Team create launch',
});
const spawnArgs = mergeJsonSettingsArgs([
'--print',
'--input-format',
'stream-json',
'--output-format',
@ -23302,6 +23312,7 @@ export class TeamProvisioningService {
throw error;
}
const launchArgs = [
'--print',
'--input-format',
'stream-json',
'--output-format',
@ -36072,6 +36083,7 @@ export class TeamProvisioningService {
: {}),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
normalizeTeamRuntimeNodeEnv(env);
const resolvedProviderId = resolveTeamProviderId(providerId);
const providerEnvResult = await buildProviderAwareCliEnv({
providerId,

View file

@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import {
getTasksBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} 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_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
let previousHome: string | undefined;
let previousHistFile: string | undefined;
let previousUserProfile: string | undefined;
let claudeJsonConfigRoot: string;
let previousClaudeJsonConfig: string | null | undefined;
let svc: TeamProvisioningService | null;
let feature: MemberWorkSyncFeatureFacade | null;
let controlServer: MemberWorkSyncLiveControlServer | null;
let teamName: string | null;
let usingConnectedClaudeAccount = false;
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;
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
previousHome = process.env.HOME;
previousHistFile = process.env.HISTFILE;
previousUserProfile = process.env.USERPROFILE;
previousClaudeJsonConfig = undefined;
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
tempHome = shouldUseConnectedAccountHome
? resolveConnectedClaudeHome(previousHome)
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
tempHome = usingConnectedClaudeAccount
? resolveConnectedClaudeHome()
: 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(tempClaudeRoot, { recursive: true });
if (usingConnectedClaudeAccount) {
setClaudeBasePathOverride(null);
} else {
setClaudeBasePathOverride(tempClaudeRoot);
}
process.env.HOME = tempHome;
process.env.HISTFILE = '/dev/null';
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
});
afterEach(async () => {
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
if (svc && teamName) {
await svc.stopTeam(teamName).catch(() => undefined);
}
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
await feature?.dispose().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_TEAM_CLI_FLAVOR', previousCliFlavor);
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
restoreEnv('HISTFILE', previousHistFile);
restoreEnv('USERPROFILE', previousUserProfile);
setClaudeBasePathOverride(null);
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
if (keepTemp) {
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
} else {
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}`;
const projectPath = path.join(tempDir, 'project');
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(
path.join(projectPath, 'README.md'),
'# 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,
providerId: 'anthropic',
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,
prompt: [
'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',
providerId: 'anthropic',
model,
effort: DEFAULT_EFFORT,
},
],
},
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude team launch',
projectPath,
sinceMs: startedAt,
});
await expect(
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude validation turn',
projectPath,
sinceMs: startedAt,
});
await feature!.replayPendingReports([teamName!]);
const [status, metrics, tasks] = await Promise.all([
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
await throwIfClaudeTranscriptApiError({
claudeRoot: tempClaudeRoot,
context: 'Claude Stop hook turn',
projectPath,
sinceMs: startedAt,
});
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
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> {
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
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();
if (explicit) {
return path.resolve(explicit);
}
const previous = previousHome?.trim();
if (previous) {
return path.resolve(previous);
}
return os.userInfo().homedir;
}

View file

@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
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 () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View 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 }],
},
};
}

View file

@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs';
import * as http from 'node:http';
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 { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
export class FatalWaitError extends Error {
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
export async function throwIfClaudeTranscriptApiError(input: {
claudeRoot: string;
context: string;
projectPath?: string;
sinceMs?: number;
}): 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 }> = [];
for (const filePath of transcriptFiles) {
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
} catch {
continue;
}
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
continue;
}
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
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<
Array<{
filePath: string;