agent-ecosystem/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts

318 lines
12 KiB
TypeScript

import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
classifyLaunchFailureArtifact,
extractLaunchBootstrapTransportBreadcrumb,
isWorkspaceTrustLaunchFailureText,
readTeamLaunchFailureDiagnosticsBundle,
redactLaunchFailureArtifactText,
writeTeamLaunchFailureArtifactPack,
} from '../../../../src/main/services/team/TeamLaunchFailureArtifactPack';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
describe('TeamLaunchFailureArtifactPack', () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-launch-artifact-pack-'));
setClaudeBasePathOverride(path.join(tempRoot, '.claude'));
});
afterEach(async () => {
setClaudeBasePathOverride(null);
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('writes a bounded redacted launch failure artifact pack with known launch files', async () => {
const teamName = 'artifact-team';
const runId = 'run-secret-1';
const teamDir = path.join(getTeamsBasePath(), teamName);
await fs.mkdir(path.join(teamDir, '.bootstrap.lock'), { recursive: true });
await fs.writeFile(
path.join(teamDir, 'launch-state.json'),
JSON.stringify({
teamName,
runId,
secret: 'sk-ant-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
token: 'abcdefghijklmnopqrstuvwxyz123456',
}),
'utf8'
);
await fs.writeFile(path.join(teamDir, 'launch-summary.json'), '{"summary":true}\n', 'utf8');
await fs.writeFile(path.join(teamDir, 'bootstrap-state.json'), '{"bootstrap":true}\n', 'utf8');
await fs.writeFile(
path.join(teamDir, 'bootstrap-journal.jsonl'),
'{"event":"started"}\n',
'utf8'
);
await fs.writeFile(
path.join(teamDir, '.bootstrap.lock', 'metadata.json'),
'{"pid":123,"runId":"run-secret-1"}\n',
'utf8'
);
const result = await writeTeamLaunchFailureArtifactPack({
teamName,
runId,
reason: 'launch_progress_failed',
startedAt: '2026-05-09T00:00:00.000Z',
cwd: '/repo',
pid: 123,
providerId: 'anthropic',
model: 'claude-opus',
expectedMembers: ['alice'],
effectiveMembers: [{ name: 'alice', role: 'developer', provider: 'anthropic' } as never],
progress: {
runId,
teamName,
state: 'failed',
message: 'Launch failed',
startedAt: '2026-05-09T00:00:00.000Z',
updatedAt: '2026-05-09T00:01:00.000Z',
error:
'Authentication failed: ANTHROPIC_API_KEY=sk-ant-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
memberSpawnStatuses: {
alice: {
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: 'bootstrap timeout',
updatedAt: '2026-05-09T00:01:00.000Z',
},
},
cliLogs: 'stderr OPENAI_API_KEY=sk-proj-cccccccccccccccccccccccccccccccccccccccc',
progressTraceLines: ['[failed] launch failed'],
runtimeAdapterTraceLines: ['runtime trace'],
});
const manifest = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')) as {
reason: string;
artifactFiles: string[];
classification: { code: string };
bootstrapTransportBreadcrumb: { lastTransportStage: string | null };
progress: { error: string };
};
expect(manifest.reason).toBe('launch_progress_failed');
expect(manifest.classification.code).toBe('provider_auth');
expect(manifest.artifactFiles).toContain('cli-logs-tail.txt');
expect(manifest.artifactFiles).toContain('launch-state.json');
expect(manifest.progress.error).toContain('[REDACTED]');
const copiedLaunchState = await fs.readFile(
path.join(result.directory, 'launch-state.json'),
'utf8'
);
expect(copiedLaunchState).toContain('[REDACTED_ANTHROPIC_API_KEY]');
expect(() => JSON.parse(copiedLaunchState)).not.toThrow();
expect(copiedLaunchState).toContain('"token":"[REDACTED]"');
expect(copiedLaunchState).not.toContain('sk-ant-');
const cliLogs = await fs.readFile(path.join(result.directory, 'cli-logs-tail.txt'), 'utf8');
expect(cliLogs).toContain('OPENAI_API_KEY=[REDACTED]');
expect(cliLogs).not.toContain('sk-proj-');
const latest = JSON.parse(
await fs.readFile(path.join(teamDir, 'launch-failure-artifacts', 'latest.json'), 'utf8')
) as { manifestPath: string };
expect(latest.manifestPath).toBe(result.manifestPath);
const bundle = await readTeamLaunchFailureDiagnosticsBundle(teamName, runId);
expect(bundle).toMatchObject({
teamName,
runId,
manifestPath: result.manifestPath,
classification: { code: 'provider_auth' },
bootstrapTransportBreadcrumb: { submitRejected: false },
});
expect(bundle.files.map((file) => file.label)).toEqual([
'launch-failure-artifacts/latest.json',
'launch-failure-artifacts/manifest.json',
'bootstrap-journal.jsonl',
'launch-state.json',
]);
expect(
bundle.files.find((file) => file.label === 'bootstrap-journal.jsonl')?.content
).toContain('"event":"started"');
const launchStateContent = bundle.files.find(
(file) => file.label === 'launch-state.json'
)?.content;
expect(launchStateContent).toContain('[REDACTED_ANTHROPIC_API_KEY]');
expect(launchStateContent).not.toContain('sk-ant-');
});
it('redacts common bearer and token-shaped secrets', () => {
const redacted = redactLaunchFailureArtifactText(
'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456'
);
expect(redacted).toContain('Authorization: Bearer [REDACTED]');
expect(redacted).toContain('token: [REDACTED]');
});
it('classifies bootstrap transport rejection and extracts breadcrumb details', () => {
const input = {
teamName: 'artifact-team',
runId: 'run-transport',
reason: 'launch_cleanup_unconfirmed_bootstrap',
progressTraceLines: [
'bob did not submit bootstrap prompt: timed out waiting for bootstrap_submitted; last transport stage: bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
'Warning: no stdin data received in 3s, proceeding without it.',
],
};
expect(classifyLaunchFailureArtifact(input).code).toBe('transport_rejected');
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
lastTransportStage:
'bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
submitRejected: true,
retryable: true,
noStdinWarning: true,
bootstrapSubmitted: false,
});
});
it('does not classify stdin warning as root cause after bootstrap transport evidence', () => {
const input = {
teamName: 'artifact-team',
runId: 'run-mailbox-written',
reason:
'atlas: Teammate process atlas@signal-ops did not submit bootstrap prompt: timed out waiting for bootstrap_submitted; last transport stage: mailbox_bootstrap_written Last stderr: Warning: no stdin data received in 3s, proceeding without it.',
progressTraceLines: [
'mailbox_bootstrap_written detail=messageId=bootstrap-atlas-1',
'Warning: no stdin data received in 3s, proceeding without it.',
],
};
expect(classifyLaunchFailureArtifact(input).code).toBe('model_no_bootstrap');
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
noStdinWarning: true,
bootstrapSubmitted: false,
});
});
it('classifies provider quota separately from protocol errors', () => {
expect(
classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: 'run-quota',
reason:
'OpenCode quota exhausted. This request requires more credits, or fewer max_tokens.',
}).code
).toBe('provider_quota');
});
it('classifies Claude Code workspace trust failures separately', () => {
const reason =
'Teammate "Gayani" cannot start in headless process runtime because workspace trust is not accepted for "C:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1". Open that workspace once interactively and accept trust, then launch the team again.';
const classification = classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: 'run-workspace-trust',
reason: 'Deterministic bootstrap failed',
memberSpawnStatuses: {
Gayani: {
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: reason,
updatedAt: '2026-05-12T00:00:00.000Z',
},
},
progressTraceLines: [reason],
});
expect(classification.code).toBe('workspace_trust_required');
expect(classification.evidence.join('\n')).toContain('workspace trust is not accepted');
});
it('classifies workspace trust preflight blocks separately', () => {
const classification = classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: 'run-workspace-trust-preflight',
reason: 'Claude workspace trust was not confirmed for /tmp/project',
launchDiagnostics: [
{
id: 'workspace-trust:preflight',
severity: 'error',
code: 'workspace_trust_preflight',
label: 'Workspace trust preflight blocked launch',
detail: 'Claude workspace trust was not confirmed for /tmp/project',
observedAt: '2026-05-13T00:00:00.000Z',
},
],
});
expect(classification.code).toBe('workspace_trust_required');
expect(classification.evidence.join('\n')).toContain('workspace trust was not confirmed');
});
it('prioritizes workspace trust over auth and transport-looking fallback text', () => {
const classification = classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: 'run-workspace-trust-priority',
reason:
'Token refresh failed after bootstrap_submit_rejected, but Claude workspace trust was not confirmed for /tmp/project',
progressTraceLines: [
'401 Unauthorized',
'workspace_trust_preflight_not_confirmed',
'last transport stage: bootstrap_submit_rejected retryable=true',
],
});
expect(classification.code).toBe('workspace_trust_required');
expect(classification.confidence).toBeGreaterThan(0.9);
});
it('matches only explicit workspace trust failure text', () => {
expect(
isWorkspaceTrustLaunchFailureText('Claude workspace trust was not confirmed for /tmp/project')
).toBe(true);
expect(isWorkspaceTrustLaunchFailureText('workspace trust preflight disabled')).toBe(false);
});
it.each([
{
name: 'stdin warning',
text: 'Warning: no stdin data received in 3s, proceeding without it.',
code: 'stdin_missing',
},
{
name: 'provider auth',
text: 'Codex API error. Token refresh failed: 401 Unauthorized',
code: 'provider_auth',
},
{
name: 'model bootstrap timeout',
text: 'bob: Teammate was registered but did not bootstrap-confirm before timeout.',
code: 'model_no_bootstrap',
},
{
name: 'sanitized launch bootstrap fallback',
text: 'Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: alice.',
code: 'model_no_bootstrap',
},
{
name: 'process stale pid',
text: 'persisted runtime pid is not alive; persisted runtime pid was not found in process table',
code: 'process_exited',
},
{
name: 'opencode protocol',
text: 'OpenCode API error. non_visible_tool_without_task_progress',
code: 'opencode_protocol',
},
])('classifies production-like failure string: $name', ({ text, code }) => {
expect(
classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: `run-${code}`,
reason: text,
}).code
).toBe(code);
});
});