diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 7f13f8d83f..2b7dd05db6 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -94,6 +94,7 @@ mock.module('@archon/workflows/event-emitter', () => ({ mock.module('@archon/git', () => ({ findRepoRoot: mock(() => Promise.resolve(null)), + getCanonicalRepoPath: mock(() => Promise.reject(new Error('not a git repo'))), getRemoteUrl: mock(() => Promise.resolve(null)), checkout: mock(() => Promise.resolve()), toRepoPath: mock((path: string) => path), @@ -975,6 +976,200 @@ describe('workflowRunCommand', () => { consoleWarnSpy.mockRestore(); } }); + + it('skips reuse when existing env has different source_repo_root (GH-1183)', async () => { + const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery'); + const { executeWorkflow } = await import('@archon/workflows/executor'); + const conversationDb = await import('@archon/core/db/conversations'); + const codebaseDb = await import('@archon/core/db/codebases'); + const isolationDb = await import('@archon/core/db/isolation-environments'); + const gitModule = await import('@archon/git'); + const isolation = await import('@archon/isolation'); + + (discoverWorkflowsWithConfig as ReturnType).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce({ + id: 'cb-123', + default_cwd: '/clone-b', + }); + // Existing env was created from /clone-a + (isolationDb.findActiveByWorkflow as ReturnType).mockResolvedValueOnce({ + id: 'env-1', + working_path: '/worktrees/feat', + branch_name: 'my-feature', + workflow_type: 'task', + workflow_id: 'my-feature', + metadata: { source_repo_root: '/clone-a' }, + }); + // Current cwd resolves to /clone-b (findRepoRoot → getCanonicalRepoPath chain) + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/clone-b'); + (gitModule.getCanonicalRepoPath as ReturnType).mockResolvedValueOnce('/clone-b'); + (conversationDb.updateConversation as ReturnType).mockResolvedValueOnce(undefined); + (executeWorkflow as ReturnType).mockResolvedValueOnce({ + success: true, + workflowRunId: 'run-123', + }); + + await workflowRunCommand('/clone-b', 'assist', 'hello', { branchName: 'my-feature' }); + + // Should have created a new worktree instead of reusing + const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType; + const provider = getIsolationProviderMock.mock.results.at(-1)?.value as { + create: ReturnType; + }; + expect(provider.create).toHaveBeenCalled(); + }); + + it('reuses existing env when source_repo_root is absent (backward compat)', async () => { + const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery'); + const { executeWorkflow } = await import('@archon/workflows/executor'); + const conversationDb = await import('@archon/core/db/conversations'); + const codebaseDb = await import('@archon/core/db/codebases'); + const isolationDb = await import('@archon/core/db/isolation-environments'); + const gitModule = await import('@archon/git'); + const isolation = await import('@archon/isolation'); + + (discoverWorkflowsWithConfig as ReturnType).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce({ + id: 'cb-123', + default_cwd: '/clone-a', + }); + // Existing env has no source_repo_root (pre-existing) + (isolationDb.findActiveByWorkflow as ReturnType).mockResolvedValueOnce({ + id: 'env-1', + working_path: '/worktrees/feat', + branch_name: 'my-feature', + workflow_type: 'task', + workflow_id: 'my-feature', + metadata: {}, + }); + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/clone-a'); + (gitModule.getCanonicalRepoPath as ReturnType).mockResolvedValueOnce('/clone-a'); + (conversationDb.updateConversation as ReturnType).mockResolvedValueOnce(undefined); + (executeWorkflow as ReturnType).mockResolvedValueOnce({ + success: true, + workflowRunId: 'run-123', + }); + + await workflowRunCommand('/clone-a', 'assist', 'hello', { branchName: 'my-feature' }); + + // Should NOT have created a new worktree — reuse path taken + const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType; + const provider = getIsolationProviderMock.mock.results.at(-1)?.value as { + create: ReturnType; + }; + expect(provider.create).not.toHaveBeenCalled(); + }); + + it('reuses existing env when source_repo_root matches current root', async () => { + const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery'); + const { executeWorkflow } = await import('@archon/workflows/executor'); + const conversationDb = await import('@archon/core/db/conversations'); + const codebaseDb = await import('@archon/core/db/codebases'); + const isolationDb = await import('@archon/core/db/isolation-environments'); + const gitModule = await import('@archon/git'); + const isolation = await import('@archon/isolation'); + + (discoverWorkflowsWithConfig as ReturnType).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce({ + id: 'cb-123', + default_cwd: '/clone-a', + }); + // Existing env was created from same checkout + (isolationDb.findActiveByWorkflow as ReturnType).mockResolvedValueOnce({ + id: 'env-1', + working_path: '/worktrees/feat', + branch_name: 'my-feature', + workflow_type: 'task', + workflow_id: 'my-feature', + metadata: { source_repo_root: '/clone-a' }, + }); + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/clone-a'); + (gitModule.getCanonicalRepoPath as ReturnType).mockResolvedValueOnce('/clone-a'); + (conversationDb.updateConversation as ReturnType).mockResolvedValueOnce(undefined); + (executeWorkflow as ReturnType).mockResolvedValueOnce({ + success: true, + workflowRunId: 'run-123', + }); + + await workflowRunCommand('/clone-a', 'assist', 'hello', { branchName: 'my-feature' }); + + // Should NOT have created a new worktree — same checkout, reuse is safe + const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType; + const provider = getIsolationProviderMock.mock.results.at(-1)?.value as { + create: ReturnType; + }; + expect(provider.create).not.toHaveBeenCalled(); + }); + + it('allows reuse with warning when repo root is undetectable but env has source_repo_root', async () => { + const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery'); + const { executeWorkflow } = await import('@archon/workflows/executor'); + const conversationDb = await import('@archon/core/db/conversations'); + const codebaseDb = await import('@archon/core/db/codebases'); + const isolationDb = await import('@archon/core/db/isolation-environments'); + const gitModule = await import('@archon/git'); + const isolation = await import('@archon/isolation'); + + (discoverWorkflowsWithConfig as ReturnType).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce({ + id: 'cb-123', + default_cwd: '/clone-a', + }); + // Env has a known source_repo_root + (isolationDb.findActiveByWorkflow as ReturnType).mockResolvedValueOnce({ + id: 'env-1', + working_path: '/worktrees/feat', + branch_name: 'my-feature', + workflow_type: 'task', + workflow_id: 'my-feature', + metadata: { source_repo_root: '/clone-a' }, + }); + // findRepoRoot returns null by default (not a git repo) — currentRepoRoot stays null + mockLogger.warn.mockClear(); + (conversationDb.updateConversation as ReturnType).mockResolvedValueOnce(undefined); + (executeWorkflow as ReturnType).mockResolvedValueOnce({ + success: true, + workflowRunId: 'run-123', + }); + + await workflowRunCommand('/clone-a', 'assist', 'hello', { branchName: 'my-feature' }); + + // Should NOT have created a new worktree — fallback allows reuse when root undetectable + const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType; + const provider = getIsolationProviderMock.mock.results.at(-1)?.value as { + create: ReturnType; + }; + expect(provider.create).not.toHaveBeenCalled(); + // Verify the warning was actually logged + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ envSourceRoot: '/clone-a' }), + 'worktree.reuse_root_undetectable' + ); + }); }); describe('workflowStatusCommand', () => { diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 6ba31d1256..f6ec8ff87b 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -423,7 +423,48 @@ export async function workflowRunCommand( ? await isolationDb.findActiveByWorkflow(codebase.id, 'task', options.branchName) : undefined; - if (existingEnv && (await provider.healthCheck(existingEnv.working_path))) { + // Guard: skip reuse if the existing environment was created from a different + // local clone of the same remote (GH-1183). Two clones share one codebase_id + // (derived from the remote URL), so findActiveByWorkflow can return an + // environment that belongs to a sibling checkout. Compare the canonical repo + // path (stable across linked worktrees) recorded at creation time with the + // current working directory's canonical path. + let currentRepoRoot: string | null = null; + try { + // Resolve subdirectory to repo top-level first, then canonicalize to handle + // linked worktrees (getCanonicalRepoPath doesn't resolve subdirs itself). + const repoRoot = await git.findRepoRoot(cwd); + currentRepoRoot = repoRoot ? await git.getCanonicalRepoPath(repoRoot) : null; + } catch (error) { + // findRepoRoot returns null for non-git dirs (expected), but both functions + // throw on unexpected errors (permissions, I/O). Log and fall through — + // the guard will allow reuse when currentRepoRoot is null. + getLog().debug({ err: error, cwd }, 'worktree.reuse_root_detection_failed'); + } + const envSourceRoot = + existingEnv && typeof existingEnv.metadata?.source_repo_root === 'string' + ? existingEnv.metadata.source_repo_root + : undefined; + + // Intentional: when currentRepoRoot or envSourceRoot is unavailable, fall back + // to allowing reuse. This preserves backward compat for pre-existing envs and + // avoids blocking workflows when git is temporarily unavailable. Risk: could + // allow cross-checkout reuse in edge cases — logged as a warning below. + const reuseSameCheckout = + !existingEnv || !envSourceRoot || !currentRepoRoot || envSourceRoot === currentRepoRoot; + + if (existingEnv && envSourceRoot && !currentRepoRoot) { + getLog().warn( + { path: existingEnv.working_path, envSourceRoot }, + 'worktree.reuse_root_undetectable' + ); + } + + if ( + existingEnv && + reuseSameCheckout && + (await provider.healthCheck(existingEnv.working_path)) + ) { if (options.fromBranch) { getLog().warn( { path: existingEnv.working_path, fromBranch: options.fromBranch }, @@ -463,6 +504,14 @@ export async function workflowRunCommand( workingCwd = existingEnv.working_path; isolationEnvId = existingEnv.id; } else { + // Log when skipping reuse due to cross-checkout mismatch (GH-1183) + if (existingEnv && !reuseSameCheckout) { + getLog().warn( + { path: existingEnv.working_path, envSourceRoot, currentRepoRoot }, + 'worktree.reuse_different_checkout' + ); + } + // Create new worktree getLog().info( { branch: branchIdentifier, fromBranch: options.fromBranch }, @@ -489,7 +538,7 @@ export async function workflowRunCommand( working_path: isolatedEnv.workingPath, branch_name: isolatedEnv.branchName, created_by_platform: 'cli', - metadata: {}, + metadata: { ...(currentRepoRoot ? { source_repo_root: currentRepoRoot } : {}) }, }); workingCwd = isolatedEnv.workingPath;