feat(v2): builder-agent self-modification WIP + container-config as per-group file
Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent, request_swap, classifier, deadman, promote) before pivoting to a unified draft-activate approach with OS-level RO enforcement. Lifts container_config out of the agent_groups row into groups/<folder>/container.json so install_packages, add_mcp_server, and rebuild flows can eventually route through the same draft path as source edits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,40 @@
|
||||
* namespace. The host uses this table both for routing (resolve name → ID)
|
||||
* and for permission checks (row exists ⇒ authorized).
|
||||
*/
|
||||
/**
|
||||
* ⚠️ DESTINATION PROJECTION INVARIANT — READ BEFORE ADDING NEW CALL SITES.
|
||||
*
|
||||
* `agent_destinations` in the central DB is the source of truth, but the
|
||||
* agent-runner container reads its destinations from a per-session
|
||||
* projection in `inbound.db`. That projection is written by
|
||||
* `writeDestinations(agentGroupId, sessionId)` in session-manager.ts.
|
||||
*
|
||||
* `spawnContainer` calls `writeDestinations` on every container wake, so a
|
||||
* fresh container always sees the latest destinations. BUT: a container
|
||||
* that is ALREADY running when you mutate the central table will keep
|
||||
* serving the stale projection until its next wake — the central write
|
||||
* does not propagate automatically.
|
||||
*
|
||||
* **Therefore: every time you call `createDestination` / `deleteDestination` /
|
||||
* `deleteAllDestinationsTouching` from code that runs while an agent's
|
||||
* container may be alive, you MUST also call `writeDestinations(agentGroupId,
|
||||
* sessionId)` for each affected session.** Forgetting this manifests as
|
||||
* "dropped: unknown destination" errors at send_message time.
|
||||
*
|
||||
* 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';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
/**
|
||||
* ⚠️ Caller responsibility: after this returns, call
|
||||
* `writeDestinations(row.agent_group_id, <sessionId>)` for each active
|
||||
* session of that agent group so the change propagates to the running
|
||||
* container's inbound.db. See the top-of-file invariant.
|
||||
*/
|
||||
export function createDestination(row: AgentDestination): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
@@ -51,12 +82,51 @@ export function hasDestination(agentGroupId: string, targetType: 'channel' | 'ag
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Caller responsibility: after this returns, call
|
||||
* `writeDestinations(agentGroupId, <sessionId>)` for each active session
|
||||
* so the deletion propagates to the running container's inbound.db.
|
||||
*/
|
||||
export function deleteDestination(agentGroupId: string, localName: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.run(agentGroupId, localName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every destination row where this agent group is either the owner
|
||||
* or the target. Used when tearing down a dev agent after a swap request
|
||||
* completes/rolls-back — drops the bidirectional destinations in one call.
|
||||
*
|
||||
* ⚠️ Caller responsibility: not only does `agentGroupId`'s own session
|
||||
* projection need a refresh, but ALSO every OTHER agent group that had
|
||||
* `agentGroupId` as a destination target. Use `getDestinationReferencers`
|
||||
* below to find them BEFORE calling this (the rows are gone afterwards).
|
||||
*/
|
||||
export function deleteAllDestinationsTouching(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
'DELETE FROM agent_destinations WHERE agent_group_id = ? OR (target_type = ? AND target_id = ?)',
|
||||
)
|
||||
.run(agentGroupId, 'agent', agentGroupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of agent_group_ids that currently have a destination
|
||||
* row pointing at `targetAgentGroupId`. Call this BEFORE
|
||||
* `deleteAllDestinationsTouching` if you need to know whose session
|
||||
* projections to refresh after the delete — the rows are gone once the
|
||||
* delete runs.
|
||||
*/
|
||||
export function getDestinationReferencers(targetAgentGroupId: string): string[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
"SELECT DISTINCT agent_group_id FROM agent_destinations WHERE target_type = 'agent' AND target_id = ? AND agent_group_id != ?",
|
||||
)
|
||||
.all(targetAgentGroupId, targetAgentGroupId) as Array<{ agent_group_id: string }>;
|
||||
return rows.map((r) => r.agent_group_id);
|
||||
}
|
||||
|
||||
/** Normalize a human-readable name into a lowercase, dash-separated identifier. */
|
||||
export function normalizeName(name: string): string {
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getDb } from './connection.js';
|
||||
export function createAgentGroup(group: AgentGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_groups (id, name, folder, agent_provider, container_config, created_at)
|
||||
VALUES (@id, @name, @folder, @agent_provider, @container_config, @created_at)`,
|
||||
`INSERT INTO agent_groups (id, name, folder, agent_provider, created_at)
|
||||
VALUES (@id, @name, @folder, @agent_provider, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function getAllAgentGroups(): AgentGroup[] {
|
||||
|
||||
export function updateAgentGroup(
|
||||
id: string,
|
||||
updates: Partial<Pick<AgentGroup, 'name' | 'agent_provider' | 'container_config'>>,
|
||||
updates: Partial<Pick<AgentGroup, 'name' | 'agent_provider'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
@@ -66,7 +66,6 @@ describe('agent groups', () => {
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
@@ -163,7 +162,6 @@ describe('messaging group agents', () => {
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
@@ -202,7 +200,6 @@ describe('messaging group agents', () => {
|
||||
name: 'Agent2',
|
||||
folder: 'agent2',
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({ ...mga(), id: 'mga-2', agent_group_id: 'ag-2', priority: 10 });
|
||||
@@ -285,7 +282,6 @@ describe('sessions', () => {
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
@@ -380,7 +376,6 @@ describe('pending questions', () => {
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createSession({
|
||||
|
||||
@@ -43,6 +43,7 @@ export {
|
||||
createSession,
|
||||
getSession,
|
||||
findSession,
|
||||
findSessionByAgentGroup,
|
||||
getSessionsByAgentGroup,
|
||||
getActiveSessions,
|
||||
getRunningSessions,
|
||||
@@ -64,3 +65,18 @@ export {
|
||||
updatePendingCredentialMessageId,
|
||||
deletePendingCredential,
|
||||
} from './credentials.js';
|
||||
export {
|
||||
createPendingSwap,
|
||||
getPendingSwap,
|
||||
getInFlightSwapForGroup,
|
||||
getSwapForDevAgent,
|
||||
getAwaitingConfirmationSwaps,
|
||||
getTerminalSwaps,
|
||||
updatePendingSwapStatus,
|
||||
setSwapPreSwapState,
|
||||
startSwapDeadman,
|
||||
extendSwapDeadman,
|
||||
setSwapHandshakeState,
|
||||
resetSwapForRetry,
|
||||
deletePendingSwap,
|
||||
} from './pending-swaps.js';
|
||||
|
||||
@@ -85,6 +85,20 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
|
||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||
// outbound messages that target this chat.
|
||||
//
|
||||
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
// `agent_destinations` row. It does NOT project into any running
|
||||
// agent's session inbound.db (see top-of-file invariant in
|
||||
// src/db/agent-destinations.ts). In practice this is fine because the
|
||||
// only real callers are one-shot setup scripts (setup/register.ts,
|
||||
// scripts/init-first-agent.ts, /manage-channels skill) that run in a
|
||||
// separate process from the host. Any already-running container for
|
||||
// `mga.agent_group_id` will keep serving the stale projection until
|
||||
// its next wake (idle timeout or next inbound message) at which
|
||||
// point spawnContainer's writeDestinations call refreshes from central.
|
||||
// If you call this from code that runs INSIDE the host process and
|
||||
// need the refresh to happen immediately, explicitly call
|
||||
// `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
|
||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||
if (existing) return;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export const migration001: Migration = {
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
||||
44
src/db/migrations/006-pending-swaps.ts
Normal file
44
src/db/migrations/006-pending-swaps.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
`);
|
||||
},
|
||||
};
|
||||
43
src/db/migrations/007-pending-approvals-title-options.ts
Normal file
43
src/db/migrations/007-pending-approvals-title-options.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/**
|
||||
* Retroactive schema fix: earlier migration 003 was edited after it had
|
||||
* already been applied in the wild, adding `title` and `options_json`
|
||||
* columns to its CREATE TABLE statement. Installs that ran 003 before the
|
||||
* edit don't have those columns, and `createPendingApproval` (which
|
||||
* inserts into both) fails with "no such column" at runtime.
|
||||
*
|
||||
* This migration adds the missing columns via ALTER TABLE so old installs
|
||||
* catch up. On a fresh install that runs 003 at its current definition,
|
||||
* the ALTER statements will fail harmlessly (column already exists) and
|
||||
* we swallow the error per-column.
|
||||
*/
|
||||
export const migration007: Migration = {
|
||||
version: 7,
|
||||
name: 'pending-approvals-title-options',
|
||||
up(db) {
|
||||
const addIfMissing = (col: string, sql: string): void => {
|
||||
try {
|
||||
db.exec(sql);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('duplicate column') || msg.includes('already exists')) {
|
||||
// Fresh install — column already added by the current 003
|
||||
// definition. Nothing to do.
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
void col;
|
||||
};
|
||||
|
||||
addIfMissing(
|
||||
'title',
|
||||
`ALTER TABLE pending_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
||||
);
|
||||
addIfMissing(
|
||||
'options_json',
|
||||
`ALTER TABLE pending_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,8 @@ 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 {
|
||||
version: number;
|
||||
@@ -13,7 +15,15 @@ export interface Migration {
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [migration001, migration002, migration003, migration004, migration005];
|
||||
const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002,
|
||||
migration003,
|
||||
migration004,
|
||||
migration005,
|
||||
migration006,
|
||||
migration007,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
|
||||
195
src/db/pending-swaps.test.ts
Normal file
195
src/db/pending-swaps.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
151
src/db/pending-swaps.ts
Normal file
151
src/db/pending-swaps.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
}
|
||||
@@ -5,14 +5,15 @@
|
||||
*/
|
||||
|
||||
export const SCHEMA = `
|
||||
-- Agent workspaces: folder, skills, CLAUDE.md, container config.
|
||||
-- Agent workspaces: folder, skills, CLAUDE.md.
|
||||
-- All workspaces are equal; privilege lives on users, not groups.
|
||||
-- Container config (mcpServers, packages, imageTag, additionalMounts) lives
|
||||
-- in groups/<folder>/container.json on disk, not in the DB.
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user