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:
gavrielc
2026-04-15 18:42:10 +03:00
parent c54c779834
commit 75c2fde2b5
48 changed files with 4385 additions and 134 deletions

View File

@@ -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 (

View File

@@ -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 };

View File

@@ -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({

View File

@@ -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';

View File

@@ -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;

View File

@@ -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
);

View 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);
`);
},
};

View 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 '[]'`,
);
},
};

View File

@@ -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(`

View 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
View 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);
}

View File

@@ -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
);