From d029d1ebcfbb5cd48a93fa3043423736d3a39af5 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 15 Apr 2026 14:24:02 -0500 Subject: [PATCH 1/5] fix: use composite key (name, default_cwd) for codebase identity (#1192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codebase identity was derived solely from the remote URL name (owner/repo), causing multiple local clones of the same remote to share a single codebase_id. This leaked conversations, sessions, env vars, and isolation environments across clones. Changes: - Add findCodebaseByNameAndPath() for composite (name, path) lookups - Update registerRepoAtPath to use composite dedup: same name + different local path now creates a distinct codebase row - Preserve managed-to-local path upgrade (archon workspace → local checkout) - Add UNIQUE INDEX on (name, default_cwd) for both SQLite and PostgreSQL - Backward compatible: existing single-clone installs are found by findCodebaseByDefaultCwd before registerRepoAtPath is reached Closes #1192 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../022_codebase_composite_identity.sql | 11 ++ packages/core/src/db/adapters/sqlite.ts | 17 +++ packages/core/src/db/codebases.ts | 21 +++ packages/core/src/handlers/clone.test.ts | 125 ++++++++++++------ packages/core/src/handlers/clone.ts | 47 +++++-- 5 files changed, 167 insertions(+), 54 deletions(-) create mode 100644 migrations/022_codebase_composite_identity.sql diff --git a/migrations/022_codebase_composite_identity.sql b/migrations/022_codebase_composite_identity.sql new file mode 100644 index 0000000000..c28df20c89 --- /dev/null +++ b/migrations/022_codebase_composite_identity.sql @@ -0,0 +1,11 @@ +-- Make codebase identity composite: (name, default_cwd). +-- Multiple local clones of the same remote now get distinct codebase_id values, +-- preventing conversations, sessions, env vars, and isolation environments from +-- leaking across clones. +-- +-- Existing single-clone installs are unaffected — the unique index only +-- prevents future duplicate (name, path) pairs, and the application layer +-- handles name-only lookups for backward compatibility. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd + ON remote_agent_codebases (name, default_cwd); diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 485706d040..2f7a2be7e8 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -153,6 +153,7 @@ export class SqliteAdapter implements IDatabase { private initSchema(): void { this.createSchema(); this.migrateColumns(); + this.migrateCodebaseIdentity(); } /** @@ -217,6 +218,22 @@ export class SqliteAdapter implements IDatabase { } } + /** + * Add a unique index on (name, default_cwd) to codebases so that multiple + * local clones of the same remote get distinct codebase_id values. + * Uses CREATE UNIQUE INDEX IF NOT EXISTS — idempotent for databases that + * already have the index (new installs get it from createSchema). + */ + private migrateCodebaseIdentity(): void { + try { + this.db.run( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd ON remote_agent_codebases (name, default_cwd)' + ); + } catch (e: unknown) { + getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebase_identity_failed'); + } + } + /** * Create all tables. * diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index f3947fb6c1..572f64e4e2 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -115,6 +115,27 @@ export async function findCodebaseByPathPrefix(cwdPath: string): Promise { + const result = await pool.query( + 'SELECT * FROM remote_agent_codebases WHERE name = $1 AND default_cwd = $2 LIMIT 1', + [name, defaultCwd] + ); + return result.rows[0] || null; +} + +/** + * Find a codebase by name only. + * When multiple rows share the same name (different local paths), returns the + * most recently created one. Callers that have path context should prefer + * {@link findCodebaseByNameAndPath} for unambiguous lookup. + */ export async function findCodebaseByName(name: string): Promise { const result = await pool.query( 'SELECT * FROM remote_agent_codebases WHERE name = $1 ORDER BY created_at DESC LIMIT 1', diff --git a/packages/core/src/handlers/clone.test.ts b/packages/core/src/handlers/clone.test.ts index c913c1a78c..9ef3c0baa9 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -29,6 +29,7 @@ const mockGetCodebaseCommands = mock(() => Promise.resolve({})); const mockUpdateCodebaseCommands = mock(() => Promise.resolve()); const mockFindCodebaseByRepoUrl = mock(() => Promise.resolve(null)); const mockFindCodebaseByDefaultCwd = mock(() => Promise.resolve(null)); +const mockFindCodebaseByNameAndPath = mock(() => Promise.resolve(null)); const mockFindCodebaseByName = mock(() => Promise.resolve(null)); const mockUpdateCodebase = mock(() => Promise.resolve()); @@ -38,6 +39,7 @@ mock.module('../db/codebases', () => ({ updateCodebaseCommands: mockUpdateCodebaseCommands, findCodebaseByRepoUrl: mockFindCodebaseByRepoUrl, findCodebaseByDefaultCwd: mockFindCodebaseByDefaultCwd, + findCodebaseByNameAndPath: mockFindCodebaseByNameAndPath, findCodebaseByName: mockFindCodebaseByName, updateCodebase: mockUpdateCodebase, })); @@ -100,6 +102,7 @@ function clearMocks(): void { mockUpdateCodebaseCommands.mockReset(); mockFindCodebaseByRepoUrl.mockReset(); mockFindCodebaseByDefaultCwd.mockReset(); + mockFindCodebaseByNameAndPath.mockReset(); mockFindCodebaseByName.mockReset(); mockUpdateCodebase.mockReset(); mockFindMarkdownFilesRecursive.mockReset(); @@ -113,6 +116,7 @@ function clearMocks(): void { mockUpdateCodebaseCommands.mockResolvedValue(undefined); mockFindCodebaseByRepoUrl.mockResolvedValue(null); mockFindCodebaseByDefaultCwd.mockResolvedValue(null); + mockFindCodebaseByNameAndPath.mockResolvedValue(null); mockFindCodebaseByName.mockResolvedValue(null); mockUpdateCodebase.mockResolvedValue(undefined); mockFindMarkdownFilesRecursive.mockResolvedValue([]); @@ -776,7 +780,7 @@ describe('normalizeRepoUrl (via cloneRepository)', () => { }); // ──────────────────────────────────────────────────────────────────────────── -describe('name-based deduplication', () => { +describe('composite identity deduplication', () => { beforeEach(() => { clearMocks(); restoreSpies(); @@ -784,15 +788,48 @@ describe('name-based deduplication', () => { delete process.env.GH_TOKEN; }); - test('should return existing codebase when registering same owner/repo via different path', async () => { - // Existing codebase registered via clone (managed path) + test('should create distinct codebase when same remote is registered from a different path', async () => { + // First clone exists at a managed path — but composite lookup (name + new path) returns null + spyExecFileAsync.mockImplementation((cmd: string, args: string[]) => { + if (args.includes('rev-parse')) return Promise.resolve({ stdout: '.git', stderr: '' }); + if (args.includes('get-url')) + return Promise.resolve({ stdout: 'https://github.com/owner/repo', stderr: '' }); + return Promise.resolve({ stdout: '', stderr: '' }); + }); + mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(null); + // Composite lookup: no match for (owner/repo, /home/user/repo) + mockFindCodebaseByNameAndPath.mockResolvedValueOnce(null); + // Name-only lookup: finds existing, but path differs and is NOT managed + mockFindCodebaseByName.mockResolvedValueOnce( + makeCodebase({ + id: 'existing-id', + name: 'owner/repo', + default_cwd: '/home/user/other-checkout', + }) + ); + // New codebase created for the distinct clone + mockCreateCodebase.mockResolvedValueOnce( + makeCodebase({ + id: 'new-clone-id', + name: 'owner/repo', + default_cwd: '/home/user/repo', + }) as ReturnType + ); + + const result = await registerRepository('/home/user/repo'); + + expect(result.alreadyExisted).toBe(false); + expect(result.codebaseId).toBe('new-clone-id'); + expect(mockCreateCodebase.mock.calls.length).toBe(1); + }); + + test('should reuse existing codebase when re-registering the same path', async () => { const existingCodebase = makeCodebase({ id: 'existing-id', name: 'owner/repo', repository_url: 'https://github.com/owner/repo', - default_cwd: '/home/test/.archon/workspaces/owner/repo/source', + default_cwd: '/home/user/repo', }); - // registerRepository: rev-parse succeeds, path not in DB, remote URL returns owner/repo spyExecFileAsync.mockImplementation((cmd: string, args: string[]) => { if (args.includes('rev-parse')) return Promise.resolve({ stdout: '.git', stderr: '' }); if (args.includes('get-url')) @@ -800,19 +837,18 @@ describe('name-based deduplication', () => { return Promise.resolve({ stdout: '', stderr: '' }); }); mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(null); - // Name-based lookup finds existing codebase - mockFindCodebaseByName.mockResolvedValueOnce(existingCodebase); + // Composite lookup: exact match for (owner/repo, /home/user/repo) + mockFindCodebaseByNameAndPath.mockResolvedValueOnce(existingCodebase); const result = await registerRepository('/home/user/repo'); expect(result.alreadyExisted).toBe(true); expect(result.codebaseId).toBe('existing-id'); - // createCodebase should NOT be called expect(mockCreateCodebase.mock.calls.length).toBe(0); }); - test('should update default_cwd to local path when local is registered after clone', async () => { - const existingCodebase = makeCodebase({ + test('should upgrade managed path to local path when local is registered after clone', async () => { + const managedCodebase = makeCodebase({ id: 'existing-id', name: 'owner/repo', repository_url: 'https://github.com/owner/repo', @@ -825,44 +861,24 @@ describe('name-based deduplication', () => { return Promise.resolve({ stdout: '', stderr: '' }); }); mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(null); - mockFindCodebaseByName.mockResolvedValueOnce(existingCodebase); + // Composite lookup: no match (name matches but path differs) + mockFindCodebaseByNameAndPath.mockResolvedValueOnce(null); + // Name-only lookup finds the managed-path codebase + mockFindCodebaseByName.mockResolvedValueOnce(managedCodebase); const result = await registerRepository('/home/user/repo'); - // updateCodebase should be called with the local path - expect(mockUpdateCodebase.mock.calls.length).toBe(1); + // Should upgrade the existing managed record to the local path + expect(result.alreadyExisted).toBe(true); + expect(result.codebaseId).toBe('existing-id'); + expect(result.defaultCwd).toBe('/home/user/repo'); + expect(mockUpdateCodebase.mock.calls.length).toBeGreaterThanOrEqual(1); const updateArgs = mockUpdateCodebase.mock.calls[0] as [string, { default_cwd?: string }]; expect(updateArgs[0]).toBe('existing-id'); expect(updateArgs[1].default_cwd).toBe('/home/user/repo'); - expect(result.defaultCwd).toBe('/home/user/repo'); - }); - - test('should not downgrade default_cwd from local to managed path', async () => { - // Existing codebase registered via local path - const existingCodebase = makeCodebase({ - id: 'existing-id', - name: 'owner/repo', - repository_url: 'https://github.com/owner/repo', - default_cwd: '/home/user/repo', - }); - // Clone same repo — name-based lookup finds existing - // .git does NOT exist (proceed to clone), but name dedup catches it - mockFindCodebaseByName.mockResolvedValueOnce(existingCodebase); - mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); - - const result = await cloneRepository('https://github.com/owner/repo'); - - // default_cwd should stay as local path (managed path is NOT "better") - expect(result.defaultCwd).toBe('/home/user/repo'); - // updateCodebase should NOT be called with default_cwd (no downgrade) - if (mockUpdateCodebase.mock.calls.length > 0) { - const updateArgs = mockUpdateCodebase.mock.calls[0] as [string, { default_cwd?: string }]; - expect(updateArgs[1].default_cwd).toBeUndefined(); - } }); test('should fill in repository_url on existing codebase if missing', async () => { - // Existing codebase registered locally without remote URL const existingCodebase = makeCodebase({ id: 'existing-id', name: 'owner/repo', @@ -876,11 +892,11 @@ describe('name-based deduplication', () => { return Promise.resolve({ stdout: '', stderr: '' }); }); mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(null); - mockFindCodebaseByName.mockResolvedValueOnce(existingCodebase); + // Composite lookup: exact match (same name AND same path) + mockFindCodebaseByNameAndPath.mockResolvedValueOnce(existingCodebase); await registerRepository('/home/user/repo'); - // updateCodebase should be called with repository_url expect(mockUpdateCodebase.mock.calls.length).toBe(1); const updateArgs = mockUpdateCodebase.mock.calls[0] as [ string, @@ -888,6 +904,33 @@ describe('name-based deduplication', () => { ]; expect(updateArgs[1].repository_url).toBe('https://github.com/owner/repo'); }); + + test('backward compat: existing single-clone installs found by path continue to work', async () => { + // User has ~/myproject registered as "owner/repo" — the directory name + // doesn't match the remote-derived name. findCodebaseByDefaultCwd catches it. + const existingCodebase = makeCodebase({ + id: 'legacy-id', + name: 'owner/repo', + default_cwd: '/home/user/myproject', + }); + spyExecFileAsync.mockImplementation((cmd: string, args: string[]) => { + if (args.includes('rev-parse')) return Promise.resolve({ stdout: '.git', stderr: '' }); + if (args.includes('get-url')) + return Promise.resolve({ stdout: 'https://github.com/owner/repo', stderr: '' }); + return Promise.resolve({ stdout: '', stderr: '' }); + }); + // Path-based lookup finds it immediately — registerRepoAtPath is never reached + mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(existingCodebase); + + const result = await registerRepository('/home/user/myproject'); + + expect(result.alreadyExisted).toBe(true); + expect(result.codebaseId).toBe('legacy-id'); + // Neither composite nor name lookup should be called + expect(mockFindCodebaseByNameAndPath.mock.calls.length).toBe(0); + expect(mockFindCodebaseByName.mock.calls.length).toBe(0); + expect(mockCreateCodebase.mock.calls.length).toBe(0); + }); }); // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 366a951b8a..722f32bf9f 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -65,18 +65,16 @@ async function registerRepoAtPath( } } - // Check if a codebase with this name already exists (dedup by project identity) - const existing = await codebaseDb.findCodebaseByName(name); + // Dedup by composite identity (name + path). + // 1. Exact (name, path) match → re-registration of the same project + // 2. Same name, different path → distinct clone, create a new row + // 3. Backward compat: existing single-clone installs where the directory + // name doesn't match the remote-derived name are handled by + // registerRepository's findCodebaseByDefaultCwd guard (checked before + // reaching this function). + const existing = await codebaseDb.findCodebaseByNameAndPath(name, targetPath); if (existing) { - // Determine if the new path is "better" (local > archon-managed clone) - const isNewPathLocal = !targetPath.includes('/.archon/workspaces/'); - const isExistingPathManaged = existing.default_cwd.includes('/.archon/workspaces/'); - const shouldUpdateCwd = isNewPathLocal && isExistingPathManaged; - const updates: { default_cwd?: string; repository_url?: string | null } = {}; - if (shouldUpdateCwd) { - updates.default_cwd = targetPath; - } // Fill in repository_url if the existing record doesn't have one if (!existing.repository_url && repositoryUrl) { updates.repository_url = repositoryUrl; @@ -86,10 +84,9 @@ async function registerRepoAtPath( } // Still reload commands for the existing codebase - const effectiveCwd = shouldUpdateCwd ? targetPath : existing.default_cwd; let commandsLoaded = 0; for (const folder of getCommandFolderSearchPaths()) { - const commandPath = join(effectiveCwd, folder); + const commandPath = join(existing.default_cwd, folder); try { await access(commandPath); } catch { @@ -114,12 +111,36 @@ async function registerRepoAtPath( codebaseId: existing.id, name: existing.name, repositoryUrl: existing.repository_url, - defaultCwd: shouldUpdateCwd ? targetPath : existing.default_cwd, + defaultCwd: existing.default_cwd, commandCount: commandsLoaded, alreadyExisted: true, }; } + // Check if a name-only match exists with a managed path that should be + // upgraded to the local path (archon-managed clone → local checkout). + const nameMatch = await codebaseDb.findCodebaseByName(name); + if (nameMatch) { + const isNewPathLocal = !targetPath.includes('/.archon/workspaces/'); + const isExistingPathManaged = nameMatch.default_cwd.includes('/.archon/workspaces/'); + if (isNewPathLocal && isExistingPathManaged) { + // Upgrade managed clone to local path (single identity, new path) + await codebaseDb.updateCodebase(nameMatch.id, { default_cwd: targetPath }); + if (!nameMatch.repository_url && repositoryUrl) { + await codebaseDb.updateCodebase(nameMatch.id, { repository_url: repositoryUrl }); + } + return { + codebaseId: nameMatch.id, + name: nameMatch.name, + repositoryUrl: nameMatch.repository_url ?? repositoryUrl, + defaultCwd: targetPath, + commandCount: 0, + alreadyExisted: true, + }; + } + // Same name, different local path → distinct clone, fall through to create + } + // No existing codebase — create new const codebase = await codebaseDb.createCodebase({ name, From 8d269b8a8ab0e883a05b90044f91e897044b7dbd Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 15 Apr 2026 14:36:30 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(db):=20address=20QA=20round=201=20?= =?UTF-8?q?=E2=80=94=20update=20combined=20migration,=20add=20dedup=20guid?= =?UTF-8?q?ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update 000_combined.sql version comment (001-020 → 001-022) and add the new idx_codebases_name_cwd unique index for fresh PostgreSQL installs - Add pre-check query and dedup guidance to migration 022 for databases that may have duplicate (name, default_cwd) rows Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/000_combined.sql | 6 +++++- migrations/022_codebase_composite_identity.sql | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 176963b40e..c432bc76bc 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -1,5 +1,5 @@ -- Remote Coding Agent - Combined Schema --- Version: Combined (final state after migrations 001-020) +-- Version: Combined (final state after migrations 001-022) -- Description: Complete database schema (idempotent - safe to run multiple times) -- -- 8 Tables: @@ -312,3 +312,7 @@ ALTER TABLE remote_agent_sessions -- From migration 021: allow_env_keys on codebases ALTER TABLE remote_agent_codebases ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE; + +-- From migration 022: composite codebase identity (name + path) +CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd + ON remote_agent_codebases (name, default_cwd); diff --git a/migrations/022_codebase_composite_identity.sql b/migrations/022_codebase_composite_identity.sql index c28df20c89..98a0f725d3 100644 --- a/migrations/022_codebase_composite_identity.sql +++ b/migrations/022_codebase_composite_identity.sql @@ -6,6 +6,11 @@ -- Existing single-clone installs are unaffected — the unique index only -- prevents future duplicate (name, path) pairs, and the application layer -- handles name-only lookups for backward compatibility. +-- +-- Pre-check for duplicates (run before applying if unsure): +-- SELECT name, default_cwd, COUNT(*) FROM remote_agent_codebases +-- GROUP BY name, default_cwd HAVING COUNT(*) > 1; +-- If duplicates exist, merge or delete the extra rows before running this migration. CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd ON remote_agent_codebases (name, default_cwd); From 4f4b33bca29a12c19946c14624b116015973e0b8 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 15 Apr 2026 14:42:44 -0500 Subject: [PATCH 3/5] =?UTF-8?q?fix(core):=20address=20QA=20round=202=20?= =?UTF-8?q?=E2=80=94=20restore=20command=20loading=20on=20path=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add command loading to managed-to-local upgrade path (was returning commandCount: 0 without scanning .archon/commands/ at the new path) - Batch default_cwd + repository_url into a single updateCodebase call - Update upgrade test to verify command loading and batched update Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/handlers/clone.test.ts | 25 ++++++++++++++--- packages/core/src/handlers/clone.ts | 34 +++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/core/src/handlers/clone.test.ts b/packages/core/src/handlers/clone.test.ts index 9ef3c0baa9..a1a7460525 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -851,7 +851,7 @@ describe('composite identity deduplication', () => { const managedCodebase = makeCodebase({ id: 'existing-id', name: 'owner/repo', - repository_url: 'https://github.com/owner/repo', + repository_url: null, default_cwd: '/home/test/.archon/workspaces/owner/repo/source', }); spyExecFileAsync.mockImplementation((cmd: string, args: string[]) => { @@ -860,6 +860,17 @@ describe('composite identity deduplication', () => { return Promise.resolve({ stdout: 'https://github.com/owner/repo', stderr: '' }); return Promise.resolve({ stdout: '', stderr: '' }); }); + // access(): command folder at local path succeeds + spyFsAccess.mockImplementation((path: string) => { + const normalized = typeof path === 'string' ? path.replace(/\\/g, '/') : ''; + if (normalized.includes('.archon/commands')) { + return Promise.resolve(undefined); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + mockFindMarkdownFilesRecursive.mockResolvedValue([ + { commandName: 'deploy', relativePath: 'deploy.md' }, + ]); mockFindCodebaseByDefaultCwd.mockResolvedValueOnce(null); // Composite lookup: no match (name matches but path differs) mockFindCodebaseByNameAndPath.mockResolvedValueOnce(null); @@ -872,10 +883,18 @@ describe('composite identity deduplication', () => { expect(result.alreadyExisted).toBe(true); expect(result.codebaseId).toBe('existing-id'); expect(result.defaultCwd).toBe('/home/user/repo'); - expect(mockUpdateCodebase.mock.calls.length).toBeGreaterThanOrEqual(1); - const updateArgs = mockUpdateCodebase.mock.calls[0] as [string, { default_cwd?: string }]; + // Batched update: default_cwd + repository_url in one call + expect(mockUpdateCodebase.mock.calls.length).toBe(1); + const updateArgs = mockUpdateCodebase.mock.calls[0] as [ + string, + { default_cwd?: string; repository_url?: string | null }, + ]; expect(updateArgs[0]).toBe('existing-id'); expect(updateArgs[1].default_cwd).toBe('/home/user/repo'); + expect(updateArgs[1].repository_url).toBe('https://github.com/owner/repo'); + // Commands loaded from new local path + expect(result.commandCount).toBe(1); + expect(mockUpdateCodebaseCommands.mock.calls.length).toBe(1); }); test('should fill in repository_url on existing codebase if missing', async () => { diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 722f32bf9f..1a8b08b59b 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -125,16 +125,44 @@ async function registerRepoAtPath( const isExistingPathManaged = nameMatch.default_cwd.includes('/.archon/workspaces/'); if (isNewPathLocal && isExistingPathManaged) { // Upgrade managed clone to local path (single identity, new path) - await codebaseDb.updateCodebase(nameMatch.id, { default_cwd: targetPath }); + const updates: { default_cwd: string; repository_url?: string | null } = { + default_cwd: targetPath, + }; if (!nameMatch.repository_url && repositoryUrl) { - await codebaseDb.updateCodebase(nameMatch.id, { repository_url: repositoryUrl }); + updates.repository_url = repositoryUrl; + } + await codebaseDb.updateCodebase(nameMatch.id, updates); + + // Reload commands from the new local path + let commandsLoaded = 0; + for (const folder of getCommandFolderSearchPaths()) { + const commandPath = join(targetPath, folder); + try { + await access(commandPath); + } catch { + continue; + } + const markdownFiles = await findMarkdownFilesRecursive(commandPath); + if (markdownFiles.length > 0) { + const commands = { ...(await codebaseDb.getCodebaseCommands(nameMatch.id)) }; + markdownFiles.forEach(({ commandName, relativePath }) => { + commands[commandName] = { + path: join(folder, relativePath), + description: `From ${folder}`, + }; + }); + await codebaseDb.updateCodebaseCommands(nameMatch.id, commands); + commandsLoaded = markdownFiles.length; + break; + } } + return { codebaseId: nameMatch.id, name: nameMatch.name, repositoryUrl: nameMatch.repository_url ?? repositoryUrl, defaultCwd: targetPath, - commandCount: 0, + commandCount: commandsLoaded, alreadyExisted: true, }; } From 2df62350319e60f176b2c15f9cefb532bc869c9d Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 15 Apr 2026 14:51:48 -0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(db):=20address=20QA=20round=203=20?= =?UTF-8?q?=E2=80=94=20add=20composite=20index=20to=20createSchema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add idx_codebases_name_cwd to SQLite createSchema() so fresh installs get the unique index from the table creation block, consistent with the PostgreSQL combined migration. The migrateCodebaseIdentity() call remains as a no-op safety net for existing databases. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/db/adapters/sqlite.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 2f7a2be7e8..07f1978449 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -392,6 +392,10 @@ export class SqliteAdapter implements IDatabase { ON remote_agent_sessions(parent_session_id); CREATE INDEX IF NOT EXISTS idx_sessions_conversation_started ON remote_agent_sessions(conversation_id, started_at DESC); + + -- From PG migration 022: composite codebase identity (name + path) + CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd + ON remote_agent_codebases (name, default_cwd); `); getLog().info('db.sqlite_schema_initialized'); } From 2baf383bbfff131b6f9010f49a1d436a7545a1bb Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 15 Apr 2026 14:56:55 -0500 Subject: [PATCH 5/5] =?UTF-8?q?fix(core):=20address=20CodeRabbit=20?= =?UTF-8?q?=E2=80=94=20fail-fast=20on=20identity=20migration,=20return=20b?= =?UTF-8?q?ackfilled=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLite migrateCodebaseIdentity now throws on failure instead of warn-and-continue, per fail-fast principle (duplicate rows must be resolved before startup) - Exact-match return now uses `existing.repository_url ?? repositoryUrl` so callers see the backfilled URL immediately Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/db/adapters/sqlite.ts | 6 +++++- packages/core/src/handlers/clone.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 07f1978449..e2fd0e25f3 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -230,7 +230,11 @@ export class SqliteAdapter implements IDatabase { 'CREATE UNIQUE INDEX IF NOT EXISTS idx_codebases_name_cwd ON remote_agent_codebases (name, default_cwd)' ); } catch (e: unknown) { - getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebase_identity_failed'); + const err = e as Error; + getLog().error({ err }, 'db.sqlite_migration_codebase_identity_failed'); + throw new Error( + 'Failed to enforce unique codebase identity. Resolve duplicate (name, default_cwd) rows in remote_agent_codebases before restarting.' + ); } } diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 1a8b08b59b..928db25d4d 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -110,7 +110,7 @@ async function registerRepoAtPath( return { codebaseId: existing.id, name: existing.name, - repositoryUrl: existing.repository_url, + repositoryUrl: existing.repository_url ?? repositoryUrl, defaultCwd: existing.default_cwd, commandCount: commandsLoaded, alreadyExisted: true,