test: fail fast on claude live api errors
This commit is contained in:
parent
0d83f94537
commit
dbec466c14
2 changed files with 105 additions and 0 deletions
|
|
@ -25,6 +25,7 @@ import {
|
||||||
readRuntimeTurnSettledProcessedMetas,
|
readRuntimeTurnSettledProcessedMetas,
|
||||||
restoreEnv,
|
restoreEnv,
|
||||||
startMemberWorkSyncControlServer,
|
startMemberWorkSyncControlServer,
|
||||||
|
throwIfClaudeTranscriptApiError,
|
||||||
type MemberWorkSyncLiveControlServer,
|
type MemberWorkSyncLiveControlServer,
|
||||||
waitUntil,
|
waitUntil,
|
||||||
} from './memberWorkSyncLiveHarness';
|
} from './memberWorkSyncLiveHarness';
|
||||||
|
|
@ -251,6 +252,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
}
|
}
|
||||||
return last?.state === 'ready';
|
return last?.state === 'ready';
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
await throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot: tempClaudeRoot,
|
||||||
|
context: 'Claude team launch',
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
fs.stat(
|
fs.stat(
|
||||||
|
|
@ -304,6 +309,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntil(async () => {
|
await waitUntil(async () => {
|
||||||
|
await throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot: tempClaudeRoot,
|
||||||
|
context: 'Claude validation turn',
|
||||||
|
});
|
||||||
await feature!.replayPendingReports([teamName!]);
|
await feature!.replayPendingReports([teamName!]);
|
||||||
const [status, tasks] = await Promise.all([
|
const [status, tasks] = await Promise.all([
|
||||||
feature!.getStatus({ teamName: teamName!, memberName }),
|
feature!.getStatus({ teamName: teamName!, memberName }),
|
||||||
|
|
@ -331,6 +340,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
|
|
||||||
const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled;
|
const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled;
|
||||||
await waitUntil(async () => {
|
await waitUntil(async () => {
|
||||||
|
await throwIfClaudeTranscriptApiError({
|
||||||
|
claudeRoot: tempClaudeRoot,
|
||||||
|
context: 'Claude Stop hook turn',
|
||||||
|
});
|
||||||
await feature!.drainRuntimeTurnSettledEvents();
|
await feature!.drainRuntimeTurnSettledEvents();
|
||||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||||
return metas.some(({ filePath, meta }) => {
|
return metas.some(({ filePath, meta }) => {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/membe
|
||||||
|
|
||||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
export class FatalWaitError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FatalWaitError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncLiveControlServer {
|
export interface MemberWorkSyncLiveControlServer {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
|
@ -102,6 +109,9 @@ export async function waitUntil(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof FatalWaitError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||||
|
|
@ -173,6 +183,57 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function throwIfClaudeTranscriptApiError(input: {
|
||||||
|
claudeRoot: string;
|
||||||
|
context: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
|
||||||
|
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||||
|
for (const filePath of transcriptFiles) {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
if (!line.includes('"isApiErrorMessage"') && !line.includes('"error"')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const message = parsed.message as Record<string, unknown> | undefined;
|
||||||
|
apiErrors.push({
|
||||||
|
filePath,
|
||||||
|
error: typeof parsed.error === 'string' ? parsed.error : 'api_error',
|
||||||
|
text: extractClaudeMessageText(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiErrors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = apiErrors.at(-1)!;
|
||||||
|
throw new FatalWaitError(
|
||||||
|
[
|
||||||
|
`${input.context}: Claude API error detected in live transcript.`,
|
||||||
|
`error=${latest.error}`,
|
||||||
|
latest.text ? `message=${latest.text}` : undefined,
|
||||||
|
`transcript=${latest.filePath}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -198,6 +259,37 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string
|
||||||
return metas.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
return metas.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findJsonlFiles(rootPath: string): Promise<string[]> {
|
||||||
|
const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
const nested = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const entryPath = path.join(rootPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return findJsonlFiles(entryPath);
|
||||||
|
}
|
||||||
|
return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return nested.flat().sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClaudeMessageText(message: Record<string, unknown> | undefined): string {
|
||||||
|
const content = message?.content;
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (!part || typeof part !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const text = (part as { text?: unknown }).text;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
async function readRequestJson(request: http.IncomingMessage): Promise<unknown> {
|
async function readRequestJson(request: http.IncomingMessage): Promise<unknown> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of request) {
|
for await (const chunk of request) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue