refactor(v2): remove builder-agent dev-agent/worktree/swap flow

The dev-agent-in-worktree approach for source self-modification is abandoned
in favor of a direct draft/activate flow with OS-level RO enforcement
(planned, not yet implemented). Strip the whole subgraph:
src/builder-agent/, pending-swaps DB module + migration 006, builder-agent
MCP tools, and all host wiring (startup sweep, approval routing, deadman,
worktree mount, freeze gate). Tool descriptions in self-mod.ts / agents.ts
no longer cross-reference create_dev_agent. CLAUDE.md + v2-checklist updated
to describe the new direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-15 21:14:39 +03:00
parent 20a24dfd13
commit 81d45b5be9
29 changed files with 9 additions and 3644 deletions

View File

@@ -30,7 +30,6 @@
*
* Affected call sites today (keep this list honest if you add more):
* - src/delivery.ts::handleSystemAction case 'create_agent'
* - src/builder-agent/handlers.ts::handleCreateDevAgent
* - src/db/messaging-groups.ts::createMessagingGroupAgent
*/
import type { AgentDestination } from '../types.js';

View File

@@ -65,18 +65,3 @@ export {
updatePendingCredentialMessageId,
deletePendingCredential,
} from './credentials.js';
export {
createPendingSwap,
getPendingSwap,
getInFlightSwapForGroup,
getSwapForDevAgent,
getAwaitingConfirmationSwaps,
getTerminalSwaps,
updatePendingSwapStatus,
setSwapPreSwapState,
startSwapDeadman,
extendSwapDeadman,
setSwapHandshakeState,
resetSwapForRetry,
deletePendingSwap,
} from './pending-swaps.js';

View File

@@ -1,44 +0,0 @@
import type { Migration } from './index.js';
/**
* `pending_swaps` — backs the builder-agent self-modification flow. One row
* per in-flight swap request from a dev agent. Everything swap-lifecycle fits
* on one row: approval state, classification, pre-swap git SHA for rollback,
* DB snapshot path, deadman timer, handshake state.
*
* Status transitions: pending_approval → awaiting_confirmation →
* (finalized | rolled_back | rejected).
*
* Handshake state (only meaningful while status = awaiting_confirmation):
* pending_restart → message1_sent → confirmed | rolled_back.
*/
export const migration006: Migration = {
version: 6,
name: 'pending-swaps',
up(db) {
db.exec(`
CREATE TABLE pending_swaps (
request_id TEXT PRIMARY KEY,
dev_agent_id TEXT NOT NULL REFERENCES agent_groups(id),
originating_group_id TEXT NOT NULL REFERENCES agent_groups(id),
dev_branch TEXT NOT NULL,
commit_sha TEXT NOT NULL,
classification TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_approval',
summary_json TEXT NOT NULL,
pre_swap_sha TEXT,
db_snapshot_path TEXT,
deadman_started_at TEXT,
deadman_expires_at TEXT,
handshake_state TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_pending_swaps_originating_status
ON pending_swaps(originating_group_id, status);
CREATE INDEX idx_pending_swaps_status
ON pending_swaps(status);
`);
},
};

View File

@@ -6,7 +6,6 @@ import { migration002 } from './002-chat-sdk-state.js';
import { migration003 } from './003-pending-approvals.js';
import { migration004 } from './004-agent-destinations.js';
import { migration005 } from './005-pending-credentials.js';
import { migration006 } from './006-pending-swaps.js';
import { migration007 } from './007-pending-approvals-title-options.js';
export interface Migration {
@@ -21,7 +20,6 @@ const migrations: Migration[] = [
migration003,
migration004,
migration005,
migration006,
migration007,
];

View File

@@ -1,197 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDb, initTestDb } from './connection.js';
import { createAgentGroup } from './agent-groups.js';
import { runMigrations } from './migrations/index.js';
import {
createPendingSwap,
deletePendingSwap,
extendSwapDeadman,
getAwaitingConfirmationSwaps,
getInFlightSwapForGroup,
getPendingSwap,
getSwapForDevAgent,
getTerminalSwaps,
setSwapHandshakeState,
setSwapPreSwapState,
startSwapDeadman,
updatePendingSwapStatus,
} from './pending-swaps.js';
import type { AgentGroup, PendingSwap } from '../types.js';
function makeAgentGroup(id: string, folder: string): AgentGroup {
return {
id,
name: folder,
folder,
agent_provider: null,
created_at: '2026-04-15T00:00:00Z',
};
}
function makeSwap(overrides: Partial<PendingSwap> = {}): PendingSwap {
return {
request_id: 'req-1',
dev_agent_id: 'ag-dev',
originating_group_id: 'ag-origin',
dev_branch: 'dev/req-1',
commit_sha: '',
classification: 'group',
status: 'pending_approval',
summary_json: JSON.stringify({ overallSummary: 'test', classifiedFiles: [] }),
pre_swap_sha: null,
db_snapshot_path: null,
deadman_started_at: null,
deadman_expires_at: null,
handshake_state: null,
created_at: '2026-04-15T00:00:00Z',
...overrides,
};
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
// Both dev_agent_id and originating_group_id are FK to agent_groups.
createAgentGroup(makeAgentGroup('ag-origin', 'origin-folder'));
createAgentGroup(makeAgentGroup('ag-dev', 'dev-folder'));
});
afterEach(() => {
closeDb();
});
describe('pending-swaps CRUD', () => {
it('createPendingSwap then getPendingSwap round-trips all fields', () => {
const swap = makeSwap({
request_id: 'req-roundtrip',
commit_sha: 'sha-xyz',
summary_json: JSON.stringify({ overallSummary: 'round trip' }),
});
createPendingSwap(swap);
const got = getPendingSwap('req-roundtrip');
expect(got).toBeDefined();
expect(got!.request_id).toBe('req-roundtrip');
expect(got!.commit_sha).toBe('sha-xyz');
expect(got!.classification).toBe('group');
expect(got!.status).toBe('pending_approval');
// Default status comes from schema; parsed summary survives.
expect(JSON.parse(got!.summary_json).overallSummary).toBe('round trip');
});
it('getPendingSwap returns undefined for missing id', () => {
expect(getPendingSwap('does-not-exist')).toBeUndefined();
});
it('deletePendingSwap removes the row', () => {
createPendingSwap(makeSwap({ request_id: 'req-del' }));
deletePendingSwap('req-del');
expect(getPendingSwap('req-del')).toBeUndefined();
});
});
describe('pending-swaps lookup by group / dev agent', () => {
it('getInFlightSwapForGroup returns pending_approval rows', () => {
createPendingSwap(makeSwap({ request_id: 'req-a', status: 'pending_approval' }));
const got = getInFlightSwapForGroup('ag-origin');
expect(got?.request_id).toBe('req-a');
});
it('getInFlightSwapForGroup returns awaiting_confirmation rows', () => {
createPendingSwap(makeSwap({ request_id: 'req-b', status: 'awaiting_confirmation' }));
const got = getInFlightSwapForGroup('ag-origin');
expect(got?.request_id).toBe('req-b');
});
it('getInFlightSwapForGroup does NOT return terminal rows', () => {
createPendingSwap(makeSwap({ request_id: 'req-c', status: 'finalized' }));
expect(getInFlightSwapForGroup('ag-origin')).toBeUndefined();
createPendingSwap(makeSwap({ request_id: 'req-d', status: 'rolled_back' }));
expect(getInFlightSwapForGroup('ag-origin')).toBeUndefined();
createPendingSwap(makeSwap({ request_id: 'req-e', status: 'rejected' }));
expect(getInFlightSwapForGroup('ag-origin')).toBeUndefined();
});
it('getSwapForDevAgent returns the row where dev_agent_id matches', () => {
createPendingSwap(makeSwap({ request_id: 'req-f' }));
const got = getSwapForDevAgent('ag-dev');
expect(got?.request_id).toBe('req-f');
});
it('getSwapForDevAgent returns undefined for unrelated dev agent', () => {
createPendingSwap(makeSwap({ request_id: 'req-g' }));
expect(getSwapForDevAgent('ag-unrelated')).toBeUndefined();
});
});
describe('pending-swaps status transitions', () => {
it('updatePendingSwapStatus transitions through the lifecycle', () => {
createPendingSwap(makeSwap({ request_id: 'req-life' }));
updatePendingSwapStatus('req-life', 'awaiting_confirmation');
expect(getPendingSwap('req-life')!.status).toBe('awaiting_confirmation');
updatePendingSwapStatus('req-life', 'finalized');
expect(getPendingSwap('req-life')!.status).toBe('finalized');
});
it('setSwapPreSwapState populates pre_swap_sha + db_snapshot_path', () => {
createPendingSwap(makeSwap({ request_id: 'req-pre' }));
setSwapPreSwapState('req-pre', 'sha-pre', '/tmp/snap.sqlite');
const got = getPendingSwap('req-pre')!;
expect(got.pre_swap_sha).toBe('sha-pre');
expect(got.db_snapshot_path).toBe('/tmp/snap.sqlite');
});
it('startSwapDeadman transitions to awaiting_confirmation and sets deadman fields', () => {
createPendingSwap(makeSwap({ request_id: 'req-dead' }));
startSwapDeadman('req-dead', '2026-04-15T01:00:00Z', '2026-04-15T01:02:00Z', 'pending_restart');
const got = getPendingSwap('req-dead')!;
expect(got.status).toBe('awaiting_confirmation');
expect(got.deadman_started_at).toBe('2026-04-15T01:00:00Z');
expect(got.deadman_expires_at).toBe('2026-04-15T01:02:00Z');
expect(got.handshake_state).toBe('pending_restart');
});
it('extendSwapDeadman updates only deadman_expires_at', () => {
createPendingSwap(makeSwap({ request_id: 'req-ext' }));
startSwapDeadman('req-ext', '2026-04-15T01:00:00Z', '2026-04-15T01:02:00Z', 'pending_restart');
extendSwapDeadman('req-ext', '2026-04-15T01:05:00Z');
const got = getPendingSwap('req-ext')!;
expect(got.deadman_expires_at).toBe('2026-04-15T01:05:00Z');
expect(got.deadman_started_at).toBe('2026-04-15T01:00:00Z');
expect(got.handshake_state).toBe('pending_restart');
});
it('setSwapHandshakeState updates only the handshake state', () => {
createPendingSwap(makeSwap({ request_id: 'req-hs' }));
startSwapDeadman('req-hs', '2026-04-15T01:00:00Z', '2026-04-15T01:02:00Z', 'pending_restart');
setSwapHandshakeState('req-hs', 'message1_sent');
expect(getPendingSwap('req-hs')!.handshake_state).toBe('message1_sent');
});
});
describe('pending-swaps bulk lookups', () => {
it('getAwaitingConfirmationSwaps returns only that status', () => {
createPendingSwap(makeSwap({ request_id: 'req-pending', status: 'pending_approval' }));
createPendingSwap(makeSwap({ request_id: 'req-await', status: 'awaiting_confirmation' }));
createPendingSwap(makeSwap({ request_id: 'req-final', status: 'finalized' }));
const got = getAwaitingConfirmationSwaps();
expect(got).toHaveLength(1);
expect(got[0].request_id).toBe('req-await');
});
it('getTerminalSwaps returns rows in terminal statuses', () => {
createPendingSwap(makeSwap({ request_id: 'req-t1', status: 'finalized' }));
createPendingSwap(makeSwap({ request_id: 'req-t2', status: 'rolled_back' }));
createPendingSwap(makeSwap({ request_id: 'req-t3', status: 'rejected' }));
createPendingSwap(makeSwap({ request_id: 'req-active', status: 'awaiting_confirmation' }));
const terminal = getTerminalSwaps()
.map((s) => s.request_id)
.sort();
expect(terminal).toEqual(['req-t1', 'req-t2', 'req-t3']);
});
});

View File

@@ -1,137 +0,0 @@
import type { PendingSwap, SwapHandshakeState, SwapStatus } from '../types.js';
import { getDb } from './connection.js';
export function createPendingSwap(swap: PendingSwap): void {
getDb()
.prepare(
`INSERT INTO pending_swaps (
request_id, dev_agent_id, originating_group_id, dev_branch, commit_sha,
classification, status, summary_json, pre_swap_sha, db_snapshot_path,
deadman_started_at, deadman_expires_at, handshake_state, created_at
) VALUES (
@request_id, @dev_agent_id, @originating_group_id, @dev_branch, @commit_sha,
@classification, @status, @summary_json, @pre_swap_sha, @db_snapshot_path,
@deadman_started_at, @deadman_expires_at, @handshake_state, @created_at
)`,
)
.run(swap);
}
export function getPendingSwap(requestId: string): PendingSwap | undefined {
return getDb().prepare('SELECT * FROM pending_swaps WHERE request_id = ?').get(requestId) as PendingSwap | undefined;
}
/**
* Returns the in-flight swap for an originating group, if any. "In-flight"
* means not in a terminal status (finalized / rolled_back / rejected).
* Used to enforce one-swap-per-originating-group serialization.
*/
export function getInFlightSwapForGroup(originatingGroupId: string): PendingSwap | undefined {
return getDb()
.prepare(
`SELECT * FROM pending_swaps
WHERE originating_group_id = ?
AND status IN ('pending_approval', 'awaiting_confirmation')
LIMIT 1`,
)
.get(originatingGroupId) as PendingSwap | undefined;
}
/**
* Returns the in-flight swap for a dev-agent group. Used by the container
* runner to decide whether to mount the worktree on the dev agent's container.
*/
export function getSwapForDevAgent(devAgentId: string): PendingSwap | undefined {
return getDb()
.prepare(
`SELECT * FROM pending_swaps
WHERE dev_agent_id = ?
AND status IN ('pending_approval', 'awaiting_confirmation')
LIMIT 1`,
)
.get(devAgentId) as PendingSwap | undefined;
}
/**
* All swaps currently in `awaiting_confirmation` — used by the startup sweep
* to resume deadmans after a host restart (expected for host-level swaps,
* unexpected for group-level crashes).
*/
export function getAwaitingConfirmationSwaps(): PendingSwap[] {
return getDb().prepare(`SELECT * FROM pending_swaps WHERE status = 'awaiting_confirmation'`).all() as PendingSwap[];
}
/** All terminal-status swaps — used by the startup worktree-orphan sweep. */
export function getTerminalSwaps(): PendingSwap[] {
return getDb()
.prepare(`SELECT * FROM pending_swaps WHERE status IN ('finalized', 'rolled_back', 'rejected')`)
.all() as PendingSwap[];
}
export function updatePendingSwapStatus(requestId: string, status: SwapStatus): void {
getDb().prepare('UPDATE pending_swaps SET status = ? WHERE request_id = ?').run(status, requestId);
}
export function setSwapPreSwapState(requestId: string, preSwapSha: string, dbSnapshotPath: string): void {
getDb()
.prepare(
`UPDATE pending_swaps
SET pre_swap_sha = ?, db_snapshot_path = ?
WHERE request_id = ?`,
)
.run(preSwapSha, dbSnapshotPath, requestId);
}
export function startSwapDeadman(
requestId: string,
startedAt: string,
expiresAt: string,
handshakeState: SwapHandshakeState,
): void {
getDb()
.prepare(
`UPDATE pending_swaps
SET status = 'awaiting_confirmation',
deadman_started_at = ?,
deadman_expires_at = ?,
handshake_state = ?
WHERE request_id = ?`,
)
.run(startedAt, expiresAt, handshakeState, requestId);
}
export function extendSwapDeadman(requestId: string, expiresAt: string): void {
getDb().prepare('UPDATE pending_swaps SET deadman_expires_at = ? WHERE request_id = ?').run(expiresAt, requestId);
}
export function setSwapHandshakeState(requestId: string, state: SwapHandshakeState): void {
getDb().prepare('UPDATE pending_swaps SET handshake_state = ? WHERE request_id = ?').run(state, requestId);
}
export function deletePendingSwap(requestId: string): void {
getDb().prepare('DELETE FROM pending_swaps WHERE request_id = ?').run(requestId);
}
/**
* Reset a swap back to `pending_approval` after a post-approval failure
* (apply / commit / build error). Clears the in-progress fields so a
* subsequent `request_swap` call from the dev agent starts clean. Leaves
* the dev_agent_id + originating_group_id + dev_branch intact so the dev
* agent can fix the issue in its worktree and retry without having to
* spin up a fresh dev agent.
*/
export function resetSwapForRetry(requestId: string): void {
getDb()
.prepare(
`UPDATE pending_swaps
SET status = 'pending_approval',
commit_sha = '',
pre_swap_sha = NULL,
db_snapshot_path = NULL,
deadman_started_at = NULL,
deadman_expires_at = NULL,
handshake_state = NULL
WHERE request_id = ?`,
)
.run(requestId);
}