diff --git a/packages/core/src/clients/claude.test.ts b/packages/core/src/clients/claude.test.ts index e09c004822..8dcb98c899 100644 --- a/packages/core/src/clients/claude.test.ts +++ b/packages/core/src/clients/claude.test.ts @@ -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', () => ({ @@ -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', () => { diff --git a/packages/core/src/clients/claude.ts b/packages/core/src/clients/claude.ts index 90595e1d25..d3bf148b9b 100644 --- a/packages/core/src/clients/claude.ts +++ b/packages/core/src/clients/claude.ts @@ -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'; } @@ -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 && diff --git a/packages/core/src/orchestrator/codebase-utils.ts b/packages/core/src/orchestrator/codebase-utils.ts new file mode 100644 index 0000000000..a6cdd58908 --- /dev/null +++ b/packages/core/src/orchestrator/codebase-utils.ts @@ -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}`); + }); +} diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index 70080cc01a..2697e55927 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -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'; diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 97d989f47c..1d72098813 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -25,6 +25,7 @@ import { formatToolCall } from '@archon/workflows/utils/tool-formatter'; import { classifyAndFormatError } from '../utils/error-formatter'; import { toError } from '../utils/error'; import { getAssistantClient } from '../clients/factory'; +import { STALE_SESSION_PATTERNS } from '../clients/claude'; import { getArchonHome, getArchonWorkspacesPath } from '@archon/paths'; import { syncArchonToWorktree } from '../utils/worktree-sync'; import { syncWorkspace, toRepoPath } from '@archon/git'; @@ -47,6 +48,8 @@ import { buildOrchestratorPrompt, buildProjectScopedPrompt } from './prompt-buil import * as workflowDb from '../db/workflows'; import * as workflowEventDb from '../db/workflow-events'; import type { ApprovalContext } from '@archon/workflows/schemas/workflow-run'; +import { buildWorkflowMcpServer } from './workflow-tool'; +import { findCodebaseByName } from './codebase-utils'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -61,6 +64,8 @@ function getLog(): ReturnType { const MAX_BATCH_ASSISTANT_CHUNKS = 20; /** Max total chunks (assistant + tool) to keep in batch mode */ const MAX_BATCH_TOTAL_CHUNKS = 200; +/** Bare commands that Slack users commonly send without a leading slash */ +const SLACK_BARE_COMMANDS = new Set(['reset']); // ─── Types ────────────────────────────────────────────────────────────────── @@ -83,21 +88,6 @@ export interface OrchestratorCommands { // ─── Command Parsing ──────────────────────────────────────────────────────── -/** - * Find a codebase by exact name or by last path segment (e.g., "repo" matches "owner/repo"). - * Case-insensitive. Used in both the parse phase and the dispatch phase. - */ -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}`); - }); -} - /** * Parse orchestrator commands from AI response text. * Scans for /invoke-workflow and /register-project patterns. @@ -319,6 +309,15 @@ async function dispatchOrchestratorWorkflow( // ─── Session Helpers ──────────────────────────────────────────────────────── +/** Returns true if the error indicates the Claude SDK session ID is no longer valid. */ +function isStaleSessionError(error: Error): boolean { + const msg = error.message.toLowerCase(); + // Two detection sources — either suffices: + // 1. Enriched prefix added by claude.ts ("Claude Code stale session: …") + // 2. Raw SDK message matched via STALE_SESSION_PATTERNS (single source of truth) + return msg.includes('stale session') || STALE_SESSION_PATTERNS.some(p => msg.includes(p)); +} + async function tryPersistSessionId(sessionId: string, assistantSessionId: string): Promise { try { await sessionDb.updateSession(sessionId, assistantSessionId); @@ -519,11 +518,18 @@ export async function handleMessage( conversationId ); + // 1b. Normalize bare commands (Slack users often omit the leading slash) + const effectiveMessage = + platform.getPlatformType() === 'slack' && + SLACK_BARE_COMMANDS.has(message.trim().toLowerCase()) + ? `/${message.trim().toLowerCase()}` + : message; + // 1c. Auto-generate title for untitled conversations (fire-and-forget) - if (!conversation.title && !message.startsWith('/')) { + if (!conversation.title && !effectiveMessage.startsWith('/')) { void generateAndSetTitle( conversation.id, - message, + effectiveMessage, conversation.ai_assistant_type, getArchonWorkspacesPath() ); @@ -645,8 +651,8 @@ export async function handleMessage( } // 2. Check for deterministic commands - if (message.startsWith('/')) { - const { command } = commandHandler.parseCommand(message); + if (effectiveMessage.startsWith('/')) { + const { command } = commandHandler.parseCommand(effectiveMessage); const deterministicCommands = [ 'help', 'status', @@ -663,7 +669,7 @@ export async function handleMessage( if (deterministicCommands.includes(command)) { if (command === 'register-project') { getLog().debug({ command, conversationId }, 'deterministic_command'); - const result = await handleRegisterProject(message, platform, conversationId); + const result = await handleRegisterProject(effectiveMessage, platform, conversationId); await platform.sendMessage(conversationId, result); return; } @@ -683,7 +689,7 @@ export async function handleMessage( } getLog().debug({ command, conversationId }, 'deterministic_command'); - const result = await commandHandler.handleCommand(conversation, message); + const result = await commandHandler.handleCommand(conversation, effectiveMessage); await platform.sendMessage(conversationId, result.message); if (result.workflow) { @@ -821,7 +827,7 @@ export async function handleMessage( async function handleStreamMode( platform: IPlatformAdapter, conversationId: string, - originalMessage: string, + _originalMessage: string, // unused — invoke_workflow MCP tool dispatches workflows inline via task_description codebases: readonly Codebase[], workflows: readonly WorkflowDefinition[], aiClient: ReturnType, @@ -830,59 +836,118 @@ async function handleStreamMode( session: { id: string; assistant_session_id: string | null }, isolationHints: HandleMessageContext['isolationHints'], conversation: Conversation, - issueContext?: string, + _issueContext?: string, // unused — issue context is passed via task_description in the tool call requestOptions?: AssistantRequestOptions ): Promise { + const workflowMcpServer = buildWorkflowMcpServer({ + platform, + conversationId, + conversation, + codebases, + workflows, + isolationHints, + dispatch: (codebase, workflow, taskDescription) => + dispatchOrchestratorWorkflow( + platform, + conversationId, + conversation, + codebase, + workflow, + taskDescription, + isolationHints + ), + }); + const allMessages: string[] = []; let newSessionId: string | undefined; let commandDetected = false; + let sessionForQuery = session; + let retried = false; - for await (const msg of aiClient.sendQuery( - fullPrompt, - cwd, - session.assistant_session_id ?? undefined, - requestOptions - )) { - if (msg.type === 'assistant' && msg.content) { - if (!commandDetected) { - allMessages.push(msg.content); - const accumulated = allMessages.join(''); - // Check for orchestrator commands BEFORE streaming to frontend. - // If detected, suppress this chunk and all future chunks — the full - // response will be parsed post-loop and the command dispatched there. - if ( - /^\/invoke-workflow\s/m.test(accumulated) || - /^\/register-project\s/m.test(accumulated) - ) { - commandDetected = true; - } else { - await platform.sendMessage(conversationId, msg.content); - } + async function runStreamQuery(): Promise { + for await (const msg of aiClient.sendQuery( + fullPrompt, + cwd, + sessionForQuery.assistant_session_id ?? undefined, + { + ...requestOptions, + mcpServers: { + ...(requestOptions?.mcpServers ?? {}), + 'archon-tools': workflowMcpServer, + }, } - } else if (msg.type === 'tool' && msg.toolName) { - if (!commandDetected) { - const toolMessage = formatToolCall(msg.toolName, msg.toolInput); - await platform.sendMessage(conversationId, toolMessage, { - category: 'tool_call_formatted', - }); - if (platform.sendStructuredEvent) { + )) { + if (msg.type === 'assistant' && msg.content) { + if (!commandDetected) { + allMessages.push(msg.content); + const accumulated = allMessages.join(''); + // Check for orchestrator commands BEFORE streaming to frontend. + // If detected, suppress this chunk and all future chunks — the full + // response will be parsed post-loop and the command dispatched there. + if (/^\/register-project\s/m.test(accumulated)) { + commandDetected = true; + } else { + await platform.sendMessage(conversationId, msg.content); + } + } + } else if (msg.type === 'tool' && msg.toolName) { + if (!commandDetected) { + const toolMessage = formatToolCall(msg.toolName, msg.toolInput); + await platform.sendMessage(conversationId, toolMessage, { + category: 'tool_call_formatted', + }); + if (platform.sendStructuredEvent) { + await platform.sendStructuredEvent(conversationId, msg); + } + } + } else if (msg.type === 'tool_result' && msg.toolName) { + if (!commandDetected && platform.sendStructuredEvent) { + await platform.sendStructuredEvent(conversationId, msg); + } + } else if (msg.type === 'result' && msg.sessionId) { + newSessionId = msg.sessionId; + if (!commandDetected && platform.sendStructuredEvent) { await platform.sendStructuredEvent(conversationId, msg); } } - } else if (msg.type === 'tool_result' && msg.toolName) { - if (!commandDetected && platform.sendStructuredEvent) { - await platform.sendStructuredEvent(conversationId, msg); - } - } else if (msg.type === 'result' && msg.sessionId) { - newSessionId = msg.sessionId; - if (!commandDetected && platform.sendStructuredEvent) { - await platform.sendStructuredEvent(conversationId, msg); + } + } + + try { + await runStreamQuery(); + } catch (error) { + const err = toError(error); + if (!retried && isStaleSessionError(err) && sessionForQuery.assistant_session_id) { + retried = true; + getLog().warn({ conversationId, sessionId: sessionForQuery.id }, 'stale_session_auto_reset'); + sessionForQuery = await sessionDb.transitionSession( + conversation.id, + 'stale-session-cleared', + { + ai_assistant_type: conversation.ai_assistant_type, + } + ); + newSessionId = undefined; // Clear any partial state from failed attempt before retry + allMessages.length = 0; + commandDetected = false; + try { + await runStreamQuery(); // retry in fresh session + await platform.sendMessage(conversationId, '⚠️ Previous session expired — starting fresh.'); + } catch (retryError) { + const retryErr = toError(retryError); + getLog().error({ conversationId, err: retryErr }, 'stale_session_retry_failed'); + await platform.sendMessage( + conversationId, + '⚠️ Previous session expired and retry also failed. Use /reset to start a fresh session.' + ); } + } else { + throw err; } } if (newSessionId) { - await tryPersistSessionId(session.id, newSessionId); + await tryPersistSessionId(sessionForQuery.id, newSessionId); } if (allMessages.length === 0) { @@ -893,25 +958,6 @@ async function handleStreamMode( const fullResponse = allMessages.join(''); const commands = parseOrchestratorCommands(fullResponse, codebases, workflows); - if (commands.workflowInvocation) { - // Retract streamed text — workflow dispatch replaces it - if (platform.emitRetract) { - await platform.emitRetract(conversationId); - } - await handleWorkflowInvocationResult( - platform, - conversationId, - conversation, - codebases, - workflows, - commands.workflowInvocation, - originalMessage, - isolationHints, - issueContext - ); - return; - } - if (commands.projectRegistration) { if (platform.emitRetract) { await platform.emitRetract(conversationId); @@ -937,7 +983,7 @@ async function handleStreamMode( async function handleBatchMode( platform: IPlatformAdapter, conversationId: string, - originalMessage: string, + _originalMessage: string, // unused — invoke_workflow MCP tool dispatches workflows inline via task_description codebases: readonly Codebase[], workflows: readonly WorkflowDefinition[], aiClient: ReturnType, @@ -946,57 +992,119 @@ async function handleBatchMode( session: { id: string; assistant_session_id: string | null }, isolationHints: HandleMessageContext['isolationHints'], conversation: Conversation, - issueContext?: string, + _issueContext?: string, // unused — issue context is passed via task_description in the tool call requestOptions?: AssistantRequestOptions ): Promise { + const workflowMcpServer = buildWorkflowMcpServer({ + platform, + conversationId, + conversation, + codebases, + workflows, + isolationHints, + dispatch: (codebase, workflow, taskDescription) => + dispatchOrchestratorWorkflow( + platform, + conversationId, + conversation, + codebase, + workflow, + taskDescription, + isolationHints + ), + }); + const allChunks: { type: string; content: string }[] = []; const assistantMessages: string[] = []; let assistantChunksTruncated = false; let totalChunksTruncated = false; let newSessionId: string | undefined; let commandDetected = false; + let sessionForQuery = session; + let retried = false; - for await (const msg of aiClient.sendQuery( - fullPrompt, - cwd, - session.assistant_session_id ?? undefined, - requestOptions - )) { - if (msg.type === 'assistant' && msg.content) { - if (!commandDetected) { - assistantMessages.push(msg.content); - allChunks.push({ type: 'assistant', content: msg.content }); - - if (assistantMessages.length > MAX_BATCH_ASSISTANT_CHUNKS) { - assistantMessages.shift(); - assistantChunksTruncated = true; + async function runBatchQuery(): Promise { + for await (const msg of aiClient.sendQuery( + fullPrompt, + cwd, + sessionForQuery.assistant_session_id ?? undefined, + { + ...requestOptions, + mcpServers: { + ...(requestOptions?.mcpServers ?? {}), + 'archon-tools': workflowMcpServer, + }, + } + )) { + if (msg.type === 'assistant' && msg.content) { + if (!commandDetected) { + assistantMessages.push(msg.content); + allChunks.push({ type: 'assistant', content: msg.content }); + + if (assistantMessages.length > MAX_BATCH_ASSISTANT_CHUNKS) { + assistantMessages.shift(); + assistantChunksTruncated = true; + } + const accumulated = assistantMessages.join(''); + if (/^\/register-project\s/m.test(accumulated)) { + commandDetected = true; + } } - const accumulated = assistantMessages.join(''); - if ( - /^\/invoke-workflow\s/m.test(accumulated) || - /^\/register-project\s/m.test(accumulated) - ) { - commandDetected = true; + } else if (msg.type === 'tool' && msg.toolName) { + if (!commandDetected) { + const toolMessage = formatToolCall(msg.toolName, msg.toolInput); + allChunks.push({ type: 'tool', content: toolMessage }); + getLog().debug({ toolName: msg.toolName }, 'tool_call'); } + } else if (msg.type === 'result' && msg.sessionId) { + newSessionId = msg.sessionId; } - } else if (msg.type === 'tool' && msg.toolName) { - if (!commandDetected) { - const toolMessage = formatToolCall(msg.toolName, msg.toolInput); - allChunks.push({ type: 'tool', content: toolMessage }); - getLog().debug({ toolName: msg.toolName }, 'tool_call'); + + if (!commandDetected && allChunks.length > MAX_BATCH_TOTAL_CHUNKS) { + allChunks.shift(); + totalChunksTruncated = true; } - } else if (msg.type === 'result' && msg.sessionId) { - newSessionId = msg.sessionId; } + } - if (!commandDetected && allChunks.length > MAX_BATCH_TOTAL_CHUNKS) { - allChunks.shift(); - totalChunksTruncated = true; + try { + await runBatchQuery(); + } catch (error) { + const err = toError(error); + if (!retried && isStaleSessionError(err) && sessionForQuery.assistant_session_id) { + retried = true; + getLog().warn({ conversationId, sessionId: sessionForQuery.id }, 'stale_session_auto_reset'); + sessionForQuery = await sessionDb.transitionSession( + conversation.id, + 'stale-session-cleared', + { + ai_assistant_type: conversation.ai_assistant_type, + } + ); + newSessionId = undefined; // Clear any partial state from failed attempt before retry + allChunks.length = 0; + assistantMessages.length = 0; + assistantChunksTruncated = false; + totalChunksTruncated = false; + commandDetected = false; + try { + await runBatchQuery(); // retry in fresh session + await platform.sendMessage(conversationId, '⚠️ Previous session expired — starting fresh.'); + } catch (retryError) { + const retryErr = toError(retryError); + getLog().error({ conversationId, err: retryErr }, 'stale_session_retry_failed'); + await platform.sendMessage( + conversationId, + '⚠️ Previous session expired and retry also failed. Use /reset to start a fresh session.' + ); + } + } else { + throw err; } } if (newSessionId) { - await tryPersistSessionId(session.id, newSessionId); + await tryPersistSessionId(sessionForQuery.id, newSessionId); } if (assistantChunksTruncated || totalChunksTruncated) { @@ -1027,24 +1135,6 @@ async function handleBatchMode( // Parse orchestrator commands from filtered response const commands = parseOrchestratorCommands(finalMessage, codebases, workflows); - if (commands.workflowInvocation) { - if (platform.emitRetract) { - await platform.emitRetract(conversationId); - } - await handleWorkflowInvocationResult( - platform, - conversationId, - conversation, - codebases, - workflows, - commands.workflowInvocation, - originalMessage, - isolationHints, - issueContext - ); - return; - } - if (commands.projectRegistration) { if (platform.emitRetract) { await platform.emitRetract(conversationId); @@ -1065,71 +1155,6 @@ async function handleBatchMode( // ─── Orchestrator Command Handlers ────────────────────────────────────────── -/** - * Handle a parsed /invoke-workflow command from AI response. - */ -async function handleWorkflowInvocationResult( - platform: IPlatformAdapter, - conversationId: string, - conversation: Conversation, - codebases: readonly Codebase[], - workflows: readonly WorkflowDefinition[], - invocation: WorkflowInvocation, - originalMessage: string, - isolationHints: HandleMessageContext['isolationHints'], - issueContext?: string -): Promise { - const { workflowName, projectName, remainingMessage } = invocation; - - // Send explanation text before dispatching - if (remainingMessage) { - await platform.sendMessage(conversationId, remainingMessage); - } - - // Find the codebase and workflow (supports partial name matching) - const codebase = findCodebaseByName(codebases, projectName); - const workflow = findWorkflow(workflowName, [...workflows]); - - if (codebase && workflow) { - const workflowPrompt = invocation.synthesizedPrompt ?? originalMessage; - getLog().debug( - { - source: invocation.synthesizedPrompt ? 'synthesized' : 'original', - promptLength: workflowPrompt.length, - workflowName, - hasIssueContext: !!issueContext, - issueContextLength: issueContext?.length ?? 0, - }, - 'workflow_prompt_resolved' - ); - await dispatchOrchestratorWorkflow( - platform, - conversationId, - conversation, - codebase, - workflow, - workflowPrompt, - isolationHints - ); - return; - } - - // Fallback: send error about missing project or workflow - if (!codebase) { - const projectList = codebases.map(c => `- ${c.name}`).join('\n'); - await platform.sendMessage( - conversationId, - `I couldn't find a project matching "${projectName}". Here are your registered projects:\n${projectList || '(none)'}\n\nPlease specify which project you'd like to use.` - ); - } else if (!workflow) { - getLog().warn({ workflowName, projectName }, 'workflow_not_found_in_dispatch'); - await platform.sendMessage( - conversationId, - `Workflow \`${workflowName}\` is not available. Use \`/workflow list\` to see available workflows.` - ); - } -} - /** * Handle a parsed /register-project command from AI response. */ diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index d5e81038da..90ae968720 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -170,6 +170,11 @@ mock.module('@archon/workflows/utils/tool-formatter', () => ({ formatToolCall: mock((toolName: string, _toolInput: unknown) => `🔧 ${toolName.toUpperCase()}`), })); +// claude client constants mock (needed because orchestrator-agent imports STALE_SESSION_PATTERNS) +mock.module('../clients/claude', () => ({ + STALE_SESSION_PATTERNS: ['no conversation found', 'conversation not found'], +})); + // fs mock for existsSync const mockExistsSync = mock(() => true); mock.module('fs', () => ({ @@ -1493,4 +1498,136 @@ describe('orchestrator-agent handleMessage', () => { expect(mockGenerateAndSetTitle).not.toHaveBeenCalled(); }); }); + + // ─── Stale Session Auto-Reset ────────────────────────────────────────── + + for (const mode of ['stream', 'batch'] as const) { + describe(`stale session recovery (${mode} mode)`, () => { + beforeEach(() => { + platform.getStreamingMode.mockReturnValue(mode); + }); + + test('resets session and retries once on stale session error', async () => { + // Session with existing assistant_session_id so stale-session guard fires + const staleSession: Session = { ...mockSession, assistant_session_id: 'old-session-id' }; + const freshSession: Session = { + ...mockSession, + id: 'session-fresh', + assistant_session_id: 'new-session-id', + }; + mockGetActiveSession.mockResolvedValue(staleSession); + mockTransitionSession.mockResolvedValue(freshSession); + + let callCount = 0; + mockClient.sendQuery.mockImplementation(async function* () { + callCount += 1; + if (callCount === 1) { + throw new Error('Claude Code stale session: No conversation found'); + } + yield { type: 'result', sessionId: 'new-session-id' }; + }); + mockGetAssistantClient.mockReturnValue(mockClient); + + await handleMessage(platform, 'chat-456', 'hello'); + + expect(mockClient.sendQuery).toHaveBeenCalledTimes(2); + // conversationId is the platform conversation ID ('chat-456'), not the DB conversation ID + expect(mockTransitionSession).toHaveBeenCalledWith( + 'chat-456', + 'stale-session-cleared', + expect.any(Object) + ); + expect(platform.sendMessage).toHaveBeenCalledWith( + 'chat-456', + expect.stringContaining('session expired') + ); + // Verify the retry uses the fresh session ID, not the stale one + const calls = mockClient.sendQuery.mock.calls; + expect(calls[0][2]).toBe('old-session-id'); // first call: stale session + expect(calls[1][2]).toBe('new-session-id'); // retry: fresh session + }); + + test('does NOT retry a third time if the retry also fails', async () => { + // handleMessage catches all errors and sends them as messages — no rejection + const staleSession: Session = { ...mockSession, assistant_session_id: 'old-session-id' }; + mockGetActiveSession.mockResolvedValue(staleSession); + mockTransitionSession.mockResolvedValue({ + ...mockSession, + assistant_session_id: 'mid-session', + }); + + mockClient.sendQuery.mockImplementation(async function* () { + throw new Error('Claude Code stale session: No conversation found'); + }); + mockGetAssistantClient.mockReturnValue(mockClient); + + // handleMessage swallows the error and sends it as a message + await handleMessage(platform, 'chat-456', 'hello'); + // sendQuery called twice: original attempt + one retry (retried guard prevents a third) + expect(mockClient.sendQuery).toHaveBeenCalledTimes(2); + }); + + test('skips stale-session reset when session has no assistant_session_id', async () => { + // Session with no assistant_session_id — guard should NOT fire + const newSession: Session = { ...mockSession, assistant_session_id: null }; + mockGetActiveSession.mockResolvedValue(newSession); + + mockClient.sendQuery.mockImplementation(async function* () { + throw new Error('Claude Code stale session: No conversation found'); + }); + mockGetAssistantClient.mockReturnValue(mockClient); + + // handleMessage swallows the error; no retry attempted + await handleMessage(platform, 'chat-456', 'hello'); + // Only called once — no retry when session has no assistant_session_id + expect(mockClient.sendQuery).toHaveBeenCalledTimes(1); + expect(mockTransitionSession).not.toHaveBeenCalledWith( + expect.anything(), + 'stale-session-cleared', + expect.anything() + ); + }); + }); + } + + // ─── Bare Command Normalization ──────────────────────────────────────── + + describe('bare command normalization', () => { + test('treats bare "reset" as "/reset" command', async () => { + mockHandleCommand.mockResolvedValue({ + message: 'Session cleared', + modified: false, + success: true, + }); + + await handleMessage(platform, 'chat-456', 'reset'); + + expect(mockParseCommand).toHaveBeenCalledWith('/reset'); + expect(platform.sendMessage).toHaveBeenCalledWith('chat-456', 'Session cleared'); + }); + + test('treats " RESET " (padded + uppercase) as "/reset"', async () => { + mockHandleCommand.mockResolvedValue({ + message: 'Session cleared', + modified: false, + success: true, + }); + + await handleMessage(platform, 'chat-456', ' RESET '); + + expect(mockParseCommand).toHaveBeenCalledWith('/reset'); + }); + + test('does NOT treat "resetall" as a bare command', async () => { + mockClient.sendQuery.mockImplementation(async function* () { + yield { type: 'result', sessionId: 'session-id' }; + }); + + await handleMessage(platform, 'chat-456', 'resetall'); + + // Should NOT parse it as a command — goes to AI instead + expect(mockParseCommand).not.toHaveBeenCalledWith('/resetall'); + expect(mockGetAssistantClient).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/orchestrator/prompt-builder.test.ts b/packages/core/src/orchestrator/prompt-builder.test.ts index 7a734950b1..bce4558954 100644 --- a/packages/core/src/orchestrator/prompt-builder.test.ts +++ b/packages/core/src/orchestrator/prompt-builder.test.ts @@ -2,32 +2,38 @@ import { describe, test, expect } from 'bun:test'; import { buildRoutingRulesWithProject } from './prompt-builder'; describe('buildRoutingRulesWithProject', () => { - test('routing rules include --prompt in invocation format', () => { + test('routing rules instruct Claude to call invoke_workflow tool', () => { const rules = buildRoutingRulesWithProject(); - expect(rules).toContain('--prompt'); - expect(rules).toContain('self-contained task description'); + expect(rules).toContain('invoke_workflow'); + expect(rules).toContain('call the'); }); - test('routing rules include --prompt with project-scoped prompt', () => { + test('routing rules include task_description parameter', () => { + const rules = buildRoutingRulesWithProject(); + + expect(rules).toContain('task_description'); + expect(rules).toContain('self-contained'); + }); + + test('routing rules mention invoke_workflow tool with project-scoped prompt', () => { const rules = buildRoutingRulesWithProject('my-project'); - expect(rules).toContain('--prompt'); + expect(rules).toContain('invoke_workflow'); expect(rules).toContain('my-project'); }); - test('invocation format line includes exact --prompt flag syntax', () => { + test('rules state task_description must have NO knowledge of conversation', () => { const rules = buildRoutingRulesWithProject(); - // The format template must include --prompt as part of the command, not just in prose - expect(rules).toContain( - '/invoke-workflow {workflow-name} --project {project-name} --prompt "{task description}"' - ); + expect(rules).toContain('NO knowledge of this conversation'); }); - test('rules state prompt must be self-contained with no conversation knowledge', () => { + test('rules do NOT instruct Claude to output /invoke-workflow as text', () => { const rules = buildRoutingRulesWithProject(); - expect(rules).toContain('NO knowledge of the conversation history'); + // The new format tells Claude NOT to use the text command + expect(rules).not.toContain('output the command as the VERY LAST line'); + expect(rules).toContain('Do NOT output'); }); }); diff --git a/packages/core/src/orchestrator/prompt-builder.ts b/packages/core/src/orchestrator/prompt-builder.ts index d5f307db5b..ffdc92bc33 100644 --- a/packages/core/src/orchestrator/prompt-builder.ts +++ b/packages/core/src/orchestrator/prompt-builder.ts @@ -62,30 +62,26 @@ ${rule4} 5. If no project needed (general question) → answer directly without workflow 6. If the user wants to add a new project → clone it, then register it (see below) -## Workflow Invocation Format +## Workflow Invocation -When invoking a workflow, output the command as the VERY LAST line of your response: -/invoke-workflow {workflow-name} --project {project-name} --prompt "{task description}" +When the user wants structured development work, call the **\`invoke_workflow\`** tool directly. -Rules: -- Use the project NAME (e.g., "my-project"), not an ID or path. -- The --prompt MUST be a complete, self-contained task description that fully captures the user's intent. -- Synthesize the prompt from conversation context — do NOT use vague references like "do what we discussed" or "yes, go ahead." -- The prompt should make sense to someone with NO knowledge of the conversation history. -- You may include a brief explanation before the command. The user will see this text. -- /invoke-workflow MUST be the absolute last thing in your response. Do NOT use any tools or generate additional text after it. +Tool parameters: +- \`workflow_name\` — exact workflow name (from list above, e.g., "archon-fix-github-issue-dag") +- \`project_name\` — project name (e.g., "moo-second-brain") +- \`task_description\` — complete, self-contained description of the task. Must make sense to someone with NO knowledge of this conversation. Do NOT use vague references like "do what we discussed" or "yes, go ahead." Routing behavior: -- If the user clearly wants work done (e.g., "create a plan for X", "implement Y", "fix Z") → include a brief explanation of what you're doing, then invoke the workflow. -- If the user is asking a question or it's unclear whether they want a workflow → answer their question directly. You may suggest a workflow by name (e.g., "I can run the **archon-assist** workflow for this if you'd like"), but do NOT include /invoke-workflow in your response. +- If the user clearly wants work done (e.g., "create a plan for X", "implement Y", "fix Z") → call \`invoke_workflow\` immediately. You may include a brief explanation first. +- If the user is asking a question or intent is unclear → answer directly. You may suggest a workflow by name (e.g., "I can run **archon-assist** for this if you'd like"), but do NOT call invoke_workflow without clear intent. +- Do NOT output \`/invoke-workflow\` as text. Always use the tool. Example (clear intent): -I'll analyze the orchestrator module architecture for you. -/invoke-workflow archon-assist --project my-project --prompt "Analyze the orchestrator module architecture: explain how it routes messages, manages sessions, and dispatches workflows to AI clients" +I'll dispatch archon-fix-github-issue-dag to fix issue #3 for you. +[calls invoke_workflow with workflow_name="archon-fix-github-issue-dag", project_name="moo-second-brain", task_description="Fix GitHub issue #3: ..."] Example (ambiguous — answer directly): -User: "What do you think about adding dark mode?" -Response: "Adding dark mode would involve... [answer the question]. If you'd like me to create a plan for this, I can run the **archon-idea-to-pr** workflow." +"Adding dark mode would involve... If you'd like me to create a plan, I can run archon-idea-to-pr." ## Project Setup diff --git a/packages/core/src/orchestrator/workflow-tool.test.ts b/packages/core/src/orchestrator/workflow-tool.test.ts new file mode 100644 index 0000000000..59b43cf016 --- /dev/null +++ b/packages/core/src/orchestrator/workflow-tool.test.ts @@ -0,0 +1,335 @@ +/** + * Tests for workflow-tool.ts + * + * Tests the buildWorkflowMcpServer factory and the invoke_workflow tool handler. + * + * Mock setup MUST occur before any import of the module under test. + */ + +import { mock, describe, test, expect, beforeEach } from 'bun:test'; +import { createMockLogger } from '../test/mocks/logger'; +import type { Codebase, Conversation } from '../types'; +import type { WorkflowDefinition } from '@archon/workflows'; + +// ─── Mock setup (ALL mocks must come before the module under test import) ──── + +const mockLogger = createMockLogger(); + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +mock.module('@archon/workflows', () => ({ + findWorkflow: mock((name: string, workflows: WorkflowDefinition[]) => + workflows.find(w => w.name === name) + ), +})); + +// Capture the tool handler so tests can invoke it directly +let capturedHandler: ((args: Record, extra: unknown) => Promise) | null = + null; +let capturedTools: unknown[] = []; + +mock.module('@anthropic-ai/claude-agent-sdk', () => ({ + createSdkMcpServer: mock((opts: { name: string; version?: string; tools?: unknown[] }) => { + capturedTools = opts.tools ?? []; + return { type: 'sdk', name: opts.name, instance: {} }; + }), + tool: mock( + ( + name: string, + description: string, + schema: unknown, + handler: (args: Record, extra: unknown) => Promise + ) => { + capturedHandler = handler; + return { name, description, inputSchema: schema, handler }; + } + ), +})); + +// ─── Import module under test (AFTER all mocks) ─────────────────────────────── + +import { buildWorkflowMcpServer } from './workflow-tool'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeWorkflow(name: string): WorkflowDefinition { + return { + name, + description: `${name} workflow`, + steps: [{ prompt: 'do the thing' }], + } as unknown as WorkflowDefinition; +} + +function makeCodebase(name: string, id = `id-${name}`): Codebase { + return { + id, + name, + repository_url: null, + default_cwd: `/repos/${name}`, + ai_assistant_type: 'claude', + commands: {}, + created_at: new Date(), + updated_at: new Date(), + }; +} + +function makeConversation(id = 'conv-1'): Conversation { + return { + id, + platform: 'slack', + platform_conversation_id: 'slack-123', + codebase_id: null, + ai_assistant_type: 'claude', + created_at: new Date(), + updated_at: new Date(), + } as unknown as Conversation; +} + +function makePlatform() { + return { + sendMessage: mock(() => Promise.resolve()), + getStreamingMode: mock(() => 'batch' as const), + getPlatformType: mock(() => 'slack' as const), + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('buildWorkflowMcpServer', () => { + const assistWorkflow = makeWorkflow('archon-assist'); + const fixWorkflow = makeWorkflow('archon-fix-github-issue-dag'); + const myProject = makeCodebase('remote-coding-agent'); + const orgProject = makeCodebase('mhooooo/remote-coding-agent'); + + const workflows = [assistWorkflow, fixWorkflow]; + const codebases = [myProject, orgProject]; + + let dispatchMock: ReturnType; + + beforeEach(() => { + capturedHandler = null; + capturedTools = []; + dispatchMock = mock(() => Promise.resolve()); + mockLogger.error.mockClear(); + }); + + function buildDeps(overrides: Partial[0]> = {}) { + return { + platform: makePlatform(), + conversationId: 'conv-1', + conversation: makeConversation(), + codebases, + workflows, + isolationHints: undefined, + dispatch: dispatchMock, + ...overrides, + }; + } + + // ─── Server shape ──────────────────────────────────────────────────────────── + + test('returns McpSdkServerConfigWithInstance with type sdk', () => { + const result = buildWorkflowMcpServer(buildDeps()); + + expect(result).toBeDefined(); + expect((result as { type: string }).type).toBe('sdk'); + expect((result as { instance: unknown }).instance).toBeDefined(); + }); + + test('registers exactly one tool named invoke_workflow', () => { + buildWorkflowMcpServer(buildDeps()); + + expect(capturedTools).toHaveLength(1); + expect((capturedTools[0] as { name: string }).name).toBe('invoke_workflow'); + }); + + // ─── Handler: workflow not found ───────────────────────────────────────────── + + test('returns error text when workflow_name is not found', async () => { + buildWorkflowMcpServer(buildDeps()); + expect(capturedHandler).not.toBeNull(); + + const result = await capturedHandler!( + { + workflow_name: 'nonexistent-workflow', + project_name: 'remote-coding-agent', + task_description: 'do something', + }, + {} + ); + + const content = (result as { content: { type: string; text: string }[] }).content; + expect(content[0].type).toBe('text'); + expect(content[0].text).toContain('nonexistent-workflow'); + expect(content[0].text).toContain('not found'); + }); + + // ─── Handler: project not found ────────────────────────────────────────────── + + test('returns error text with available projects when project_name is not found', async () => { + buildWorkflowMcpServer(buildDeps()); + expect(capturedHandler).not.toBeNull(); + + const result = await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'unknown-project', + task_description: 'do something', + }, + {} + ); + + const content = (result as { content: { type: string; text: string }[] }).content; + expect(content[0].type).toBe('text'); + expect(content[0].text).toContain('unknown-project'); + expect(content[0].text).toContain('not found'); + // Should list available projects + expect(content[0].text).toContain('remote-coding-agent'); + }); + + // ─── Handler: success dispatch ─────────────────────────────────────────────── + + test('calls dispatch once with correct codebase, workflow, and task description on success', async () => { + buildWorkflowMcpServer(buildDeps()); + expect(capturedHandler).not.toBeNull(); + + await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'remote-coding-agent', + task_description: 'Fix issue #3', + }, + {} + ); + + // Give the fire-and-forget promise a tick to resolve + await new Promise(r => setTimeout(r, 0)); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const [calledCodebase, calledWorkflow, calledDesc] = ( + dispatchMock as { mock: { calls: unknown[][] } } + ).mock.calls[0]; + expect((calledCodebase as Codebase).name).toBe('remote-coding-agent'); + expect((calledWorkflow as WorkflowDefinition).name).toBe('archon-assist'); + expect(calledDesc).toBe('Fix issue #3'); + }); + + // ─── Handler: success text ─────────────────────────────────────────────────── + + test('returns confirmation text with workflow name and project name on success', async () => { + buildWorkflowMcpServer(buildDeps()); + expect(capturedHandler).not.toBeNull(); + + const result = await capturedHandler!( + { + workflow_name: 'archon-fix-github-issue-dag', + project_name: 'remote-coding-agent', + task_description: 'Fix issue #5', + }, + {} + ); + + const content = (result as { content: { type: string; text: string }[] }).content; + expect(content[0].type).toBe('text'); + expect(content[0].text).toContain('archon-fix-github-issue-dag'); + expect(content[0].text).toContain('remote-coding-agent'); + }); + + // ─── Handler: dispatch throws ──────────────────────────────────────────────── + + test('does not throw when dispatch rejects — fire-and-forget catches the error', async () => { + const failingDispatch = mock(() => Promise.reject(new Error('dispatch failed'))); + const platformMock = makePlatform(); + buildWorkflowMcpServer(buildDeps({ dispatch: failingDispatch, platform: platformMock })); + expect(capturedHandler).not.toBeNull(); + + // Handler should resolve without throwing + const result = await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'remote-coding-agent', + task_description: 'do something', + }, + {} + ); + + // Still returns confirmation (workflow was accepted) + const content = (result as { content: { type: string; text: string }[] }).content; + expect(content[0].type).toBe('text'); + expect(content[0].text).toContain('Dispatched'); + + // Give the fire-and-forget .catch() a tick to run + await new Promise(r => setTimeout(r, 0)); + + // Error was caught and logged + expect(mockLogger.error).toHaveBeenCalled(); + + // User is notified of the failure (not silently dropped) + expect(platformMock.sendMessage).toHaveBeenCalledWith( + 'conv-1', + expect.stringContaining('Failed to start workflow') + ); + }); + + // ─── Case-insensitive project matching ────────────────────────────────────── + + test('matches project by last path segment (owner/repo → repo)', async () => { + buildWorkflowMcpServer(buildDeps()); + expect(capturedHandler).not.toBeNull(); + + await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'remote-coding-agent', + task_description: 'test', + }, + {} + ); + + await new Promise(r => setTimeout(r, 0)); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + test('matches org-qualified codebase by short name (mhooooo/remote-coding-agent → remote-coding-agent)', async () => { + buildWorkflowMcpServer(buildDeps({ codebases: [orgProject] })); + expect(capturedHandler).not.toBeNull(); + + await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'remote-coding-agent', + task_description: 'test', + }, + {} + ); + + await new Promise(r => setTimeout(r, 0)); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const [calledCodebase] = (dispatchMock as { mock: { calls: unknown[][] } }).mock.calls[0]; + expect((calledCodebase as Codebase).name).toBe('mhooooo/remote-coding-agent'); + }); + + test('matches project case-insensitively', async () => { + buildWorkflowMcpServer(buildDeps({ codebases: [myProject] })); + expect(capturedHandler).not.toBeNull(); + + await capturedHandler!( + { + workflow_name: 'archon-assist', + project_name: 'REMOTE-CODING-AGENT', + task_description: 'test', + }, + {} + ); + + await new Promise(r => setTimeout(r, 0)); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const [calledCodebase] = (dispatchMock as { mock: { calls: unknown[][] } }).mock.calls[0]; + expect((calledCodebase as Codebase).name).toBe('remote-coding-agent'); + }); +}); diff --git a/packages/core/src/orchestrator/workflow-tool.ts b/packages/core/src/orchestrator/workflow-tool.ts new file mode 100644 index 0000000000..6d0700e584 --- /dev/null +++ b/packages/core/src/orchestrator/workflow-tool.ts @@ -0,0 +1,124 @@ +/** + * Workflow MCP Tool — in-process MCP server for orchestrator sessions. + * + * Registers a single `invoke_workflow` tool that Claude can call natively, + * replacing the fragile `/invoke-workflow` text-parsing approach. + */ +import { + createSdkMcpServer, + tool, + type McpSdkServerConfigWithInstance, +} from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { createLogger } from '@archon/paths'; +import { findWorkflow } from '@archon/workflows/router'; +import type { WorkflowDefinition } from '@archon/workflows/schemas/workflow'; +import type { Codebase, IPlatformAdapter, HandleMessageContext, Conversation } from '../types'; +import { findCodebaseByName } from './codebase-utils'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('workflow-tool'); + return cachedLog; +} + +export interface WorkflowToolDeps { + platform: IPlatformAdapter; + conversationId: string; + conversation: Conversation; + codebases: readonly Codebase[]; + workflows: readonly WorkflowDefinition[]; + isolationHints?: HandleMessageContext['isolationHints']; + dispatch: ( + codebase: Codebase, + workflow: WorkflowDefinition, + taskDescription: string + ) => Promise; +} + +/** + * Build an in-process MCP server with a single `invoke_workflow` tool. + * Pass the returned value in `requestOptions.mcpServers` on each `sendQuery` call. + */ +export function buildWorkflowMcpServer(deps: WorkflowToolDeps): McpSdkServerConfigWithInstance { + const workflowTool = tool( + 'invoke_workflow', + 'Dispatch an Archon workflow for a registered project. Use this when the user wants structured development work (e.g. fix an issue, implement a feature, create a plan). The workflow runs in the background — this tool returns immediately.', + { + workflow_name: z + .string() + .describe('Exact workflow name (e.g., "archon-fix-github-issue-dag", "archon-assist")'), + project_name: z.string().describe('Project name (e.g., "remote-coding-agent")'), + task_description: z + .string() + .min(1, 'task_description cannot be empty') + .describe( + 'Complete, self-contained description of the task. Must make sense with NO knowledge of this conversation. Do NOT use vague references like "do what we discussed".' + ), + }, + async ( + args: { workflow_name: string; project_name: string; task_description: string }, + _extra: unknown + ) => { + const workflow = findWorkflow(args.workflow_name, [...deps.workflows]); + if (!workflow) { + const available = deps.workflows.map(w => w.name).join(', ') || 'none'; + return { + content: [ + { + type: 'text', + text: `Error: workflow "${args.workflow_name}" not found. Available workflows: ${available}`, + }, + ], + }; + } + + const codebase = findCodebaseByName(deps.codebases, args.project_name); + if (!codebase) { + const available = deps.codebases.map(c => c.name).join(', ') || 'none'; + return { + content: [ + { + type: 'text', + text: `Error: project "${args.project_name}" not found. Registered projects: ${available}`, + }, + ], + }; + } + + // Fire-and-forget — handler returns immediately; workflow runs in background + void deps.dispatch(codebase, workflow, args.task_description).catch((err: unknown) => { + getLog().error( + { + err, + workflowName: workflow.name, + codebaseName: codebase.name, + conversationId: deps.conversationId, + }, + 'workflow_dispatch_error' + ); + // Notify user — fire-and-forget failure must not be silent + void deps.platform.sendMessage( + deps.conversationId, + `⚠️ Failed to start workflow \`${workflow.name}\` for \`${codebase.name}\`. Check server logs or use \`/reset\` to start fresh.` + ); + }); + + return { + content: [ + { + type: 'text', + text: `Dispatched workflow ${workflow.name} for project ${codebase.name}. It is now running in the background.`, + }, + ], + }; + } + ); + + return createSdkMcpServer({ + name: 'archon-tools', + version: '1.0.0', + tools: [workflowTool], + }); +} diff --git a/packages/core/src/state/session-transitions.ts b/packages/core/src/state/session-transitions.ts index efd0e55ccd..eba4229246 100644 --- a/packages/core/src/state/session-transitions.ts +++ b/packages/core/src/state/session-transitions.ts @@ -13,7 +13,8 @@ export type TransitionTrigger = | 'isolation-changed' // Working directory/worktree changed | 'reset-requested' // User requested /reset | 'worktree-removed' // Worktree manually removed - | 'conversation-closed'; // Platform conversation closed (issue/PR closed) + | 'conversation-closed' // Platform conversation closed (issue/PR closed) + | 'stale-session-cleared'; // Auto-reset on SDK stale session error (no conversation found) /** * Behavior category for each trigger. @@ -31,6 +32,7 @@ const TRIGGER_BEHAVIOR: Record { describe('session errors', () => { test('detects lowercase "session" in message', () => { const result = classifyAndFormatError(new Error('session not found')); - expect(result).toBe('⚠️ Session error. Use /reset to start a fresh session.'); + expect(result).toBe('⚠️ Session error. Use `reset` to start a fresh session.'); }); test('detects titlecase "Session" in message', () => { const result = classifyAndFormatError(new Error('Session expired')); - expect(result).toBe('⚠️ Session error. Use /reset to start a fresh session.'); + expect(result).toBe('⚠️ Session error. Use `reset` to start a fresh session.'); }); test('matches session anywhere in message', () => { const result = classifyAndFormatError(new Error('Failed to resume session state')); - expect(result).toBe('⚠️ Session error. Use /reset to start a fresh session.'); + expect(result).toBe('⚠️ Session error. Use `reset` to start a fresh session.'); }); }); diff --git a/packages/core/src/utils/error-formatter.ts b/packages/core/src/utils/error-formatter.ts index 86e51f8a41..3686503d8d 100644 --- a/packages/core/src/utils/error-formatter.ts +++ b/packages/core/src/utils/error-formatter.ts @@ -40,7 +40,7 @@ export function classifyAndFormatError(error: Error): string { // Session errors if (message.includes('session') || message.includes('Session')) { - return '⚠️ Session error. Use /reset to start a fresh session.'; + return '⚠️ Session error. Use `reset` to start a fresh session.'; } if (message.startsWith('❌ Model "') && message.includes('not available for your account')) {