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-717
title: afx-attach-can-t-find-builders
protocol: bugfix
phase: verified
plan_phases: []
current_plan_phase: null
gates: {}
iteration: 1
build_complete: false
history: []
started_at: '2026-05-02T01:51:59.248Z'
updated_at: '2026-05-02T02:10:47.664Z'
124 changes: 121 additions & 3 deletions packages/codev/src/agent-farm/__tests__/attach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ vi.mock('../utils/logger.js', () => ({

// Mock DB — configurable per test
const mockDbGet = vi.fn();
const mockDbAll = vi.fn().mockReturnValue([]);
vi.mock('../db/index.js', () => ({
getGlobalDb: () => ({
prepare: () => ({ get: mockDbGet }),
prepare: () => ({ get: mockDbGet, all: mockDbAll }),
}),
}));

Expand Down Expand Up @@ -126,6 +127,7 @@ describe('attach command', () => {
beforeEach(() => {
mockBuilders.length = 0;
mockDbGet.mockReset();
mockDbAll.mockReset().mockReturnValue([]);
mockExistsSync.mockReset().mockReturnValue(false);
mockAccessSync.mockReset();
mockReaddirSync.mockReset().mockReturnValue([]);
Expand Down Expand Up @@ -172,6 +174,26 @@ describe('attach command', () => {
await expect(attach({ issue: 999 })).rejects.toThrow();
expect(fatal).toHaveBeenCalledWith(expect.stringContaining('No builder found for issue #999'));
});

// Regression for bugfix #717: when local state.db is empty but Tower's
// terminal_sessions table knows about the builder, attach must fall back
// to that registry instead of erroring "Builder not found."
it('should fall back to Tower terminal_sessions when local state has no match', async () => {
mockDbAll.mockReturnValue([
{
role_id: 'builder-bugfix-717',
cwd: '/workspace/.builders/bugfix-717',
label: 'Bugfix #717',
},
]);

const { attach } = await import('../commands/attach.js');
const { openBrowser } = await import('../utils/shell.js');

await attach({ issue: 717, browser: true });

expect(openBrowser).toHaveBeenCalledWith(expect.stringContaining('localhost:4100/workspace/'));
});
});

describe('findBuilderById', () => {
Expand Down Expand Up @@ -221,6 +243,59 @@ describe('attach command', () => {
await expect(attach({ project: 'nonexistent' })).rejects.toThrow();
expect(fatal).toHaveBeenCalledWith(expect.stringContaining('Builder "nonexistent" not found'));
});

// Regression for bugfix #717: a builder visible in Tower (via
// terminal_sessions.role_id) but missing from local state.db must still
// be resolvable by `afx attach -p`.
it('should fall back to Tower terminal_sessions for exact ID', async () => {
mockDbAll.mockReturnValue([
{
role_id: 'builder-spir-118',
cwd: '/workspace/.builders/spir-118',
label: '118-feature',
},
]);

const { attach } = await import('../commands/attach.js');
const { openBrowser } = await import('../utils/shell.js');

await attach({ project: 'builder-spir-118', browser: true });

expect(openBrowser).toHaveBeenCalledWith(expect.stringContaining('localhost:4100/workspace/'));
});

it('should fall back to Tower terminal_sessions for substring match', async () => {
mockDbAll.mockReturnValue([
{
role_id: 'builder-bugfix-717',
cwd: '/workspace/.builders/bugfix-717',
label: 'Bugfix #717',
},
]);

const { attach } = await import('../commands/attach.js');
const { openBrowser } = await import('../utils/shell.js');

await attach({ project: '717', browser: true });

expect(openBrowser).toHaveBeenCalledWith(expect.stringContaining('localhost:4100/workspace/'));
});

it('should error when Tower fallback also has no match', async () => {
mockDbAll.mockReturnValue([
{
role_id: 'builder-spir-100',
cwd: '/workspace/.builders/spir-100',
label: '100-other',
},
]);

const { attach } = await import('../commands/attach.js');
const { fatal } = await import('../utils/logger.js');

await expect(attach({ project: 'nonexistent' })).rejects.toThrow();
expect(fatal).toHaveBeenCalledWith(expect.stringContaining('Builder "nonexistent" not found'));
});
});

describe('displayBuilderList', () => {
Expand Down Expand Up @@ -298,7 +373,11 @@ describe('attach command', () => {
expect(result).toBe('/tmp/shellper-test.sock');
});

it('should pass workspace_path and role_id to SQLite query', async () => {
// Regression for bugfix #717: terminal_sessions.workspace_path stores
// config.workspaceRoot (the workspace ROOT), not the builder's worktree
// path. Querying with the worktree would always miss and the fallback
// scan could attach to the wrong builder.
it('should query SQLite with workspace ROOT, not builder.worktree', async () => {
const { findShellperSocket } = await import('../commands/attach.js');

mockDbGet.mockReturnValue(undefined);
Expand All @@ -315,7 +394,8 @@ describe('attach command', () => {

findShellperSocket(builder);

expect(mockDbGet).toHaveBeenCalledWith('/workspace/.builders/spir-118', 'spir-118');
// Mock config.workspaceRoot is '/test/workspace' (see top of file).
expect(mockDbGet).toHaveBeenCalledWith('/test/workspace', 'spir-118');
});

it('should return null when no socket found in DB or filesystem', async () => {
Expand Down Expand Up @@ -564,5 +644,43 @@ describe('attach command', () => {
await expect(attach({ project: '0116' })).rejects.toThrow();
expect(fatal).toHaveBeenCalledWith(expect.stringContaining('No shellper socket found'));
});

// Regression for bugfix #717: end-to-end terminal-mode attach when the
// builder is only in Tower's terminal_sessions (not local state.db) —
// must locate the right shellper socket via the SQLite lookup, not the
// first-socket-found scan.
it('should attach to Tower-only builder using its socket from SQLite', async () => {
mockDbAll.mockReturnValue([
{
role_id: 'builder-spir-118',
cwd: '/workspace/.builders/spir-118',
label: '118-feature',
},
]);
mockDbGet.mockReturnValue({ shellper_socket: '/tmp/shellper-118.sock' });
mockExistsSync.mockImplementation((p) => p === '/tmp/shellper-118.sock');

// Make attachTerminal exit immediately so the test doesn't hang.
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
mockShellperConnect.mockImplementation(() => {
throw new Error('attachTerminal aborted for test');
});

const { attach } = await import('../commands/attach.js');
const { fatal } = await import('../utils/logger.js');

await expect(attach({ project: 'builder-spir-118' })).rejects.toThrow();

// Tower fallback must have been queried.
expect(mockDbAll).toHaveBeenCalled();
// SQLite socket lookup must use workspace ROOT + role_id.
expect(mockDbGet).toHaveBeenCalledWith('/test/workspace', 'builder-spir-118');
// We should fail on the connect step (socket was found), not "no socket".
expect(fatal).toHaveBeenCalledWith(expect.stringContaining('Failed to attach'));

exitSpy.mockRestore();
});
});
});
109 changes: 106 additions & 3 deletions packages/codev/src/agent-farm/commands/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export interface AttachOptions {
*/
function findBuilderByIssue(issueNumber: number): Builder | null {
const builders = getBuilders();
return builders.find((b) => b.issueNumber === issueNumber) ?? null;
const local = builders.find((b) => b.issueNumber === issueNumber);
if (local) return local;

// Fallback: Tower's terminal_sessions table may know about builders that
// were never written to local state.db (Bugfix #717).
return findBuilderInTowerByIssue(issueNumber);
}

/**
Expand All @@ -59,9 +64,103 @@ function findBuilderById(id: string): Builder | null {
return null;
}

// Fallback: Tower's terminal_sessions table may know about builders that
// were never written to local state.db (Bugfix #717).
return findBuilderInTowerById(id);
}

/**
* Row shape we read from terminal_sessions for builder reconstruction.
*/
interface TowerBuilderRow {
role_id: string;
cwd: string | null;
label: string | null;
}

/**
* Load all builder-type terminal sessions for the current workspace from
* Tower's global SQLite db. Returns [] if Tower db is unavailable.
*/
function loadTowerBuilderRows(): TowerBuilderRow[] {
try {
const db = getGlobalDb();
const config = getConfig();
const workspacePath = normalizeWorkspacePath(config.workspaceRoot);
return db.prepare(`
SELECT role_id, cwd, label
FROM terminal_sessions
WHERE workspace_path = ?
AND type = 'builder'
AND role_id IS NOT NULL
ORDER BY created_at DESC
`).all(workspacePath) as TowerBuilderRow[];
} catch {
return [];
}
}

/**
* Reconstruct a minimal Builder from a Tower terminal_sessions row. The
* resulting object has the fields needed by attach (id, worktree) and
* placeholder values elsewhere — it's enough to drive findShellperSocket.
*/
function towerRowToBuilder(row: TowerBuilderRow): Builder {
const isBugfix = row.role_id.includes('-bugfix-');
const issueMatch = isBugfix ? row.role_id.match(/-bugfix-(\d+)/) : null;
return {
id: row.role_id,
name: row.label ?? row.role_id,
status: 'implementing',
phase: 'unknown',
worktree: row.cwd ?? '',
branch: '',
type: isBugfix ? 'bugfix' : 'spec',
issueNumber: issueMatch ? Number(issueMatch[1]) : undefined,
};
}

/**
* Tower fallback for findBuilderById. Applies the same exact-then-substring
* matching used against local state.
*/
function findBuilderInTowerById(id: string): Builder | null {
const rows = loadTowerBuilderRows();
if (rows.length === 0) return null;

const exact = rows.find((r) => r.role_id === id);
if (exact) return towerRowToBuilder(exact);

const matches = rows.filter((r) => r.role_id.startsWith(id) || r.role_id.includes(id));
if (matches.length === 1) return towerRowToBuilder(matches[0]);

if (matches.length > 1) {
logger.error(`Ambiguous builder ID "${id}" in Tower terminal registry. Matches:`);
for (const r of matches) {
logger.info(` - ${r.role_id}`);
}
return null;
}

return null;
}

/**
* Tower fallback for findBuilderByIssue. Matches builder-bugfix-<n> rows
* with leading zeros stripped from the issue number.
*/
function findBuilderInTowerByIssue(issueNumber: number): Builder | null {
const rows = loadTowerBuilderRows();
if (rows.length === 0) return null;

const stripped = String(issueNumber);
const match = rows.find((r) => {
const m = r.role_id.match(/-bugfix-(\d+)/);
return m !== null && m[1] === stripped;
});
return match ? towerRowToBuilder(match) : null;
}

/**
* Display a list of running builders for interactive selection
*/
Expand Down Expand Up @@ -115,10 +214,14 @@ async function displayBuilderList(): Promise<void> {
* 2. Fallback: scan ~/.codev/run/shellper-*.sock
*/
export function findShellperSocket(builder: Builder): string | null {
// 1. Try SQLite lookup
// 1. Try SQLite lookup. terminal_sessions.workspace_path is the workspace
// ROOT (set to config.workspaceRoot at spawn time), not the builder's
// worktree path — querying by builder.worktree would never match. Without
// this scoping, the fallback scan below could attach to the wrong builder
// when multiple shellper sockets exist.
try {
const db = getGlobalDb();
const workspacePath = normalizeWorkspacePath(builder.worktree);
const workspacePath = normalizeWorkspacePath(getConfig().workspaceRoot);

const session = db.prepare(`
SELECT shellper_socket FROM terminal_sessions
Expand Down
Loading