From 7757d83dab43cf9eb08aaf7633c3b7e10fa38c67 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Tue, 19 May 2026 15:15:47 -0700 Subject: [PATCH 1/2] chore(porch): bugfix-774 init bugfix --- .../status.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 codev/projects/bugfix-774-multi-architect-routing-builde/status.yaml diff --git a/codev/projects/bugfix-774-multi-architect-routing-builde/status.yaml b/codev/projects/bugfix-774-multi-architect-routing-builde/status.yaml new file mode 100644 index 00000000..4a1621a5 --- /dev/null +++ b/codev/projects/bugfix-774-multi-architect-routing-builde/status.yaml @@ -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' From f66d9431f620782c94ab7fcb897c541dc84ec445 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Tue, 19 May 2026 15:22:46 -0700 Subject: [PATCH 2/2] [Bugfix #774] Open workspace state.db in detectCurrentBuilderId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a builder runs `afx send architect` from inside `.builders//`, `detectCurrentBuilderId()` resolved the singleton DB via getDb(), which walks up via findWorkspaceRoot() and stops at the worktree itself (worktrees are full git checkouts with their own codev/). The result is that getDb() opens the worktree-local `.agent-farm/state.db`, which is empty — so the lookup misses and the function falls back to the worktree directory name (e.g. `bugfix-1599`) instead of the canonical `builder-bugfix-1599`. Downstream, tower-messages.ts looks up the spawning architect by ID; the mismatch causes lookupBuilderSpawningArchitect to return undefined, and the message routes to 'main' instead of the sibling architect that spawned the builder. Multi-architect affinity routing was non-functional. Fix: derive workspace path from the CWD's `.builders//` pattern and open that workspace's state.db directly (readonly), mirroring the per-workspace handle pattern in lookupBuilderSpawningArchitect. The worktree-local state.db is no longer touched. Adds 5 regression tests covering the canonical path, the empty-worktree-DB case (the original bug), missing-DB fallback, no-match fallback, and the non-builder-CWD case. --- .../bugfix-774-detect-builder-id.test.ts | 100 ++++++++++++++++++ .../codev/src/agent-farm/commands/send.ts | 63 ++++++++--- 2 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 packages/codev/src/agent-farm/__tests__/bugfix-774-detect-builder-id.test.ts diff --git a/packages/codev/src/agent-farm/__tests__/bugfix-774-detect-builder-id.test.ts b/packages/codev/src/agent-farm/__tests__/bugfix-774-detect-builder-id.test.ts new file mode 100644 index 00000000..a4c7aeb6 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/bugfix-774-detect-builder-id.test.ts @@ -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//`. 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(); + }); +}); diff --git a/packages/codev/src/agent-farm/commands/send.ts b/packages/codev/src/agent-farm/commands/send.ts index fd9284c3..d2713cec 100644 --- a/packages/codev/src/agent-farm/commands/send.ts +++ b/packages/codev/src/agent-farm/commands/send.ts @@ -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'; @@ -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//`, 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// - 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//. + 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(); + } } /**