Files
EJClaw/src/db/schema.ts

3288 lines
108 KiB
TypeScript

import { Database } from 'bun:sqlite';
import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import {
canonicalizeChannelOwnerLeaseMetadata,
canonicalizePairedTaskMetadata,
canonicalizeServiceHandoffMetadata,
inferExecutionLeaseServiceIdFromCanonicalMetadata,
} from './canonical-role-metadata.js';
import {
backfillPairedTurnAttemptsFromTurns,
buildPairedTurnAttemptId,
} from './paired-turn-attempts.js';
import { normalizeStoredAgentType } from './room-registration.js';
import {
backfillLegacyServiceSessions,
dropLegacyServiceSessionsTable,
migrateSessionsTableToCompositePk,
} from './sessions.js';
function getTableColumns(database: Database, tableName: string): string[] {
return (
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: string;
}>
).map((column) => column.name);
}
export function tableHasColumn(
database: Database,
tableName: string,
columnName: string,
): boolean {
return getTableColumns(database, tableName).includes(columnName);
}
function getTableSql(database: Database, tableName: string): string | null {
const row = database
.prepare(
`
SELECT sql
FROM sqlite_master
WHERE type = 'table'
AND name = ?
`,
)
.get(tableName) as { sql?: string | null } | undefined;
return row?.sql ?? null;
}
function tryExecMigration(database: Database, sql: string): void {
try {
database.exec(sql);
} catch {
/* column already exists */
}
}
function buildPairedTurnAttemptIdSql(
turnIdExpr: string,
attemptNoExpr: string,
): string {
return `${turnIdExpr} || ':attempt:' || CAST(${attemptNoExpr} AS TEXT)`;
}
function buildPairedTurnAttemptParentIdSql(
turnIdExpr: string,
attemptNoExpr: string,
): string {
return `CASE
WHEN ${attemptNoExpr} <= 1 THEN NULL
ELSE ${buildPairedTurnAttemptIdSql(turnIdExpr, `(${attemptNoExpr}) - 1`)}
END`;
}
function buildPairedTurnAttemptParentHandoffMatchSql(args: {
parentHandoffIdExpr: string;
turnIdExpr: string;
parentAttemptIdExpr: string;
attemptNoExpr: string;
}): string {
return `EXISTS (
SELECT 1
FROM service_handoffs
WHERE service_handoffs.id = ${args.parentHandoffIdExpr}
AND service_handoffs.turn_id = ${args.turnIdExpr}
AND service_handoffs.status = 'failed'
AND (
service_handoffs.turn_attempt_id = ${args.parentAttemptIdExpr}
OR (
service_handoffs.turn_attempt_id IS NULL
AND service_handoffs.turn_attempt_no = (${args.attemptNoExpr}) - 1
)
)
)`;
}
function buildPairedTurnAttemptContinuationHandoffMatchSql(args: {
continuationHandoffIdExpr: string;
turnIdExpr: string;
attemptIdExpr: string;
attemptNoExpr: string;
}): string {
return `EXISTS (
SELECT 1
FROM service_handoffs
WHERE service_handoffs.id = ${args.continuationHandoffIdExpr}
AND service_handoffs.turn_id = ${args.turnIdExpr}
AND (
service_handoffs.turn_attempt_id = ${args.attemptIdExpr}
OR (
service_handoffs.turn_attempt_id IS NULL
AND service_handoffs.turn_attempt_no = ${args.attemptNoExpr}
)
)
)`;
}
function tableHasForeignKey(
database: Database,
args: {
tableName: string;
referencedTable: string;
fromColumns: string[];
toColumns: string[];
onDelete?: string;
},
): boolean {
const rows = database
.prepare(`PRAGMA foreign_key_list(${args.tableName})`)
.all() as Array<{
id: number;
seq: number;
table: string;
from: string;
to: string;
on_delete: string;
}>;
if (rows.length === 0) {
return false;
}
const byId = new Map<number, typeof rows>();
for (const row of rows) {
const group = byId.get(row.id) ?? [];
group.push(row);
byId.set(row.id, group);
}
for (const group of byId.values()) {
const sorted = [...group].sort((left, right) => left.seq - right.seq);
if (sorted.length !== args.fromColumns.length) {
continue;
}
if (sorted[0]!.table !== args.referencedTable) {
continue;
}
if (
args.onDelete &&
sorted[0]!.on_delete.toUpperCase() !== args.onDelete.toUpperCase()
) {
continue;
}
const matches = sorted.every((row, index) => {
return (
row.from === args.fromColumns[index] && row.to === args.toColumns[index]
);
});
if (matches) {
return true;
}
}
return false;
}
function backfillMessageSeq(database: Database): void {
const rows = database
.prepare(
`SELECT rowid, seq
FROM messages
ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`,
)
.all() as Array<{ rowid: number; seq: number | null }>;
if (rows.length === 0) {
return;
}
let nextSeq = 1;
const assignSeq = database.prepare(
'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL',
);
const tx = database.transaction(() => {
for (const row of rows) {
if (row.seq === null) {
assignSeq.run(nextSeq, row.rowid);
}
nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1);
}
});
tx();
const maxSeqRow = database
.prepare('SELECT MAX(seq) AS maxSeq FROM messages')
.get() as { maxSeq: number | null };
const maxSeq = maxSeqRow.maxSeq ?? 0;
if (maxSeq > 0) {
database
.prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)')
.run(maxSeq);
}
}
interface LegacyExecutionLeaseServiceRow {
rowid: number;
role: string;
owner_service_id?: string | null;
reviewer_service_id?: string | null;
arbiter_service_id?: string | null;
owner_agent_type?: string | null;
reviewer_agent_type?: string | null;
arbiter_agent_type?: string | null;
}
interface StoredPairedTaskServiceRow {
rowid: number;
id: string;
chat_jid: string;
group_folder: string;
owner_service_id?: string | null;
reviewer_service_id?: string | null;
owner_agent_type?: string | null;
reviewer_agent_type?: string | null;
arbiter_agent_type?: string | null;
}
interface StoredChannelOwnerLeaseServiceRow {
rowid: number;
chat_jid: string;
owner_service_id?: string | null;
reviewer_service_id?: string | null;
arbiter_service_id?: string | null;
owner_agent_type?: string | null;
reviewer_agent_type?: string | null;
arbiter_agent_type?: string | null;
}
interface StoredServiceHandoffServiceRow {
id: number;
chat_jid: string;
group_folder: string;
source_service_id?: string | null;
target_service_id?: string | null;
source_role?: string | null;
target_role?: string | null;
source_agent_type?: string | null;
target_agent_type?: string | null;
intended_role?: string | null;
}
interface PairedTurnAttemptTimingRow {
turn_id: string;
attempt_no: number;
created_at: string;
}
interface PairedTurnAttemptTiming {
attemptNo: number;
createdAtMs: number;
}
function parseBackfillTimestampMs(
value: string | null | undefined,
): number | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
let normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(normalized)) {
normalized = `${normalized}Z`;
}
const parsed = Date.parse(normalized);
return Number.isNaN(parsed) ? null : parsed;
}
function getPairedTurnAttemptTimingsByTurnId(
database: Database,
): Map<string, PairedTurnAttemptTiming[]> {
const rows = database
.prepare(
`
SELECT turn_id, attempt_no, created_at
FROM paired_turn_attempts
WHERE attempt_no >= 1
ORDER BY turn_id ASC, attempt_no ASC
`,
)
.all() as PairedTurnAttemptTimingRow[];
const timingsByTurnId = new Map<string, PairedTurnAttemptTiming[]>();
for (const row of rows) {
const createdAtMs = parseBackfillTimestampMs(row.created_at);
if (createdAtMs === null) {
throw new Error(
`paired_turn_attempts(${row.turn_id}, attempt=${row.attempt_no}) has an invalid created_at timestamp`,
);
}
const timings = timingsByTurnId.get(row.turn_id) ?? [];
timings.push({
attemptNo: row.attempt_no,
createdAtMs,
});
timingsByTurnId.set(row.turn_id, timings);
}
return timingsByTurnId;
}
function resolveAttemptNoForBackfill(
args: {
turnId: string;
eventTimestamp: string | null | undefined;
rowLabel: string;
},
timingsByTurnId: Map<string, PairedTurnAttemptTiming[]>,
): number | null {
const attemptTimings = timingsByTurnId.get(args.turnId) ?? [];
if (attemptTimings.length === 0) {
return null;
}
if (attemptTimings.length === 1) {
return attemptTimings[0]!.attemptNo;
}
const eventAtMs = parseBackfillTimestampMs(args.eventTimestamp);
if (eventAtMs === null) {
throw new Error(
`${args.rowLabel} has turn_id=${args.turnId} but no timestamp that can be mapped to a specific attempt`,
);
}
const exactMatches = attemptTimings.filter((timing, index) => {
const nextTiming = attemptTimings[index + 1];
return (
eventAtMs >= timing.createdAtMs &&
(nextTiming === undefined || eventAtMs < nextTiming.createdAtMs)
);
});
if (exactMatches.length === 1) {
return exactMatches[0]!.attemptNo;
}
if (exactMatches.length > 1) {
throw new Error(
`${args.rowLabel} has an ambiguous exact timestamp match for turn_id=${args.turnId}`,
);
}
const eventAtSecond = Math.floor(eventAtMs / 1000);
const secondPrecisionMatches = attemptTimings.filter((timing, index) => {
const nextTiming = attemptTimings[index + 1];
const timingSecond = Math.floor(timing.createdAtMs / 1000);
const nextTimingSecond =
nextTiming === undefined
? null
: Math.floor(nextTiming.createdAtMs / 1000);
return (
eventAtSecond >= timingSecond &&
(nextTimingSecond === null || eventAtSecond < nextTimingSecond)
);
});
if (secondPrecisionMatches.length === 1) {
return secondPrecisionMatches[0]!.attemptNo;
}
if (secondPrecisionMatches.length > 1) {
throw new Error(
`${args.rowLabel} has an ambiguous second-precision timestamp match for turn_id=${args.turnId}`,
);
}
throw new Error(
`${args.rowLabel} could not be mapped to a specific attempt for turn_id=${args.turnId}`,
);
}
function backfillPairedTurnAttemptIds(database: Database): void {
if (!tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) {
return;
}
database.exec(`
UPDATE paired_turn_attempts
SET attempt_id = ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}
WHERE attempt_id IS NULL
OR TRIM(attempt_id) = ''
`);
}
function backfillPairedTurnAttemptParentIds(database: Database): void {
if (!tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) {
return;
}
database.exec(`
UPDATE paired_turn_attempts
SET parent_attempt_id = ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
WHERE attempt_no > 1
AND EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
)
AND (
parent_attempt_id IS NULL
OR TRIM(parent_attempt_id) = ''
OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
)
`);
database.exec(`
UPDATE paired_turn_attempts
SET parent_attempt_id = NULL
WHERE attempt_no <= 1
AND parent_attempt_id IS NOT NULL
`);
}
function backfillPairedTurnAttemptActiveRunIds(database: Database): void {
if (!tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) {
return;
}
const pairedTurnsActiveRunIdExists = tableHasColumn(
database,
'paired_turns',
'active_run_id',
);
const leaseTurnIdExists = tableHasColumn(
database,
'paired_task_execution_leases',
'turn_id',
);
const leaseTurnAttemptNoExists = tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_no',
);
const leaseActiveRunIdSql = leaseTurnIdExists
? leaseTurnAttemptNoExists
? `COALESCE(
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
),
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no IS NULL
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)
)`
: `(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)`
: 'NULL';
const legacyTurnActiveRunIdSql = pairedTurnsActiveRunIdExists
? `(
SELECT paired_turns.active_run_id
FROM paired_turns
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
)`
: 'NULL';
database.exec(`
UPDATE paired_turn_attempts
SET active_run_id = COALESCE(
active_run_id,
CASE
WHEN state = 'running'
THEN COALESCE(
${leaseActiveRunIdSql},
${legacyTurnActiveRunIdSql}
)
ELSE NULL
END
)
WHERE state = 'running'
AND (active_run_id IS NULL OR TRIM(active_run_id) = '')
`);
database.exec(`
UPDATE paired_turn_attempts
SET active_run_id = NULL
WHERE state != 'running'
AND active_run_id IS NOT NULL
`);
}
function backfillPairedTurnAttemptEntityIds(database: Database): void {
if (
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') &&
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')
) {
database.exec(`
UPDATE paired_turn_reservations
SET turn_attempt_id = (
SELECT paired_turn_attempts.attempt_id
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id
AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no
)
WHERE turn_attempt_no IS NOT NULL
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
`);
}
if (
tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_id',
) &&
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
) {
database.exec(`
UPDATE paired_task_execution_leases
SET turn_attempt_id = (
SELECT paired_turn_attempts.attempt_id
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id
AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no
)
WHERE turn_attempt_no IS NOT NULL
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
`);
}
if (
tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') &&
tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')
) {
database.exec(`
UPDATE service_handoffs
SET turn_attempt_id = (
SELECT paired_turn_attempts.attempt_id
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id
AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no
)
WHERE turn_attempt_no IS NOT NULL
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
`);
}
}
function backfillPairedTurnAttemptProvenance(database: Database): void {
const timingsByTurnId = getPairedTurnAttemptTimingsByTurnId(database);
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
const hasTurnAttemptIdColumn = tableHasColumn(
database,
'paired_turn_reservations',
'turn_attempt_id',
);
const rows = database
.prepare(
`
SELECT rowid, turn_id, status, created_at, updated_at, consumed_at
FROM paired_turn_reservations
WHERE turn_id IS NOT NULL
AND turn_attempt_no IS NULL
`,
)
.all() as Array<{
rowid: number;
turn_id: string;
status: string;
created_at: string | null;
updated_at: string | null;
consumed_at: string | null;
}>;
const update = hasTurnAttemptIdColumn
? database.prepare(
'UPDATE paired_turn_reservations SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?',
)
: database.prepare(
'UPDATE paired_turn_reservations SET turn_attempt_no = ? WHERE rowid = ?',
);
for (const row of rows) {
if (row.status !== 'completed') {
continue;
}
const attemptNo = resolveAttemptNoForBackfill(
{
turnId: row.turn_id,
eventTimestamp: row.consumed_at ?? row.updated_at ?? row.created_at,
rowLabel: `paired_turn_reservations(rowid=${row.rowid})`,
},
timingsByTurnId,
);
if (attemptNo !== null) {
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
if (hasTurnAttemptIdColumn) {
update.run(attemptId, attemptNo, row.rowid);
} else {
update.run(attemptNo, row.rowid);
}
}
}
}
if (
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
) {
const hasTurnAttemptIdColumn = tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_id',
);
const rows = database
.prepare(
`
SELECT rowid, turn_id, claimed_at, updated_at
FROM paired_task_execution_leases
WHERE turn_id IS NOT NULL
AND turn_attempt_no IS NULL
`,
)
.all() as Array<{
rowid: number;
turn_id: string;
claimed_at: string | null;
updated_at: string | null;
}>;
const update = hasTurnAttemptIdColumn
? database.prepare(
'UPDATE paired_task_execution_leases SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?',
)
: database.prepare(
'UPDATE paired_task_execution_leases SET turn_attempt_no = ? WHERE rowid = ?',
);
for (const row of rows) {
const attemptNo = resolveAttemptNoForBackfill(
{
turnId: row.turn_id,
eventTimestamp: row.updated_at ?? row.claimed_at,
rowLabel: `paired_task_execution_leases(rowid=${row.rowid})`,
},
timingsByTurnId,
);
if (attemptNo !== null) {
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
if (hasTurnAttemptIdColumn) {
update.run(attemptId, attemptNo, row.rowid);
} else {
update.run(attemptNo, row.rowid);
}
}
}
}
if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) {
const hasTurnAttemptIdColumn = tableHasColumn(
database,
'service_handoffs',
'turn_attempt_id',
);
const rows = database
.prepare(
`
SELECT id, turn_id, created_at, claimed_at, completed_at
FROM service_handoffs
WHERE turn_id IS NOT NULL
AND turn_attempt_no IS NULL
`,
)
.all() as Array<{
id: number;
turn_id: string;
created_at: string | null;
claimed_at: string | null;
completed_at: string | null;
}>;
const update = hasTurnAttemptIdColumn
? database.prepare(
'UPDATE service_handoffs SET turn_attempt_id = ?, turn_attempt_no = ? WHERE id = ?',
)
: database.prepare(
'UPDATE service_handoffs SET turn_attempt_no = ? WHERE id = ?',
);
for (const row of rows) {
const attemptNo = resolveAttemptNoForBackfill(
{
turnId: row.turn_id,
eventTimestamp: row.completed_at ?? row.claimed_at ?? row.created_at,
rowLabel: `service_handoffs(id=${row.id})`,
},
timingsByTurnId,
);
if (attemptNo !== null) {
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
if (hasTurnAttemptIdColumn) {
update.run(attemptId, attemptNo, row.id);
} else {
update.run(attemptNo, row.id);
}
}
}
}
}
function assertPairedTurnAttemptProvenanceIntegrity(database: Database): void {
if (tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) {
const invalidAttemptIdRow = database
.prepare(
`
SELECT turn_id, attempt_no, attempt_id
FROM paired_turn_attempts
WHERE attempt_id IS NULL
OR attempt_id != ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
attempt_id: string | null;
}
| undefined;
if (invalidAttemptIdRow) {
throw new Error(
`paired_turn_attempts(${invalidAttemptIdRow.turn_id}, attempt=${invalidAttemptIdRow.attempt_no}) has invalid attempt_id provenance`,
);
}
}
if (tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) {
const invalidParentAttemptRow = database
.prepare(
`
SELECT turn_id, attempt_no, parent_attempt_id
FROM paired_turn_attempts
WHERE (
attempt_no <= 1
AND parent_attempt_id IS NOT NULL
)
OR (
attempt_no > 1
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
)
)
OR (
attempt_no > 1
AND (
parent_attempt_id IS NULL
OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
OR NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE COALESCE(
previous_attempt.attempt_id,
${buildPairedTurnAttemptIdSql(
'previous_attempt.turn_id',
'previous_attempt.attempt_no',
)}
) = paired_turn_attempts.parent_attempt_id
AND previous_attempt.turn_id = paired_turn_attempts.turn_id
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
)
)
)
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
parent_attempt_id: string | null;
}
| undefined;
if (invalidParentAttemptRow) {
throw new Error(
`paired_turn_attempts(${invalidParentAttemptRow.turn_id}, attempt=${invalidParentAttemptRow.attempt_no}) has invalid parent_attempt_id provenance`,
);
}
}
if (tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id')) {
const invalidParentHandoffRow = database
.prepare(
`
SELECT turn_id, attempt_no, parent_handoff_id
FROM paired_turn_attempts
WHERE parent_handoff_id IS NOT NULL
AND (
attempt_no <= 1
OR NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
parentHandoffIdExpr: 'paired_turn_attempts.parent_handoff_id',
turnIdExpr: 'paired_turn_attempts.turn_id',
parentAttemptIdExpr: 'paired_turn_attempts.parent_attempt_id',
attemptNoExpr: 'paired_turn_attempts.attempt_no',
})}
)
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
parent_handoff_id: number | null;
}
| undefined;
if (invalidParentHandoffRow) {
throw new Error(
`paired_turn_attempts(${invalidParentHandoffRow.turn_id}, attempt=${invalidParentHandoffRow.attempt_no}) has invalid parent_handoff_id provenance`,
);
}
}
if (
tableHasColumn(database, 'paired_turn_attempts', 'continuation_handoff_id')
) {
const invalidContinuationHandoffRow = database
.prepare(
`
SELECT turn_id, attempt_no, continuation_handoff_id
FROM paired_turn_attempts
WHERE continuation_handoff_id IS NOT NULL
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
continuationHandoffIdExpr:
'paired_turn_attempts.continuation_handoff_id',
turnIdExpr: 'paired_turn_attempts.turn_id',
attemptIdExpr:
"COALESCE(paired_turn_attempts.attempt_id, paired_turn_attempts.turn_id || ':attempt:' || CAST(paired_turn_attempts.attempt_no AS TEXT))",
attemptNoExpr: 'paired_turn_attempts.attempt_no',
})}
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
continuation_handoff_id: number | null;
}
| undefined;
if (invalidContinuationHandoffRow) {
throw new Error(
`paired_turn_attempts(${invalidContinuationHandoffRow.turn_id}, attempt=${invalidContinuationHandoffRow.attempt_no}) has invalid continuation_handoff_id provenance`,
);
}
}
const invalidAttemptRow = database
.prepare(
`
SELECT turn_id, attempt_no
FROM paired_turn_attempts
WHERE NOT EXISTS (
SELECT 1
FROM paired_turns
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
AND paired_turns.task_id = paired_turn_attempts.task_id
AND paired_turns.task_updated_at = paired_turn_attempts.task_updated_at
AND paired_turns.role = paired_turn_attempts.role
AND paired_turns.intent_kind = paired_turn_attempts.intent_kind
)
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
}
| undefined;
if (invalidAttemptRow) {
throw new Error(
`paired_turn_attempts(${invalidAttemptRow.turn_id}, attempt=${invalidAttemptRow.attempt_no}) does not match its paired_turns identity`,
);
}
if (tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) {
const invalidAttemptActiveRunRow = database
.prepare(
`
SELECT turn_id, attempt_no
FROM paired_turn_attempts
WHERE (
state = 'running'
AND (
active_run_id IS NULL
OR TRIM(active_run_id) = ''
)
)
OR (
state != 'running'
AND active_run_id IS NOT NULL
)
LIMIT 1
`,
)
.get() as
| {
turn_id: string;
attempt_no: number;
}
| undefined;
if (invalidAttemptActiveRunRow) {
throw new Error(
`paired_turn_attempts(${invalidAttemptActiveRunRow.turn_id}, attempt=${invalidAttemptActiveRunRow.attempt_no}) has invalid active_run_id provenance`,
);
}
}
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
const invalidReservation = database
.prepare(
`
SELECT rowid
FROM paired_turn_reservations
WHERE turn_attempt_no IS NOT NULL
AND (
turn_id IS NULL
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
OR NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id
AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no
${
tableHasColumn(
database,
'paired_turn_reservations',
'turn_attempt_id',
)
? 'AND paired_turn_attempts.attempt_id = paired_turn_reservations.turn_attempt_id'
: ''
}
AND paired_turn_attempts.task_id = paired_turn_reservations.task_id
AND paired_turn_attempts.task_updated_at = paired_turn_reservations.task_updated_at
AND paired_turn_attempts.role = paired_turn_reservations.turn_role
AND paired_turn_attempts.intent_kind = paired_turn_reservations.intent_kind
)
)
LIMIT 1
`,
)
.get() as { rowid: number } | undefined;
if (invalidReservation) {
throw new Error(
`paired_turn_reservations(rowid=${invalidReservation.rowid}) has invalid paired_turn_attempt provenance`,
);
}
}
if (
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
) {
const invalidLease = database
.prepare(
`
SELECT rowid
FROM paired_task_execution_leases
WHERE turn_attempt_no IS NOT NULL
AND (
turn_id IS NULL
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
OR NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id
AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no
${
tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_id',
)
? 'AND paired_turn_attempts.attempt_id = paired_task_execution_leases.turn_attempt_id'
: ''
}
AND paired_turn_attempts.task_id = paired_task_execution_leases.task_id
AND paired_turn_attempts.task_updated_at = paired_task_execution_leases.task_updated_at
AND paired_turn_attempts.role = paired_task_execution_leases.role
AND paired_turn_attempts.intent_kind = paired_task_execution_leases.intent_kind
)
)
LIMIT 1
`,
)
.get() as { rowid: number } | undefined;
if (invalidLease) {
throw new Error(
`paired_task_execution_leases(rowid=${invalidLease.rowid}) has invalid paired_turn_attempt provenance`,
);
}
}
if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) {
const invalidHandoff = database
.prepare(
`
SELECT id
FROM service_handoffs
WHERE turn_attempt_no IS NOT NULL
AND (
turn_id IS NULL
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
OR paired_task_id IS NULL
OR paired_task_updated_at IS NULL
OR turn_role IS NULL
OR turn_intent_kind IS NULL
OR NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id
AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no
${
tableHasColumn(
database,
'service_handoffs',
'turn_attempt_id',
)
? 'AND paired_turn_attempts.attempt_id = service_handoffs.turn_attempt_id'
: ''
}
AND paired_turn_attempts.task_id = service_handoffs.paired_task_id
AND paired_turn_attempts.task_updated_at = service_handoffs.paired_task_updated_at
AND paired_turn_attempts.role = service_handoffs.turn_role
AND paired_turn_attempts.intent_kind = service_handoffs.turn_intent_kind
)
)
LIMIT 1
`,
)
.get() as { id: number } | undefined;
if (invalidHandoff) {
throw new Error(
`service_handoffs(id=${invalidHandoff.id}) has invalid paired_turn_attempt provenance`,
);
}
}
}
function rebuildPairedTurnAttemptsWithForeignKeys(database: Database): void {
const tableSql = getTableSql(database, 'paired_turn_attempts') ?? '';
if (
tableHasColumn(database, 'paired_turn_attempts', 'attempt_id') &&
tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id') &&
tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id') &&
tableHasColumn(
database,
'paired_turn_attempts',
'continuation_handoff_id',
) &&
tableSql.includes('attempt_id TEXT NOT NULL PRIMARY KEY') &&
tableSql.includes('parent_attempt_id TEXT') &&
tableSql.includes('parent_handoff_id INTEGER') &&
tableSql.includes('continuation_handoff_id INTEGER') &&
tableSql.includes('UNIQUE (turn_id, attempt_no)') &&
tableHasForeignKey(database, {
tableName: 'paired_turn_attempts',
referencedTable: 'paired_turn_attempts',
fromColumns: ['parent_attempt_id'],
toColumns: ['attempt_id'],
onDelete: 'CASCADE',
}) &&
tableHasForeignKey(database, {
tableName: 'paired_turn_attempts',
referencedTable: 'service_handoffs',
fromColumns: ['parent_handoff_id'],
toColumns: ['id'],
onDelete: 'SET NULL',
}) &&
tableHasForeignKey(database, {
tableName: 'paired_turn_attempts',
referencedTable: 'service_handoffs',
fromColumns: ['continuation_handoff_id'],
toColumns: ['id'],
onDelete: 'SET NULL',
}) &&
tableHasForeignKey(database, {
tableName: 'paired_turn_attempts',
referencedTable: 'paired_turns',
fromColumns: ['turn_id'],
toColumns: ['turn_id'],
onDelete: 'CASCADE',
})
) {
return;
}
const parentAttemptIdSelectSql = tableHasColumn(
database,
'paired_turn_attempts',
'parent_attempt_id',
)
? `COALESCE(
parent_attempt_id,
CASE
WHEN attempt_no > 1
AND EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
)
THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
ELSE NULL
END
)`
: `CASE
WHEN attempt_no > 1
AND EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
)
THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
ELSE NULL
END`;
const parentHandoffIdSelectSql = tableHasColumn(
database,
'paired_turn_attempts',
'parent_handoff_id',
)
? 'parent_handoff_id'
: 'NULL';
const continuationHandoffIdSelectSql = tableHasColumn(
database,
'paired_turn_attempts',
'continuation_handoff_id',
)
? 'continuation_handoff_id'
: 'NULL';
const activeRunIdSelectSql = tableHasColumn(
database,
'paired_turn_attempts',
'active_run_id',
)
? `CASE
WHEN state = 'running' THEN active_run_id
ELSE NULL
END`
: `CASE
WHEN state = 'running'
THEN ${
tableHasColumn(database, 'paired_turns', 'active_run_id')
? `COALESCE(
${
tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_no',
)
? `COALESCE(
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
),
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no IS NULL
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)
)`
: `(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)`
},
(
SELECT paired_turns.active_run_id
FROM paired_turns
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
)
)`
: `(
${
tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_no',
)
? `COALESCE(
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
),
(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
AND paired_task_execution_leases.turn_attempt_no IS NULL
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)
)`
: `(
SELECT paired_task_execution_leases.claimed_run_id
FROM paired_task_execution_leases
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
ORDER BY paired_task_execution_leases.updated_at DESC,
paired_task_execution_leases.claimed_at DESC
LIMIT 1
)`
}`
}
ELSE NULL
END`;
database.transaction(() => {
database.exec(`
CREATE TABLE paired_turn_attempts_new (
attempt_id TEXT NOT NULL PRIMARY KEY,
parent_attempt_id TEXT,
parent_handoff_id INTEGER,
continuation_handoff_id INTEGER,
turn_id TEXT NOT NULL,
attempt_no INTEGER NOT NULL,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
state TEXT NOT NULL,
executor_service_id TEXT,
executor_agent_type TEXT,
active_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
last_error TEXT,
UNIQUE (turn_id, attempt_no),
FOREIGN KEY (parent_attempt_id)
REFERENCES paired_turn_attempts_new(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (parent_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (continuation_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
),
CHECK (
state IN (
'running',
'delegated',
'completed',
'failed',
'cancelled'
)
),
CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL)
);
INSERT INTO paired_turn_attempts_new (
attempt_id,
parent_attempt_id,
parent_handoff_id,
continuation_handoff_id,
turn_id,
attempt_no,
task_id,
task_updated_at,
role,
intent_kind,
state,
executor_service_id,
executor_agent_type,
active_run_id,
created_at,
updated_at,
completed_at,
last_error
)
SELECT
COALESCE(attempt_id, ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}),
${parentAttemptIdSelectSql},
${parentHandoffIdSelectSql},
${continuationHandoffIdSelectSql},
turn_id,
attempt_no,
task_id,
task_updated_at,
role,
intent_kind,
state,
executor_service_id,
executor_agent_type,
${activeRunIdSelectSql},
created_at,
updated_at,
completed_at,
last_error
FROM paired_turn_attempts
ORDER BY turn_id ASC, attempt_no ASC;
DROP TABLE paired_turn_attempts;
ALTER TABLE paired_turn_attempts_new RENAME TO paired_turn_attempts;
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_attempt_id
ON paired_turn_attempts(attempt_id);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_attempt_id
ON paired_turn_attempts(parent_attempt_id);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id
ON paired_turn_attempts(parent_handoff_id);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id
ON paired_turn_attempts(continuation_handoff_id);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn
ON paired_turn_attempts(turn_id, attempt_no);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task
ON paired_turn_attempts(task_id, task_updated_at, attempt_no);
`);
})();
}
function rebuildPairedTurnsWithoutLegacyScratchColumns(
database: Database,
): void {
if (
!tableHasColumn(database, 'paired_turns', 'next_parent_handoff_id') &&
!tableHasColumn(database, 'paired_turns', 'active_run_id') &&
!tableHasColumn(database, 'paired_turns', 'state') &&
!tableHasColumn(database, 'paired_turns', 'executor_service_id') &&
!tableHasColumn(database, 'paired_turns', 'executor_agent_type') &&
!tableHasColumn(database, 'paired_turns', 'attempt_no') &&
!tableHasColumn(database, 'paired_turns', 'completed_at') &&
!tableHasColumn(database, 'paired_turns', 'last_error')
) {
return;
}
database.transaction(() => {
database.exec(`
CREATE TABLE paired_turns_new (
turn_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
INSERT INTO paired_turns_new (
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
)
SELECT
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
FROM paired_turns
ORDER BY created_at ASC, turn_id ASC;
DROP TABLE paired_turns;
ALTER TABLE paired_turns_new RENAME TO paired_turns;
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
ON paired_turns(task_id, task_updated_at, updated_at);
`);
})();
}
function rebuildPairedTurnReservationsWithForeignKeys(
database: Database,
): void {
if (
!tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no') ||
(tableHasForeignKey(database, {
tableName: 'paired_turn_reservations',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_id', 'turn_attempt_no'],
toColumns: ['turn_id', 'attempt_no'],
onDelete: 'CASCADE',
}) &&
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') &&
tableHasForeignKey(database, {
tableName: 'paired_turn_reservations',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_attempt_id'],
toColumns: ['attempt_id'],
onDelete: 'CASCADE',
}))
) {
return;
}
database.transaction(() => {
database.exec(`
CREATE TABLE paired_turn_reservations_new (
chat_jid TEXT NOT NULL,
task_id TEXT NOT NULL,
task_status TEXT NOT NULL,
round_trip_count INTEGER NOT NULL DEFAULT 0,
task_updated_at TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
scheduled_run_id TEXT,
consumed_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
consumed_at TEXT,
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
FOREIGN KEY (turn_attempt_id)
REFERENCES paired_turn_attempts(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'completed')),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
INSERT INTO paired_turn_reservations_new (
chat_jid,
task_id,
task_status,
round_trip_count,
task_updated_at,
turn_id,
turn_attempt_id,
turn_attempt_no,
turn_role,
intent_kind,
status,
scheduled_run_id,
consumed_run_id,
created_at,
updated_at,
consumed_at
)
SELECT
chat_jid,
task_id,
task_status,
round_trip_count,
task_updated_at,
turn_id,
COALESCE(
turn_attempt_id,
CASE
WHEN turn_attempt_no IS NOT NULL
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
ELSE NULL
END
),
turn_attempt_no,
turn_role,
intent_kind,
status,
scheduled_run_id,
consumed_run_id,
created_at,
updated_at,
consumed_at
FROM paired_turn_reservations;
DROP TABLE paired_turn_reservations;
ALTER TABLE paired_turn_reservations_new RENAME TO paired_turn_reservations;
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
ON paired_turn_reservations(task_id, task_updated_at, status);
`);
})();
}
function rebuildPairedTaskExecutionLeasesWithForeignKeys(
database: Database,
): void {
if (
!tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_no',
) ||
(tableHasForeignKey(database, {
tableName: 'paired_task_execution_leases',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_id', 'turn_attempt_no'],
toColumns: ['turn_id', 'attempt_no'],
onDelete: 'CASCADE',
}) &&
tableHasColumn(
database,
'paired_task_execution_leases',
'turn_attempt_id',
) &&
tableHasForeignKey(database, {
tableName: 'paired_task_execution_leases',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_attempt_id'],
toColumns: ['attempt_id'],
onDelete: 'CASCADE',
}))
) {
return;
}
database.transaction(() => {
database.exec(`
CREATE TABLE paired_task_execution_leases_new (
task_id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
intent_kind TEXT NOT NULL,
claimed_run_id TEXT NOT NULL,
claimed_service_id TEXT NOT NULL,
task_status TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (turn_attempt_id)
REFERENCES paired_turn_attempts(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
INSERT INTO paired_task_execution_leases_new (
task_id,
chat_jid,
role,
turn_id,
turn_attempt_id,
turn_attempt_no,
intent_kind,
claimed_run_id,
claimed_service_id,
task_status,
task_updated_at,
claimed_at,
updated_at,
expires_at
)
SELECT
task_id,
chat_jid,
role,
turn_id,
COALESCE(
turn_attempt_id,
CASE
WHEN turn_attempt_no IS NOT NULL
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
ELSE NULL
END
),
turn_attempt_no,
intent_kind,
claimed_run_id,
claimed_service_id,
task_status,
task_updated_at,
claimed_at,
updated_at,
expires_at
FROM paired_task_execution_leases;
DROP TABLE paired_task_execution_leases;
ALTER TABLE paired_task_execution_leases_new RENAME TO paired_task_execution_leases;
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at
ON paired_task_execution_leases(expires_at);
`);
})();
}
function rebuildServiceHandoffsWithForeignKeys(database: Database): void {
if (
!tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') ||
(tableHasForeignKey(database, {
tableName: 'service_handoffs',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_id', 'turn_attempt_no'],
toColumns: ['turn_id', 'attempt_no'],
onDelete: 'CASCADE',
}) &&
tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') &&
tableHasForeignKey(database, {
tableName: 'service_handoffs',
referencedTable: 'paired_turn_attempts',
fromColumns: ['turn_attempt_id'],
toColumns: ['attempt_id'],
onDelete: 'CASCADE',
}))
) {
return;
}
database.transaction(() => {
database.exec(`
CREATE TABLE service_handoffs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
source_service_id TEXT NOT NULL,
target_service_id TEXT NOT NULL,
paired_task_id TEXT,
paired_task_updated_at TEXT,
turn_id TEXT,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_intent_kind TEXT,
turn_role TEXT,
source_role TEXT,
source_agent_type TEXT,
target_role TEXT,
target_agent_type TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
start_seq INTEGER,
end_seq INTEGER,
reason TEXT,
intended_role TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
claimed_at TEXT,
completed_at TEXT,
last_error TEXT,
FOREIGN KEY (turn_attempt_id)
REFERENCES paired_turn_attempts(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
CHECK (
turn_intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
) OR turn_intent_kind IS NULL
),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL),
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
);
INSERT INTO service_handoffs_new (
id,
chat_jid,
group_folder,
source_service_id,
target_service_id,
paired_task_id,
paired_task_updated_at,
turn_id,
turn_attempt_id,
turn_attempt_no,
turn_intent_kind,
turn_role,
source_role,
source_agent_type,
target_role,
target_agent_type,
prompt,
status,
start_seq,
end_seq,
reason,
intended_role,
created_at,
claimed_at,
completed_at,
last_error
)
SELECT
id,
chat_jid,
group_folder,
source_service_id,
target_service_id,
paired_task_id,
paired_task_updated_at,
turn_id,
COALESCE(
turn_attempt_id,
CASE
WHEN turn_attempt_no IS NOT NULL
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
ELSE NULL
END
),
turn_attempt_no,
turn_intent_kind,
turn_role,
source_role,
source_agent_type,
target_role,
target_agent_type,
prompt,
status,
start_seq,
end_seq,
reason,
intended_role,
created_at,
claimed_at,
completed_at,
last_error
FROM service_handoffs;
DROP TABLE service_handoffs;
ALTER TABLE service_handoffs_new RENAME TO service_handoffs;
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
ON service_handoffs(status, target_role, target_agent_type, created_at);
`);
})();
}
function rebuildPairedTurnAttemptForeignKeyTables(database: Database): void {
rebuildPairedTurnAttemptsWithForeignKeys(database);
rebuildPairedTurnReservationsWithForeignKeys(database);
rebuildPairedTaskExecutionLeasesWithForeignKeys(database);
rebuildServiceHandoffsWithForeignKeys(database);
}
function dropPairedTurnAttemptProvenanceConstraints(database: Database): void {
database.exec(`
DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert;
DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update;
DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_insert;
DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_update;
DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_insert;
DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_update;
DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_insert;
DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_update;
`);
}
function applyPairedTurnAttemptProvenanceConstraints(database: Database): void {
database.exec(`
CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_insert
BEFORE INSERT ON paired_turn_attempts
BEGIN
SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no')
WHERE NEW.attempt_id IS NULL
OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')};
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id')
WHERE NEW.attempt_no <= 1
AND NEW.parent_attempt_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage')
WHERE NEW.attempt_no > 1
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = NEW.turn_id
AND previous_attempt.attempt_no = NEW.attempt_no - 1
);
SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage')
WHERE NEW.attempt_no > 1
AND (
NEW.parent_attempt_id IS NULL
OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')}
);
SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn')
WHERE NEW.parent_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE COALESCE(
previous_attempt.attempt_id,
${buildPairedTurnAttemptIdSql(
'previous_attempt.turn_id',
'previous_attempt.attempt_no',
)}
) = NEW.parent_attempt_id
AND previous_attempt.turn_id = NEW.turn_id
AND previous_attempt.attempt_no = NEW.attempt_no - 1
);
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id')
WHERE NEW.attempt_no <= 1
AND NEW.parent_handoff_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn')
WHERE NEW.parent_handoff_id IS NOT NULL
AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
parentHandoffIdExpr: 'NEW.parent_handoff_id',
turnIdExpr: 'NEW.turn_id',
parentAttemptIdExpr: 'NEW.parent_attempt_id',
attemptNoExpr: 'NEW.attempt_no',
})};
SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt')
WHERE NEW.continuation_handoff_id IS NOT NULL
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
continuationHandoffIdExpr: 'NEW.continuation_handoff_id',
turnIdExpr: 'NEW.turn_id',
attemptIdExpr: 'NEW.attempt_id',
attemptNoExpr: 'NEW.attempt_no',
})};
SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id')
WHERE NEW.state = 'running'
AND (
NEW.active_run_id IS NULL
OR TRIM(NEW.active_run_id) = ''
);
SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id')
WHERE NEW.state != 'running'
AND NEW.active_run_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row')
WHERE NOT EXISTS (
SELECT 1
FROM paired_turns
WHERE paired_turns.turn_id = NEW.turn_id
AND paired_turns.task_id = NEW.task_id
AND paired_turns.task_updated_at = NEW.task_updated_at
AND paired_turns.role = NEW.role
AND paired_turns.intent_kind = NEW.intent_kind
);
END;
CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_update
BEFORE UPDATE OF attempt_id, parent_attempt_id, parent_handoff_id, continuation_handoff_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind, state, executor_service_id, executor_agent_type, active_run_id
ON paired_turn_attempts
BEGIN
SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no')
WHERE NEW.attempt_id IS NULL
OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')};
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id')
WHERE NEW.attempt_no <= 1
AND NEW.parent_attempt_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage')
WHERE NEW.attempt_no > 1
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE previous_attempt.turn_id = NEW.turn_id
AND previous_attempt.attempt_no = NEW.attempt_no - 1
);
SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage')
WHERE NEW.attempt_no > 1
AND (
NEW.parent_attempt_id IS NULL
OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')}
);
SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn')
WHERE NEW.parent_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts previous_attempt
WHERE COALESCE(
previous_attempt.attempt_id,
${buildPairedTurnAttemptIdSql(
'previous_attempt.turn_id',
'previous_attempt.attempt_no',
)}
) = NEW.parent_attempt_id
AND previous_attempt.turn_id = NEW.turn_id
AND previous_attempt.attempt_no = NEW.attempt_no - 1
);
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id')
WHERE NEW.attempt_no <= 1
AND NEW.parent_handoff_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn')
WHERE NEW.parent_handoff_id IS NOT NULL
AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
parentHandoffIdExpr: 'NEW.parent_handoff_id',
turnIdExpr: 'NEW.turn_id',
parentAttemptIdExpr: 'NEW.parent_attempt_id',
attemptNoExpr: 'NEW.attempt_no',
})};
SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt')
WHERE NEW.continuation_handoff_id IS NOT NULL
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
continuationHandoffIdExpr: 'NEW.continuation_handoff_id',
turnIdExpr: 'NEW.turn_id',
attemptIdExpr: 'NEW.attempt_id',
attemptNoExpr: 'NEW.attempt_no',
})};
SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id')
WHERE NEW.state = 'running'
AND (
NEW.active_run_id IS NULL
OR TRIM(NEW.active_run_id) = ''
);
SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id')
WHERE NEW.state != 'running'
AND NEW.active_run_id IS NOT NULL;
SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row')
WHERE NOT EXISTS (
SELECT 1
FROM paired_turns
WHERE paired_turns.turn_id = NEW.turn_id
AND paired_turns.task_id = NEW.task_id
AND paired_turns.task_updated_at = NEW.task_updated_at
AND paired_turns.role = NEW.role
AND paired_turns.intent_kind = NEW.intent_kind
);
END;
`);
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt
ON paired_turn_reservations(turn_id, turn_attempt_no);
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt_id
ON paired_turn_reservations(turn_attempt_id);
CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_insert
BEFORE INSERT ON paired_turn_reservations
BEGIN
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id')
WHERE NEW.turn_attempt_no IS NOT NULL
AND NEW.turn_id IS NULL;
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.task_id
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
AND paired_turn_attempts.role = NEW.turn_role
AND paired_turn_attempts.intent_kind = NEW.intent_kind
);
END;
CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_update
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, turn_role, intent_kind
ON paired_turn_reservations
BEGIN
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id')
WHERE NEW.turn_attempt_no IS NOT NULL
AND NEW.turn_id IS NULL;
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.task_id
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
AND paired_turn_attempts.role = NEW.turn_role
AND paired_turn_attempts.intent_kind = NEW.intent_kind
);
END;
`);
}
if (
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
) {
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt
ON paired_task_execution_leases(turn_id, turn_attempt_no);
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt_id
ON paired_task_execution_leases(turn_attempt_id);
CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_insert
BEFORE INSERT ON paired_task_execution_leases
BEGIN
SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id')
WHERE NEW.turn_attempt_no IS NOT NULL
AND NEW.turn_id IS NULL;
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.task_id
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
AND paired_turn_attempts.role = NEW.role
AND paired_turn_attempts.intent_kind = NEW.intent_kind
);
END;
CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_update
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, role, intent_kind
ON paired_task_execution_leases
BEGIN
SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id')
WHERE NEW.turn_attempt_no IS NOT NULL
AND NEW.turn_id IS NULL;
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.task_id
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
AND paired_turn_attempts.role = NEW.role
AND paired_turn_attempts.intent_kind = NEW.intent_kind
);
END;
`);
}
if (
tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') &&
tableHasColumn(database, 'service_handoffs', 'paired_task_id') &&
tableHasColumn(database, 'service_handoffs', 'paired_task_updated_at') &&
tableHasColumn(database, 'service_handoffs', 'turn_role') &&
tableHasColumn(database, 'service_handoffs', 'turn_intent_kind')
) {
database.exec(`
CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt
ON service_handoffs(turn_id, turn_attempt_no);
CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt_id
ON service_handoffs(turn_attempt_id);
CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_insert
BEFORE INSERT ON service_handoffs
BEGIN
SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity')
WHERE NEW.turn_attempt_no IS NOT NULL
AND (
NEW.turn_id IS NULL
OR NEW.paired_task_id IS NULL
OR NEW.paired_task_updated_at IS NULL
OR NEW.turn_role IS NULL
OR NEW.turn_intent_kind IS NULL
);
SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.paired_task_id
AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at
AND paired_turn_attempts.role = NEW.turn_role
AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind
);
END;
CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_update
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, paired_task_id, paired_task_updated_at, turn_role, turn_intent_kind
ON service_handoffs
BEGIN
SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity')
WHERE NEW.turn_attempt_no IS NOT NULL
AND (
NEW.turn_id IS NULL
OR NEW.paired_task_id IS NULL
OR NEW.paired_task_updated_at IS NULL
OR NEW.turn_role IS NULL
OR NEW.turn_intent_kind IS NULL
);
SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync')
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row')
WHERE NEW.turn_attempt_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
AND paired_turn_attempts.turn_id = NEW.turn_id
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
AND paired_turn_attempts.task_id = NEW.paired_task_id
AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at
AND paired_turn_attempts.role = NEW.turn_role
AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind
);
END;
`);
}
}
interface StoredWorkItemServiceRow {
id: number;
agent_type?: string | null;
service_id?: string | null;
delivery_role?: string | null;
}
function normalizePairedRole(
role: string | null | undefined,
): 'owner' | 'reviewer' | 'arbiter' | null {
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
? role
: null;
}
function inferLegacyExecutionLeaseServiceId(
row: LegacyExecutionLeaseServiceRow,
): string | null {
return inferExecutionLeaseServiceIdFromCanonicalMetadata(row);
}
function backfillLegacyExecutionLeaseServiceShadows(database: Database): void {
if (
!tableHasColumn(
database,
'paired_task_execution_leases',
'claimed_service_id',
)
) {
return;
}
const pairedTasksTable = database
.prepare(
`
SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = 'paired_tasks'
`,
)
.get();
if (!pairedTasksTable) {
return;
}
const pairedTaskColumns = getTableColumns(database, 'paired_tasks');
const selectPairedTaskColumn = (columnName: string): string =>
pairedTaskColumns.includes(columnName)
? `paired_tasks.${columnName} AS ${columnName}`
: `NULL AS ${columnName}`;
const rows = database
.prepare(
`
SELECT
paired_task_execution_leases.rowid AS rowid,
paired_task_execution_leases.role AS role,
${selectPairedTaskColumn('owner_service_id')},
${selectPairedTaskColumn('reviewer_service_id')},
${selectPairedTaskColumn('arbiter_service_id')},
${selectPairedTaskColumn('owner_agent_type')},
${selectPairedTaskColumn('reviewer_agent_type')},
${selectPairedTaskColumn('arbiter_agent_type')}
FROM paired_task_execution_leases
LEFT JOIN paired_tasks
ON paired_tasks.id = paired_task_execution_leases.task_id
WHERE paired_task_execution_leases.claimed_service_id IS NULL
`,
)
.all() as LegacyExecutionLeaseServiceRow[];
if (rows.length === 0) {
return;
}
const update = database.prepare(
`
UPDATE paired_task_execution_leases
SET claimed_service_id = ?
WHERE rowid = ?
AND claimed_service_id IS NULL
`,
);
const tx = database.transaction(
(leaseRows: LegacyExecutionLeaseServiceRow[]) => {
for (const row of leaseRows) {
const claimedServiceId = inferLegacyExecutionLeaseServiceId(row);
if (!claimedServiceId) {
continue;
}
update.run(claimedServiceId, row.rowid);
}
},
);
tx(rows);
}
function backfillCanonicalPairedTaskServiceIds(database: Database): void {
if (
!tableHasColumn(database, 'paired_tasks', 'owner_service_id') ||
!tableHasColumn(database, 'paired_tasks', 'reviewer_service_id')
) {
return;
}
const rows = database
.prepare(
`
SELECT
rowid,
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
owner_agent_type,
reviewer_agent_type,
arbiter_agent_type
FROM paired_tasks
`,
)
.all() as StoredPairedTaskServiceRow[];
if (rows.length === 0) {
return;
}
const update = database.prepare(
`
UPDATE paired_tasks
SET owner_service_id = ?,
reviewer_service_id = ?,
owner_agent_type = ?,
reviewer_agent_type = ?,
arbiter_agent_type = ?
WHERE rowid = ?
`,
);
const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => {
for (const row of taskRows) {
const {
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
ownerServiceId,
reviewerServiceId,
} = canonicalizePairedTaskMetadata({
id: row.id,
owner_service_id: row.owner_service_id,
reviewer_service_id: row.reviewer_service_id,
owner_agent_type: row.owner_agent_type,
reviewer_agent_type: row.reviewer_agent_type,
arbiter_agent_type: row.arbiter_agent_type,
});
if (
row.owner_service_id === ownerServiceId &&
row.reviewer_service_id === reviewerServiceId &&
row.owner_agent_type === ownerAgentType &&
row.reviewer_agent_type === reviewerAgentType &&
row.arbiter_agent_type === arbiterAgentType
) {
continue;
}
update.run(
ownerServiceId,
reviewerServiceId,
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
row.rowid,
);
}
});
tx(rows);
}
function backfillCanonicalChannelOwnerServiceIds(database: Database): void {
if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
return;
}
const rows = database
.prepare(
`
SELECT
rowid,
chat_jid,
owner_service_id,
reviewer_service_id,
arbiter_service_id,
owner_agent_type,
reviewer_agent_type,
arbiter_agent_type
FROM channel_owner
`,
)
.all() as StoredChannelOwnerLeaseServiceRow[];
if (rows.length === 0) {
return;
}
const update = database.prepare(
`
UPDATE channel_owner
SET owner_service_id = ?,
reviewer_service_id = ?,
arbiter_service_id = ?,
owner_agent_type = ?,
reviewer_agent_type = ?,
arbiter_agent_type = ?
WHERE rowid = ?
`,
);
const tx = database.transaction(
(leaseRows: StoredChannelOwnerLeaseServiceRow[]) => {
for (const row of leaseRows) {
const {
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
ownerServiceId,
reviewerServiceId,
arbiterServiceId,
} = canonicalizeChannelOwnerLeaseMetadata({
chat_jid: row.chat_jid,
owner_service_id: row.owner_service_id,
reviewer_service_id: row.reviewer_service_id,
arbiter_service_id: row.arbiter_service_id,
owner_agent_type: row.owner_agent_type,
reviewer_agent_type: row.reviewer_agent_type,
arbiter_agent_type: row.arbiter_agent_type,
});
if (
row.owner_service_id === ownerServiceId &&
row.reviewer_service_id === reviewerServiceId &&
row.arbiter_service_id === arbiterServiceId &&
row.owner_agent_type === ownerAgentType &&
row.reviewer_agent_type === reviewerAgentType &&
row.arbiter_agent_type === arbiterAgentType
) {
continue;
}
update.run(
ownerServiceId,
reviewerServiceId,
arbiterServiceId,
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
row.rowid,
);
}
},
);
tx(rows);
}
function backfillCanonicalServiceHandoffServiceIds(database: Database): void {
if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
return;
}
const rows = database
.prepare(
`
SELECT
id,
chat_jid,
group_folder,
source_service_id,
target_service_id,
source_role,
target_role,
source_agent_type,
target_agent_type,
intended_role
FROM service_handoffs
`,
)
.all() as StoredServiceHandoffServiceRow[];
if (rows.length === 0) {
return;
}
const update = database.prepare(
`
UPDATE service_handoffs
SET source_service_id = ?,
target_service_id = ?,
source_agent_type = ?,
target_agent_type = ?,
source_role = ?,
target_role = ?
WHERE id = ?
`,
);
const tx = database.transaction(
(handoffRows: StoredServiceHandoffServiceRow[]) => {
for (const row of handoffRows) {
const {
sourceRole,
targetRole,
sourceAgentType,
targetAgentType,
sourceServiceId,
targetServiceId,
} = canonicalizeServiceHandoffMetadata({
id: row.id,
chat_jid: row.chat_jid,
source_service_id: row.source_service_id,
target_service_id: row.target_service_id,
source_role: row.source_role,
target_role: row.target_role,
intended_role: row.intended_role,
source_agent_type: row.source_agent_type,
target_agent_type: row.target_agent_type,
});
if (
row.source_service_id === sourceServiceId &&
row.target_service_id === targetServiceId &&
row.source_agent_type === (sourceAgentType ?? null) &&
row.target_agent_type === targetAgentType &&
row.source_role === sourceRole &&
row.target_role === targetRole
) {
continue;
}
update.run(
sourceServiceId,
targetServiceId,
sourceAgentType,
targetAgentType,
sourceRole,
targetRole,
row.id,
);
}
},
);
tx(rows);
}
function backfillCanonicalWorkItemServiceIds(database: Database): void {
if (!tableHasColumn(database, 'work_items', 'service_id')) {
return;
}
const rows = database
.prepare(
`SELECT id, agent_type, service_id, delivery_role
FROM work_items`,
)
.all() as StoredWorkItemServiceRow[];
if (rows.length === 0) {
return;
}
const update = database.prepare(
`UPDATE work_items
SET service_id = ?
WHERE id = ?`,
);
const tx = database.transaction(
(workItemRows: StoredWorkItemServiceRow[]) => {
for (const row of workItemRows) {
const agentType =
normalizeStoredAgentType(row.agent_type) ?? 'claude-code';
const deliveryRole = normalizePairedRole(row.delivery_role) ?? 'owner';
const serviceId =
(row.service_id ? normalizeServiceId(row.service_id) : null) ??
resolveRoleServiceShadow(deliveryRole, agentType) ??
SERVICE_SESSION_SCOPE;
if (row.service_id === serviceId) {
continue;
}
update.run(serviceId, row.id);
}
},
);
tx(rows);
}
export function applySchemaMigrations(
database: Database,
args: {
assistantName: string;
},
): void {
const { assistantName } = args;
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
);
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN folder TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN created_at TEXT`,
);
if (tableHasColumn(database, 'room_settings', 'created_at')) {
database.exec(`
UPDATE room_settings
SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP)
`);
}
database.exec(`
CREATE TABLE IF NOT EXISTS room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, role),
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (agent_type IN ('claude-code', 'codex'))
)
`);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN source_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN target_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN source_service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN target_service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN paired_task_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN paired_task_updated_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN turn_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN turn_attempt_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN turn_attempt_no INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN turn_intent_kind TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN turn_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN reviewer_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN owner_service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN reviewer_service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_task_execution_leases ADD COLUMN expires_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_task_execution_leases ADD COLUMN claimed_service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_no INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_attempts ADD COLUMN attempt_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_attempts ADD COLUMN parent_attempt_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_attempts ADD COLUMN parent_handoff_id INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_attempts ADD COLUMN continuation_handoff_id INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_attempts ADD COLUMN active_run_id TEXT`,
);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id
ON paired_turn_attempts(parent_handoff_id)
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id
ON paired_turn_attempts(continuation_handoff_id)
`);
database.exec(`
UPDATE paired_task_execution_leases
SET expires_at = COALESCE(
expires_at,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+10 minutes')
)
`);
// Legacy lease rows predate claimed_service_id. Infer the original service
// from paired-task role metadata when possible instead of blanket-marking
// everything as the current service, which would let startup cleanup delete
// leases that belong to another runtime.
backfillLegacyExecutionLeaseServiceShadows(database);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at
ON paired_task_execution_leases(expires_at)
`);
tryExecMigration(
database,
`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE work_items ADD COLUMN service_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_no INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_role TEXT`,
);
database.exec(`
UPDATE paired_turn_reservations
SET turn_id = COALESCE(
turn_id,
task_id || ':' || task_updated_at || ':' || intent_kind
)
`);
database.exec(`
UPDATE paired_turn_reservations
SET turn_role = COALESCE(
turn_role,
CASE
WHEN intent_kind = 'reviewer-turn' THEN 'reviewer'
WHEN intent_kind = 'arbiter-turn' THEN 'arbiter'
ELSE 'owner'
END
)
`);
database.exec(`
UPDATE paired_task_execution_leases
SET turn_id = COALESCE(
turn_id,
task_id || ':' || task_updated_at || ':' || intent_kind
)
`);
database.exec(
`UPDATE service_handoffs
SET target_role = COALESCE(
target_role,
intended_role,
CASE
WHEN reason LIKE 'reviewer-%' THEN 'reviewer'
WHEN reason LIKE 'arbiter-%' THEN 'arbiter'
WHEN reason IS NOT NULL THEN 'owner'
ELSE NULL
END
)
WHERE target_role IS NULL`,
);
database.exec(
`UPDATE service_handoffs
SET source_role = COALESCE(source_role, target_role, intended_role)
WHERE source_role IS NULL`,
);
database.exec(
`UPDATE service_handoffs
SET turn_role = COALESCE(turn_role, target_role, intended_role)
WHERE turn_role IS NULL`,
);
database.exec(
`UPDATE service_handoffs
SET turn_intent_kind = COALESCE(
turn_intent_kind,
CASE
WHEN turn_role = 'reviewer' THEN 'reviewer-turn'
WHEN turn_role = 'arbiter' THEN 'arbiter-turn'
ELSE turn_intent_kind
END
)`,
);
database.exec(
`UPDATE service_handoffs
SET turn_id = COALESCE(
turn_id,
CASE
WHEN paired_task_id IS NOT NULL
AND paired_task_updated_at IS NOT NULL
AND turn_intent_kind IS NOT NULL
THEN paired_task_id || ':' || paired_task_updated_at || ':' || turn_intent_kind
ELSE turn_id
END
)`,
);
backfillPairedTurnAttemptsFromTurns(database);
backfillPairedTurnAttemptIds(database);
backfillPairedTurnAttemptParentIds(database);
backfillPairedTurnAttemptActiveRunIds(database);
backfillPairedTurnAttemptProvenance(database);
backfillPairedTurnAttemptEntityIds(database);
assertPairedTurnAttemptProvenanceIntegrity(database);
dropPairedTurnAttemptProvenanceConstraints(database);
rebuildPairedTurnsWithoutLegacyScratchColumns(database);
rebuildPairedTurnAttemptForeignKeyTables(database);
applyPairedTurnAttemptProvenanceConstraints(database);
for (const column of [
'owner_service_id',
'reviewer_service_id',
'arbiter_service_id',
'owner_agent_type',
'reviewer_agent_type',
'arbiter_agent_type',
]) {
tryExecMigration(
database,
`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`,
);
}
backfillCanonicalPairedTaskServiceIds(database);
backfillCanonicalChannelOwnerServiceIds(database);
backfillCanonicalServiceHandoffServiceIds(database);
backfillCanonicalWorkItemServiceIds(database);
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
);
database.exec(`
UPDATE scheduled_tasks
SET agent_type = COALESCE(
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
AND folder = scheduled_tasks.group_folder
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE folder = scheduled_tasks.group_folder
)
)
WHERE agent_type IS NULL;
`);
try {
database.exec(
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
);
database
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
.run(`${assistantName}:%`);
} catch {
/* column already exists */
}
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
backfillMessageSeq(database);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
ON work_items(chat_jid, agent_type, service_id, delivery_role, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
`);
const roomBindingsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (
roomBindingsSql &&
!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')
) {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
const hasAgentType = registeredGroupCols.some(
(col) => col.name === 'agent_type',
);
const hasWorkDir = registeredGroupCols.some(
(col) => col.name === 'work_dir',
);
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
database.exec(`
CREATE TABLE registered_groups_new (
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
`);
database.exec(`
INSERT INTO registered_groups_new (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
)
SELECT
jid,
name,
folder,
trigger_pattern,
added_at,
${
hasAgentConfig
? 'agent_config'
: hasContainerConfig
? 'container_config'
: 'NULL'
},
requires_trigger,
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
${hasWorkDir ? 'work_dir' : 'NULL'}
FROM registered_groups;
`);
database.exec(`
DROP TABLE registered_groups;
ALTER TABLE registered_groups_new RENAME TO registered_groups;
`);
} else {
database.exec(
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
);
}
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
if (!hasAgentConfig) {
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
}
if (hasContainerConfig) {
database.exec(
`UPDATE registered_groups
SET agent_config = COALESCE(agent_config, container_config)
WHERE container_config IS NOT NULL`,
);
}
const pairedTasksSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
)
.get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
const pairedTasksNeedsRebuild =
pairedTasksSql &&
(pairedTasksSql.includes('task_policy') ||
!pairedTasksSql.includes('round_trip_count'));
if (pairedTasksNeedsRebuild) {
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
completion_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
`);
}
{
const ptSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
)
.get() as { sql?: string } | undefined;
const ptSql = ptSqlRow?.sql || '';
if (ptSql && !ptSql.includes('arbiter_requested')) {
const pairedTaskCols = getTableColumns(database, 'paired_tasks');
const selectPairedTaskColumn = (columnName: string): string =>
pairedTaskCols.includes(columnName)
? columnName
: `NULL AS ${columnName}`;
database.exec(`
CREATE TABLE paired_tasks_new (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT,
reviewer_service_id TEXT,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
completion_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
INSERT INTO paired_tasks_new (
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
owner_agent_type, reviewer_agent_type,
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
)
SELECT
id, chat_jid, group_folder,
${selectPairedTaskColumn('owner_service_id')},
${selectPairedTaskColumn('reviewer_service_id')},
owner_agent_type, reviewer_agent_type,
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
FROM paired_tasks;
DROP TABLE paired_tasks;
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
`);
}
}
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
);
for (const table of [
'paired_executions',
'paired_approvals',
'paired_artifacts',
'paired_events',
]) {
database.exec(`DROP TABLE IF EXISTS ${table}`);
}
const pairedWsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`,
)
.get() as { sql?: string } | undefined;
const pairedWsSql = pairedWsSqlRow?.sql || '';
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
snapshot_ref TEXT,
status TEXT NOT NULL DEFAULT 'ready',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('ready', 'stale'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
`);
}
const pairedProjSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`,
)
.get() as { sql?: string } | undefined;
const pairedProjSql = pairedProjSqlRow?.sql || '';
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
database.exec(`DROP TABLE IF EXISTS paired_projects`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
}
migrateSessionsTableToCompositePk(database, 'claude-code');
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
dropLegacyServiceSessionsTable(database);
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
);
database.exec(
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
);
database.exec(
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
);
} catch {
/* columns already exist */
}
}