Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import { createMockLogger } from '../test/mocks/logger';
import { classifySubprocessError } from './claude';

const mockLogger = createMockLogger();
mock.module('@archon/paths', () => ({
Expand Down Expand Up @@ -856,6 +857,91 @@ describe('ClaudeClient', () => {
expect(chunks).toHaveLength(1);
expect(chunks[0]).toEqual({ type: 'assistant', content: 'Real content' });
});

test('classifies stale session as fatal (no retry)', async () => {
const error = new Error('No conversation found');
mockQuery.mockImplementation(async function* () {
throw error;
});

let thrown: unknown;
const consumeGenerator = async () => {
try {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
} catch (e) {
thrown = e;
throw e;
}
};

await expect(consumeGenerator()).rejects.toThrow(/Claude Code stale session/);
// Stale session should NOT retry - single call
expect(mockQuery).toHaveBeenCalledTimes(1);
// Enriched error must preserve original cause for stack trace diagnostics
expect((thrown as Error).cause).toBeDefined();
});

test('classifies "conversation not found" variant as stale session (no retry)', async () => {
const error = new Error('conversation not found');
mockQuery.mockImplementation(async function* () {
throw error;
});

let thrown: unknown;
const consumeGenerator = async () => {
try {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
} catch (e) {
thrown = e;
throw e;
}
};

await expect(consumeGenerator()).rejects.toThrow(/Claude Code stale session/);
expect(mockQuery).toHaveBeenCalledTimes(1);
expect((thrown as Error).cause).toBeDefined();
});
});
});

describe('classifySubprocessError', () => {
test('classifies "no conversation found" as stale_session', () => {
expect(classifySubprocessError('No conversation found', '')).toBe('stale_session');
});

test('classifies stale session case-insensitively', () => {
expect(classifySubprocessError('NO CONVERSATION FOUND', '')).toBe('stale_session');
});

test('classifies "conversation not found" variant as stale_session', () => {
expect(classifySubprocessError('query failed', 'conversation not found')).toBe('stale_session');
});

test('classifies rate_limit correctly', () => {
expect(classifySubprocessError('rate limit exceeded', '')).toBe('rate_limit');
});

test('classifies auth errors correctly', () => {
expect(classifySubprocessError('unauthorized', '')).toBe('auth');
});

test('classifies crash correctly', () => {
expect(classifySubprocessError('exited with code 1', '')).toBe('crash');
});

test('returns unknown for unrelated errors', () => {
expect(classifySubprocessError('network timeout', '')).toBe('unknown');
});

test('stale_session is checked before crash — overlapping message classifies as stale_session', () => {
// A message containing both a crash token and a stale session token should be stale_session
expect(classifySubprocessError('exited with code 1: no conversation found', '')).toBe(
'stale_session'
);
});

describe('pre-spawn env leak gate', () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,18 @@ const SUBPROCESS_CRASH_PATTERNS = [
'operation aborted',
];

function classifySubprocessError(
/** Patterns indicating the Claude SDK session no longer exists (stale resume ID) */
export const STALE_SESSION_PATTERNS = ['no conversation found', 'conversation not found'];

/** Exported for testing only */
export function classifySubprocessError(
errorMessage: string,
stderrOutput: string
): 'rate_limit' | 'auth' | 'crash' | 'unknown' {
): 'rate_limit' | 'auth' | 'crash' | 'stale_session' | 'unknown' {
const combined = `${errorMessage} ${stderrOutput}`.toLowerCase();
if (RATE_LIMIT_PATTERNS.some(p => combined.includes(p))) return 'rate_limit';
if (AUTH_PATTERNS.some(p => combined.includes(p))) return 'auth';
if (STALE_SESSION_PATTERNS.some(p => combined.includes(p))) return 'stale_session'; // checked before crash: stale session is specific and non-retryable, like auth
if (SUBPROCESS_CRASH_PATTERNS.some(p => combined.includes(p))) return 'crash';
return 'unknown';
}
Expand Down Expand Up @@ -622,6 +627,15 @@ export class ClaudeClient implements IAssistantClient {
throw enrichedError;
}

// Don't retry stale session errors - the SDK session ID is gone; orchestrator handles reset
if (errorClass === 'stale_session') {
const enrichedError = new Error(
`Claude Code stale session: ${err.message}${stderrContext ? ` (${stderrContext})` : ''}`
);
enrichedError.cause = error;
throw enrichedError;
}

// Retry transient failures (rate limit, crash)
if (
attempt < MAX_SUBPROCESS_RETRIES &&
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/orchestrator/codebase-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Shared codebase lookup utilities.
* Extracted to prevent divergence between orchestrator-agent.ts and workflow-tool.ts.
*/
import type { Codebase } from '../types';

/**
* Find a codebase by exact name or by last path segment (e.g., "repo" matches "owner/repo").
* Case-insensitive.
*/
export function findCodebaseByName(
codebases: readonly Codebase[],
projectName: string
): Codebase | undefined {
const projectLower = projectName.toLowerCase();
return codebases.find(c => {
const nameLower = c.name.toLowerCase();
return nameLower === projectLower || nameLower.endsWith(`/${projectLower}`);
});
}
4 changes: 4 additions & 0 deletions packages/core/src/orchestrator/orchestrator-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ mock.module('fs', () => ({
existsSync: mock(() => true),
}));

mock.module('./workflow-tool', () => ({
buildWorkflowMcpServer: mock(() => ({ type: 'sdk', name: 'archon-tools', instance: {} })),
}));

// ─── Import module under test (AFTER all mocks) ───────────────────────────────

import { parseOrchestratorCommands, handleMessage } from './orchestrator-agent';
Expand Down
Loading