diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index b90ae3cd62..11ff15f0ea 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -51,9 +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: mockEnsureArchonWorkspacesPath, getArchonHome: mock(() => '/home/test/.archon'), })); @@ -906,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)); @@ -931,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 () => { 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,