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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user