fix: simplify and stabilize runtime service routing

This commit is contained in:
ejclaw
2026-04-09 22:49:20 +09:00
parent 0461e401e8
commit 5714348897
17 changed files with 1911 additions and 1387 deletions

View File

@@ -1,29 +1,30 @@
import { Database } from 'bun:sqlite';
import { normalizeServiceId } from '../config.js';
import {
ARBITER_AGENT_TYPE,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
OWNER_AGENT_TYPE,
SERVICE_SESSION_SCOPE,
normalizeServiceId,
} from '../config.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import {
resolveStablePairedTaskOwnerAgentType,
resolveStableReviewerAgentType,
resolveStableRoomRoleAgentType,
} from './legacy-rebuilds.js';
import { normalizeStoredAgentType } from './room-registration.js';
import {
backfillLegacyServiceSessions,
dropLegacyServiceSessionsTable,
migrateSessionsTableToCompositePk,
} from './sessions.js';
export interface SchemaMigrationHooks {
backfillMessageSeq(database: Database): void;
backfillStoredRoomSettings(database: Database): void;
backfillChannelOwnerRoleMetadata(database: Database): void;
backfillWorkItemServiceShadows(database: Database): void;
backfillServiceHandoffServiceShadows(database: Database): void;
backfillPairedTaskRoleMetadata(database: Database): void;
rebuildWorkItemsCanonicalSchema(database: Database): void;
rebuildChannelOwnerCanonicalSchema(database: Database): void;
rebuildPairedTasksCanonicalSchema(database: Database): void;
rebuildServiceHandoffsCanonicalSchema(database: Database): void;
}
function getTableColumns(database: Database, tableName: string): string[] {
return (
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
@@ -48,6 +49,43 @@ function tryExecMigration(database: Database, sql: string): void {
}
}
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;
@@ -59,13 +97,53 @@ interface LegacyExecutionLeaseServiceRow {
arbiter_agent_type?: string | null;
}
function normalizePairedAgentType(
agentType: string | null | undefined,
): 'codex' | 'claude-code' | null {
if (agentType === 'codex' || agentType === 'claude-code') {
return agentType;
}
return null;
interface StoredPairedTaskServiceRow {
rowid: number;
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;
}
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 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(
@@ -73,7 +151,7 @@ function inferLegacyExecutionLeaseServiceId(
): string | null {
switch (row.role) {
case 'owner': {
const ownerAgentType = normalizePairedAgentType(row.owner_agent_type);
const ownerAgentType = normalizeStoredAgentType(row.owner_agent_type);
return (
(row.owner_service_id
? normalizeServiceId(row.owner_service_id)
@@ -81,7 +159,7 @@ function inferLegacyExecutionLeaseServiceId(
);
}
case 'reviewer': {
const reviewerAgentType = normalizePairedAgentType(
const reviewerAgentType = normalizeStoredAgentType(
row.reviewer_agent_type,
);
return (
@@ -91,7 +169,7 @@ function inferLegacyExecutionLeaseServiceId(
);
}
case 'arbiter': {
const arbiterAgentType = normalizePairedAgentType(row.arbiter_agent_type);
const arbiterAgentType = normalizeStoredAgentType(row.arbiter_agent_type);
return (
(row.arbiter_service_id
? normalizeServiceId(row.arbiter_service_id)
@@ -181,14 +259,344 @@ function backfillLegacyExecutionLeaseServiceShadows(database: Database): void {
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,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
owner_agent_type,
reviewer_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 = ?
WHERE rowid = ?
`,
);
const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => {
for (const row of taskRows) {
const persistedOwnerAgentType = normalizeStoredAgentType(
row.owner_agent_type,
);
const persistedReviewerAgentType = normalizeStoredAgentType(
row.reviewer_agent_type,
);
const stableOwnerAgentType = resolveStablePairedTaskOwnerAgentType(
database,
row,
);
const ownerAgentType =
stableOwnerAgentType ??
(row.owner_service_id
? inferAgentTypeFromServiceShadow(row.owner_service_id)
: undefined);
const stableReviewerAgentType = resolveStableReviewerAgentType(
stableOwnerAgentType,
row.reviewer_agent_type ?? null,
);
const reviewerAgentType =
persistedReviewerAgentType ??
stableReviewerAgentType ??
(row.reviewer_service_id
? inferAgentTypeFromServiceShadow(row.reviewer_service_id)
: null) ??
resolveStableReviewerAgentType(ownerAgentType, null);
const ownerServiceId =
(persistedOwnerAgentType
? resolveRoleServiceShadow('owner', ownerAgentType)
: null) ??
row.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CODEX_MAIN_SERVICE_ID;
const reviewerServiceId =
(persistedReviewerAgentType
? resolveRoleServiceShadow('reviewer', reviewerAgentType)
: null) ??
row.reviewer_service_id ??
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
CODEX_REVIEW_SERVICE_ID;
if (
row.owner_service_id === ownerServiceId &&
row.reviewer_service_id === reviewerServiceId
) {
continue;
}
update.run(ownerServiceId, reviewerServiceId, 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 = ?
WHERE rowid = ?
`,
);
const tx = database.transaction(
(leaseRows: StoredChannelOwnerLeaseServiceRow[]) => {
for (const row of leaseRows) {
const persistedOwnerAgentType = normalizeStoredAgentType(
row.owner_agent_type,
);
const persistedReviewerAgentType = normalizeStoredAgentType(
row.reviewer_agent_type,
);
const persistedArbiterAgentType = normalizeStoredAgentType(
row.arbiter_agent_type,
);
const ownerAgentType =
persistedOwnerAgentType ??
(row.owner_service_id
? inferAgentTypeFromServiceShadow(row.owner_service_id)
: undefined) ??
OWNER_AGENT_TYPE;
const reviewerAgentType =
row.reviewer_agent_type == null && row.reviewer_service_id == null
? null
: (persistedReviewerAgentType ??
(row.reviewer_service_id
? inferAgentTypeFromServiceShadow(row.reviewer_service_id)
: null) ??
resolveStableReviewerAgentType(ownerAgentType, null));
const arbiterAgentType =
row.arbiter_agent_type == null && row.arbiter_service_id == null
? null
: (persistedArbiterAgentType ??
(row.arbiter_service_id
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
: undefined) ??
ARBITER_AGENT_TYPE ??
null);
const ownerServiceId =
row.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CLAUDE_SERVICE_ID;
const reviewerServiceId =
row.reviewer_service_id ??
(reviewerAgentType == null
? null
: resolveRoleServiceShadow('reviewer', reviewerAgentType));
const arbiterServiceId =
row.arbiter_service_id ??
(arbiterAgentType == null
? null
: resolveRoleServiceShadow('arbiter', arbiterAgentType));
if (
row.owner_service_id === ownerServiceId &&
row.reviewer_service_id === reviewerServiceId &&
row.arbiter_service_id === arbiterServiceId
) {
continue;
}
update.run(
ownerServiceId,
reviewerServiceId,
arbiterServiceId,
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 = ?
WHERE id = ?
`,
);
const tx = database.transaction(
(handoffRows: StoredServiceHandoffServiceRow[]) => {
for (const row of handoffRows) {
const sourceRole =
normalizePairedRole(row.source_role) ??
normalizePairedRole(row.intended_role);
const targetRole =
normalizePairedRole(row.target_role) ??
normalizePairedRole(row.intended_role);
const sourceAgentType =
normalizeStoredAgentType(row.source_agent_type) ??
(sourceRole
? resolveStableRoomRoleAgentType(database, {
chatJid: row.chat_jid,
groupFolder: row.group_folder,
role: sourceRole,
})
: null);
const targetAgentType =
normalizeStoredAgentType(row.target_agent_type) ??
(targetRole
? resolveStableRoomRoleAgentType(database, {
chatJid: row.chat_jid,
groupFolder: row.group_folder,
role: targetRole,
})
: null) ??
'claude-code';
const sourceServiceId =
row.source_service_id ??
(sourceRole != null
? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ??
SERVICE_SESSION_SCOPE)
: SERVICE_SESSION_SCOPE);
const targetServiceId =
row.target_service_id ??
(targetRole != null
? (resolveRoleServiceShadow(targetRole, targetAgentType) ??
SERVICE_SESSION_SCOPE)
: SERVICE_SESSION_SCOPE);
if (
row.source_service_id === sourceServiceId &&
row.target_service_id === targetServiceId
) {
continue;
}
update.run(sourceServiceId, targetServiceId, 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;
hooks: SchemaMigrationHooks;
},
): void {
const { assistantName, hooks } = args;
const { assistantName } = args;
tryExecMigration(
database,
@@ -269,6 +677,14 @@ export function applySchemaMigrations(
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,
@@ -282,6 +698,14 @@ export function applySchemaMigrations(
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`,
@@ -311,6 +735,7 @@ export function applySchemaMigrations(
database,
`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`,
);
tryExecMigration(database, `ALTER TABLE work_items ADD COLUMN service_id TEXT`);
database.exec(
`UPDATE service_handoffs
@@ -333,6 +758,22 @@ export function applySchemaMigrations(
WHERE source_role IS NULL`,
);
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'
@@ -375,7 +816,7 @@ export function applySchemaMigrations(
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
hooks.backfillMessageSeq(database);
backfillMessageSeq(database);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
@@ -385,9 +826,9 @@ export function applySchemaMigrations(
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, delivery_role, status);
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(delivery_role, ''))
ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
`);
@@ -516,6 +957,8 @@ export function applySchemaMigrations(
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,
@@ -548,11 +991,18 @@ export function applySchemaMigrations(
.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,
@@ -573,12 +1023,16 @@ export function applySchemaMigrations(
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_agent_type, reviewer_agent_type,
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, owner_agent_type, reviewer_agent_type,
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;
@@ -590,17 +1044,6 @@ export function applySchemaMigrations(
}
}
for (const column of [
'owner_agent_type',
'reviewer_agent_type',
'arbiter_agent_type',
]) {
tryExecMigration(
database,
`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`,
);
}
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
@@ -663,6 +1106,8 @@ export function applySchemaMigrations(
}
migrateSessionsTableToCompositePk(database, 'claude-code');
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
dropLegacyServiceSessionsTable(database);
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
@@ -683,24 +1128,4 @@ export function applySchemaMigrations(
/* columns already exist */
}
hooks.backfillStoredRoomSettings(database);
if (tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
hooks.backfillChannelOwnerRoleMetadata(database);
}
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
if (tableHasColumn(database, 'work_items', 'service_id')) {
hooks.backfillWorkItemServiceShadows(database);
}
if (tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
hooks.backfillServiceHandoffServiceShadows(database);
}
if (tableHasColumn(database, 'paired_tasks', 'owner_service_id')) {
hooks.backfillPairedTaskRoleMetadata(database);
}
hooks.rebuildWorkItemsCanonicalSchema(database);
dropLegacyServiceSessionsTable(database);
hooks.rebuildChannelOwnerCanonicalSchema(database);
hooks.rebuildPairedTasksCanonicalSchema(database);
hooks.rebuildServiceHandoffsCanonicalSchema(database);
}