Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
id: bugfix-774
title: multi-architect-routing-builde
protocol: bugfix
phase: investigate
plan_phases: []
current_plan_phase: null
gates: {}
iteration: 1
build_complete: false
history: []
started_at: '2026-05-19T22:15:47.639Z'
updated_at: '2026-05-19T22:15:47.639Z'
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Regression test for Issue #774: Builder→architect messages misrouted when
* worktree has its own state.db.
*
* The bug: `detectCurrentBuilderId()` used `loadState()` (singleton getDb()),
* which resolves to the worktree's own .agent-farm/state.db when CWD is
* inside `.builders/<id>/`. The worktree DB is empty, so the lookup falls
* back to the worktree directory name (e.g. `bugfix-774`) instead of the
* canonical builder ID (`builder-bugfix-774`). That breaks affinity routing
* downstream — the sibling architect that spawned the builder is bypassed
* and the message lands on 'main'.
*
* Fix: open the workspace's state.db directly (not the singleton).
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';

import { detectCurrentBuilderId } from '../commands/send.js';
import { LOCAL_SCHEMA } from '../db/schema.js';

function writeBuilderRow(dbPath: string, id: string, worktree: string): void {
const db = new Database(dbPath);
db.exec(LOCAL_SCHEMA);
db.prepare(
`INSERT INTO builders (id, name, worktree, branch, type, status, spawned_by_architect)
VALUES (?, ?, ?, ?, 'bugfix', 'implementing', 'ob-refine')`,
).run(id, id, worktree, `builder/${id}`);
db.close();
}

describe('detectCurrentBuilderId — issue #774', () => {
let tmpRoot: string;
let workspacePath: string;
let worktreePath: string;
const origCwd = process.cwd();

beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'bugfix-774-'));
workspacePath = join(tmpRoot, 'workspace');
worktreePath = join(workspacePath, '.builders', 'bugfix-1599');

mkdirSync(join(workspacePath, '.agent-farm'), { recursive: true });
mkdirSync(worktreePath, { recursive: true });

// Populate the WORKSPACE state.db with the canonical builder row.
writeBuilderRow(
join(workspacePath, '.agent-farm', 'state.db'),
'builder-bugfix-1599',
worktreePath,
);
});

afterEach(() => {
process.chdir(origCwd);
rmSync(tmpRoot, { recursive: true, force: true });
});

it('returns canonical ID when CWD is the worktree and worktree has no state.db', () => {
process.chdir(worktreePath);
expect(detectCurrentBuilderId()).toBe('builder-bugfix-1599');
});

it('returns canonical ID even when worktree has its own EMPTY state.db (the original bug)', () => {
// Simulate the v3.0.5 bug: the worktree opened its own state.db (created
// by a stray getDb() call) which has zero builder rows.
mkdirSync(join(worktreePath, '.agent-farm'), { recursive: true });
const worktreeDbPath = join(worktreePath, '.agent-farm', 'state.db');
const emptyDb = new Database(worktreeDbPath);
emptyDb.exec(LOCAL_SCHEMA);
emptyDb.close();

process.chdir(worktreePath);
expect(detectCurrentBuilderId()).toBe('builder-bugfix-1599');
});

it('falls back to worktree dir name when workspace state.db is missing', () => {
rmSync(join(workspacePath, '.agent-farm'), { recursive: true });
process.chdir(worktreePath);
expect(detectCurrentBuilderId()).toBe('bugfix-1599');
});

it('falls back to worktree dir name when no row matches', () => {
// Workspace DB exists but has no row for this worktree.
const wsDb = new Database(join(workspacePath, '.agent-farm', 'state.db'));
wsDb.prepare('DELETE FROM builders').run();
wsDb.close();

process.chdir(worktreePath);
expect(detectCurrentBuilderId()).toBe('bugfix-1599');
});

it('returns null when not in a builder worktree', () => {
process.chdir(workspacePath);
expect(detectCurrentBuilderId()).toBeNull();
});
});
63 changes: 49 additions & 14 deletions packages/codev/src/agent-farm/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { readFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import Database from 'better-sqlite3';
import type { SendOptions } from '../types.js';
import { logger, fatal } from '../utils/logger.js';
import { loadState } from '../state.js';
Expand Down Expand Up @@ -41,28 +42,62 @@ export function detectWorkspaceRoot(): string | null {

/**
* Detect the current builder ID from worktree path.
* Looks up the canonical builder ID from state.db by matching worktree path.
* Falls back to the worktree directory name if no match in state.db.
*
* Looks up the canonical builder ID by opening the **workspace's** state.db
* directly (not the singleton). When CWD is `.builders/<id>/`, the singleton
* `getDb()` resolves to the worktree's own state.db — which is empty because
* the worktree is itself a full git checkout with its own `codev/`. Reading
* that empty DB causes the lookup to miss and the function to fall back to
* the worktree directory name (e.g. `bugfix-774`), breaking multi-architect
* routing downstream because the canonical ID is `builder-bugfix-774`.
*
* Mirrors the per-workspace-handle pattern used by
* `lookupBuilderSpawningArchitect` in state.ts. Issue #774.
*
* Falls back to the worktree directory name only as a safety net.
* Returns null if not in a builder worktree.
*/
export function detectCurrentBuilderId(): string | null {
const cwd = process.cwd();
// Builder worktrees are at .builders/<dir-name>/
const match = cwd.match(/\.builders\/([^/]+)/);
const match = cwd.match(/^(.+?)\/\.builders\/([^/]+)/);
if (!match) return null;

const worktreeDirName = match[1];
const workspacePath = match[1];
const worktreeDirName = match[2];

// Look up the canonical builder ID from state.db by matching worktree path
const state = loadState();
const builder = state.builders.find(b => {
if (!b.worktree) return false;
// Match on the worktree directory name (last segment of the path)
const builderWorktreeDir = b.worktree.split('/').pop();
return builderWorktreeDir === worktreeDirName;
});

return builder ? builder.id : worktreeDirName;
// Open the WORKSPACE's state.db readonly — not the singleton getDb(),
// which resolves to the worktree-local state.db when CWD is inside
// .builders/<id>/.
const dbPath = join(workspacePath, '.agent-farm', 'state.db');
if (!existsSync(dbPath)) return worktreeDirName;

let wsDb: Database.Database;
try {
wsDb = new Database(dbPath, { readonly: true });
} catch {
return worktreeDirName;
}

try {
// Match by canonical worktree path first (most precise), then fall back
// to a tail-segment match for legacy rows that recorded a different
// absolute prefix.
const canonicalWorktree = join(workspacePath, '.builders', worktreeDirName);
const rows = wsDb
.prepare('SELECT id, worktree FROM builders WHERE worktree IS NOT NULL')
.all() as Array<{ id: string; worktree: string }>;

const exact = rows.find(r => r.worktree === canonicalWorktree);
if (exact) return exact.id;

const tail = rows.find(r => r.worktree.split('/').pop() === worktreeDirName);
if (tail) return tail.id;

return worktreeDirName;
} finally {
wsDb.close();
}
}

/**
Expand Down
Loading