From 32a8f4e12388f9170bd6c4882bbaeed5ce62c556 Mon Sep 17 00:00:00 2001 From: truffle Date: Sat, 2 May 2026 00:14:07 +0000 Subject: [PATCH 1/2] fix(orchestrator): create ~/.archon/workspaces before AI provider spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh install, ~/.archon/workspaces doesn't exist yet. The orchestrator passes that path as cwd to the AI provider, which calls spawn() — which raises ENOENT. The error is then misclassified as "binary not found" in the friendly-error path, surfacing as an incorrect "Claude binary not found" message. Add ensureArchonWorkspacesPath() in @archon/paths that mkdir -p's the directory and returns the path. Use it at the orchestrator's spawn-cwd site so the directory is guaranteed to exist before spawn(). Other call sites of getArchonWorkspacesPath() (workflow discovery, path-prefix comparisons) only consume the path string and don't need the directory to exist; they keep using the pure getter. Closes #1528 --- .../orchestrator/orchestrator-agent.test.ts | 1 + .../src/orchestrator/orchestrator-agent.ts | 4 +- .../orchestrator-isolation.test.ts | 1 + .../src/orchestrator/orchestrator.test.ts | 1 + packages/paths/src/archon-paths.test.ts | 38 +++++++++++++++++++ packages/paths/src/archon-paths.ts | 10 +++++ packages/paths/src/index.ts | 1 + 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index b90ae3cd62..f28ca30524 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -54,6 +54,7 @@ const mockLogger = createMockLogger(); mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), getArchonWorkspacesPath: mock(() => '/home/test/.archon/workspaces'), + ensureArchonWorkspacesPath: mock(() => Promise.resolve('/home/test/.archon/workspaces')), getArchonHome: mock(() => '/home/test/.archon'), })); diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 8521e83a82..943b0f0b58 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -25,7 +25,7 @@ import { formatToolCall } from '@archon/workflows/utils/tool-formatter'; import { classifyAndFormatError } from '../utils/error-formatter'; import { toError } from '../utils/error'; import { getAgentProvider, getProviderCapabilities } from '@archon/providers'; -import { getArchonWorkspacesPath } from '@archon/paths'; +import { getArchonWorkspacesPath, ensureArchonWorkspacesPath } from '@archon/paths'; import { syncArchonToWorktree } from '../utils/worktree-sync'; import { syncWorkspace, toRepoPath } from '@archon/git'; import type { WorkspaceSyncResult } from '@archon/git'; @@ -821,7 +821,7 @@ export async function handleMessage( attachedFiles, workflowContext ); - const cwd = getArchonWorkspacesPath(); + const cwd = await ensureArchonWorkspacesPath(); // 4. Update activity and get/create session await db.touchConversation(conversation.id); diff --git a/packages/core/src/orchestrator/orchestrator-isolation.test.ts b/packages/core/src/orchestrator/orchestrator-isolation.test.ts index 6bcbedb697..9d86303a86 100644 --- a/packages/core/src/orchestrator/orchestrator-isolation.test.ts +++ b/packages/core/src/orchestrator/orchestrator-isolation.test.ts @@ -10,6 +10,7 @@ const mockLogger = createMockLogger(); mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), getArchonWorkspacesPath: mock(() => '/home/test/.archon/workspaces'), + ensureArchonWorkspacesPath: mock(() => Promise.resolve('/home/test/.archon/workspaces')), getArchonHome: mock(() => '/home/test/.archon'), })); diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index 58e5ac304e..570c466ac5 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -12,6 +12,7 @@ const mockLogger = createMockLogger(); mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), getArchonWorkspacesPath: mock(() => '/home/test/.archon/workspaces'), + ensureArchonWorkspacesPath: mock(() => Promise.resolve('/home/test/.archon/workspaces')), getArchonHome: mock(() => '/home/test/.archon'), })); diff --git a/packages/paths/src/archon-paths.test.ts b/packages/paths/src/archon-paths.test.ts index b6584810d4..a4303c7957 100644 --- a/packages/paths/src/archon-paths.test.ts +++ b/packages/paths/src/archon-paths.test.ts @@ -10,6 +10,7 @@ import { isDocker, getArchonHome, getArchonWorkspacesPath, + ensureArchonWorkspacesPath, getArchonWorktreesPath, getArchonConfigPath, getHomeWorkflowsPath, @@ -631,6 +632,43 @@ describe('ensureProjectStructure', () => { }); }); +describe('ensureArchonWorkspacesPath', () => { + let tempArchonHome: string; + useEnvSnapshot(); + + beforeEach(async () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + tempArchonHome = join( + tmpdir(), + `archon-paths-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + process.env.ARCHON_HOME = tempArchonHome; + }); + + afterEach(async () => { + await rm(tempArchonHome, { recursive: true, force: true }); + }); + + test('creates the workspaces directory when missing', async () => { + const expected = getArchonWorkspacesPath(); + expect(existsSync(expected)).toBe(false); + + const returned = await ensureArchonWorkspacesPath(); + + expect(returned).toBe(expected); + expect((await lstat(expected)).isDirectory()).toBe(true); + }); + + test('is idempotent - safe to call twice', async () => { + await ensureArchonWorkspacesPath(); + await ensureArchonWorkspacesPath(); + + const expected = getArchonWorkspacesPath(); + expect((await lstat(expected)).isDirectory()).toBe(true); + }); +}); + describe('createProjectSourceSymlink', () => { let tempArchonHome: string; let tempTarget: string; diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index d6db7cf69a..9a5d30aae4 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -80,6 +80,16 @@ export function getArchonWorkspacesPath(): string { return join(getArchonHome(), 'workspaces'); } +/** + * Ensure the workspaces directory exists and return its path. + * Safe to call on a fresh install before any workspace is registered. + */ +export async function ensureArchonWorkspacesPath(): Promise { + const path = getArchonWorkspacesPath(); + await mkdir(path, { recursive: true }); + return path; +} + /** * Get the global worktrees directory (~/.archon/worktrees/). * Used as the legacy fallback for repos not registered under workspaces/. diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 443d55ff90..a7121201f0 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -4,6 +4,7 @@ export { isDocker, getArchonHome, getArchonWorkspacesPath, + ensureArchonWorkspacesPath, getArchonWorktreesPath, getArchonConfigPath, getArchonEnvPath, From 1385e76fc62d060f782db8f454593cd7a08e00f0 Mon Sep 17 00:00:00 2001 From: truffle Date: Sat, 2 May 2026 01:07:11 +0000 Subject: [PATCH 2/2] test(orchestrator): assert ensureArchonWorkspacesPath is called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the @archon/paths mock as a named variable and assert it was called in the syncWorkspace handleMessage path. Without this, the test suite passes even if orchestrator-agent.ts:824 reverts to the non-ensuring getArchonWorkspacesPath() variant — exactly the regression that surfaced as 'Claude Code native binary not found' in #1528. --- packages/core/src/orchestrator/orchestrator-agent.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index f28ca30524..11ff15f0ea 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -51,10 +51,11 @@ const mockLoadConfig = mock(() => const mockLogger = createMockLogger(); +const mockEnsureArchonWorkspacesPath = mock(() => Promise.resolve('/home/test/.archon/workspaces')); mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), getArchonWorkspacesPath: mock(() => '/home/test/.archon/workspaces'), - ensureArchonWorkspacesPath: mock(() => Promise.resolve('/home/test/.archon/workspaces')), + ensureArchonWorkspacesPath: mockEnsureArchonWorkspacesPath, getArchonHome: mock(() => '/home/test/.archon'), })); @@ -907,6 +908,7 @@ describe('discoverAllWorkflows — remote sync', () => { mockSendQuery.mockClear(); mockGetCodebaseEnvVars.mockReset(); mockLoadConfig.mockReset(); + mockEnsureArchonWorkspacesPath.mockClear(); // Reset mocks between tests in this suite and restore safe defaults mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(null)); mockGetCodebase.mockImplementation(() => Promise.resolve(null)); @@ -932,6 +934,9 @@ describe('discoverAllWorkflows — remote sync', () => { expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, { resetAfterFetch: false, }); + // Regression guard: orchestrator must resolve cwd through the ensure variant + // so the workspaces dir is created before the AI provider spawn (issue #1528). + expect(mockEnsureArchonWorkspacesPath).toHaveBeenCalled(); }); test('passes resetAfterFetch=true for managed clones', async () => {