Finalize turn-role invariants and legacy migration cutover

This commit is contained in:
ejclaw
2026-04-10 11:44:34 +09:00
parent 7753ee46f0
commit c67bd1c622
45 changed files with 15014 additions and 1381 deletions

View File

@@ -178,6 +178,10 @@ export function applyBaseSchema(database: Database): void {
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,
@@ -186,7 +190,11 @@ export function applyBaseSchema(database: Database): void {
updated_at TEXT NOT NULL,
consumed_at TEXT,
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
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',
@@ -199,10 +207,89 @@ export function applyBaseSchema(database: Database): void {
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
ON paired_turn_reservations(task_id, task_updated_at, status);
CREATE TABLE IF NOT EXISTS paired_turns (
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'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
ON paired_turns(task_id, task_updated_at, updated_at);
CREATE TABLE IF NOT EXISTS paired_turn_attempts (
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(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)
);
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);
CREATE TABLE IF NOT EXISTS paired_task_execution_leases (
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,
@@ -211,6 +298,9 @@ export function applyBaseSchema(database: Database): void {
claimed_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
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 (
@@ -249,16 +339,35 @@ export function applyBaseSchema(database: Database): void {
is_main INTEGER DEFAULT 0,
owner_agent_type TEXT,
work_dir TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
);
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'))
);
CREATE TABLE IF NOT EXISTS service_handoffs (
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,
@@ -273,8 +382,21 @@ export function applyBaseSchema(database: Database): void {
claimed_at TEXT,
completed_at TEXT,
last_error TEXT,
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)
);

View File

@@ -2,27 +2,41 @@ import { Database } from 'bun:sqlite';
import fs from 'fs';
import path from 'path';
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import type { RegisteredGroup } from '../types.js';
import { readJsonFile } from '../utils.js';
import { ASSISTANT_NAME, STORE_DIR } from '../config.js';
import { applyBaseSchema } from './base-schema.js';
import { applySchemaMigrations } from './schema.js';
export interface JsonStateMigrationHooks {
setRouterState(key: string, value: string): void;
setSession(groupFolder: string, sessionId: string): void;
writeLegacyRegisteredGroupAndSyncRoomSettings(
jid: string,
group: RegisteredGroup,
): void;
function setForeignKeys(database: Database, enabled: boolean): void {
database.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`);
}
function assertNoForeignKeyViolations(database: Database): void {
const violations = database
.prepare('PRAGMA foreign_key_check')
.all() as Array<{
table: string;
rowid: number;
parent: string;
fkid: number;
}>;
if (violations.length === 0) {
return;
}
const violation = violations[0]!;
throw new Error(
`Foreign key integrity check failed for ${violation.table}(rowid=${violation.rowid}) referencing ${violation.parent} [fk=${violation.fkid}]`,
);
}
export function initializeDatabaseSchema(database: Database): void {
setForeignKeys(database, false);
applyBaseSchema(database);
applySchemaMigrations(database, {
assistantName: ASSISTANT_NAME,
});
setForeignKeys(database, true);
assertNoForeignKeyViolations(database);
}
export function openPersistentDatabase(): Database {
@@ -42,62 +56,3 @@ export function openInMemoryDatabase(): Database {
export function openDatabaseFromFile(dbPath: string): Database {
return new Database(dbPath);
}
export function migrateJsonStateFromFiles(
hooks: JsonStateMigrationHooks,
): void {
const migrateFile = (filename: string) => {
const filePath = path.join(DATA_DIR, filename);
const data = readJsonFile(filePath);
if (data === null) return null;
try {
fs.renameSync(filePath, `${filePath}.migrated`);
} catch {
/* best effort */
}
return data;
};
const routerState = migrateFile('router_state.json') as {
last_timestamp?: string;
last_agent_timestamp?: Record<string, string>;
} | null;
if (routerState) {
if (routerState.last_timestamp) {
hooks.setRouterState('last_timestamp', routerState.last_timestamp);
}
if (routerState.last_agent_timestamp) {
hooks.setRouterState(
'last_agent_timestamp',
JSON.stringify(routerState.last_agent_timestamp),
);
}
}
const sessions = migrateFile('sessions.json') as Record<
string,
string
> | null;
if (sessions) {
for (const [folder, sessionId] of Object.entries(sessions)) {
hooks.setSession(folder, sessionId);
}
}
const groups = migrateFile('registered_groups.json') as Record<
string,
RegisteredGroup
> | null;
if (groups) {
for (const [jid, group] of Object.entries(groups)) {
try {
hooks.writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group);
} catch (err) {
logger.warn(
{ jid, folder: group.folder, err },
'Skipping migrated registered group with invalid folder',
);
}
}
}
}

View File

@@ -0,0 +1,374 @@
import { ARBITER_AGENT_TYPE, SERVICE_SESSION_SCOPE } from '../config.js';
import {
inferAgentTypeFromServiceShadow,
inferRoleFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import type { AgentType, PairedRoomRole } from '../types.js';
import { normalizeStoredAgentType } from './room-registration.js';
interface RequiredRoleMetadataInput {
context: string;
role: PairedRoomRole;
storedAgentType?: string | null;
storedServiceId?: string | null;
}
interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {}
function resolveRequiredAgentType(input: RequiredRoleMetadataInput): AgentType {
const persistedAgentType = normalizeStoredAgentType(input.storedAgentType);
const inferredAgentType = inferAgentTypeFromServiceShadow(
input.storedServiceId,
);
if (
persistedAgentType &&
inferredAgentType &&
persistedAgentType !== inferredAgentType
) {
throw new Error(
`${input.context}: ${input.role}_agent_type conflicts with ${input.role}_service_id`,
);
}
const agentType = persistedAgentType ?? inferredAgentType;
if (agentType) {
return agentType;
}
throw new Error(
`${input.context}: cannot resolve ${input.role}_agent_type from stored row metadata`,
);
}
function resolveRequiredServiceId(
context: string,
role: PairedRoomRole,
agentType: AgentType,
storedServiceId?: string | null,
): string {
return (
storedServiceId ??
resolveRoleServiceShadow(role, agentType) ??
(() => {
throw new Error(
`${context}: cannot resolve ${role}_service_id from stored row metadata`,
);
})()
);
}
function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): {
agentType: AgentType | null;
serviceId: string | null;
} {
if (!input.storedAgentType && !input.storedServiceId) {
return { agentType: null, serviceId: null };
}
const agentType = resolveRequiredAgentType(input);
return {
agentType,
serviceId: resolveRequiredServiceId(
input.context,
input.role,
agentType,
input.storedServiceId,
),
};
}
export interface CanonicalPairedTaskMetadata {
ownerAgentType: AgentType;
reviewerAgentType: AgentType;
arbiterAgentType: AgentType | null;
ownerServiceId: string;
reviewerServiceId: string;
}
export function canonicalizePairedTaskMetadata(input: {
id: 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;
}): CanonicalPairedTaskMetadata {
const context = `paired_tasks(${input.id})`;
const ownerAgentType = resolveRequiredAgentType({
context,
role: 'owner',
storedAgentType: input.owner_agent_type,
storedServiceId: input.owner_service_id,
});
const reviewerAgentType = resolveRequiredAgentType({
context,
role: 'reviewer',
storedAgentType: input.reviewer_agent_type,
storedServiceId: input.reviewer_service_id,
});
return {
ownerAgentType,
reviewerAgentType,
arbiterAgentType:
normalizeStoredAgentType(input.arbiter_agent_type) ??
ARBITER_AGENT_TYPE ??
null,
ownerServiceId: resolveRequiredServiceId(
context,
'owner',
ownerAgentType,
input.owner_service_id,
),
reviewerServiceId: resolveRequiredServiceId(
context,
'reviewer',
reviewerAgentType,
input.reviewer_service_id,
),
};
}
export interface CanonicalChannelOwnerLeaseMetadata {
ownerAgentType: AgentType;
reviewerAgentType: AgentType | null;
arbiterAgentType: AgentType | null;
ownerServiceId: string;
reviewerServiceId: string | null;
arbiterServiceId: string | null;
}
export function canonicalizeChannelOwnerLeaseMetadata(input: {
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;
}): CanonicalChannelOwnerLeaseMetadata {
const context = `channel_owner(${input.chat_jid})`;
const ownerAgentType = resolveRequiredAgentType({
context,
role: 'owner',
storedAgentType: input.owner_agent_type,
storedServiceId: input.owner_service_id,
});
const reviewer = resolveOptionalRoleMetadata({
context,
role: 'reviewer',
storedAgentType: input.reviewer_agent_type,
storedServiceId: input.reviewer_service_id,
});
const arbiter = resolveOptionalRoleMetadata({
context,
role: 'arbiter',
storedAgentType: input.arbiter_agent_type,
storedServiceId: input.arbiter_service_id,
});
return {
ownerAgentType,
reviewerAgentType: reviewer.agentType,
arbiterAgentType: arbiter.agentType,
ownerServiceId: resolveRequiredServiceId(
context,
'owner',
ownerAgentType,
input.owner_service_id,
),
reviewerServiceId: reviewer.serviceId,
arbiterServiceId: arbiter.serviceId,
};
}
function normalizeHandoffRole(
role: string | null | undefined,
): PairedRoomRole | null {
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
? role
: null;
}
function assertStoredRoleMatchesServiceShadow(args: {
context: string;
storedRole: PairedRoomRole | null;
agentType: AgentType | null;
serviceId: string | null;
roleField: 'source_role' | 'target_role';
serviceField: 'source_service_id' | 'target_service_id';
}): void {
if (!args.storedRole || !args.agentType || !args.serviceId) {
return;
}
const inferredRole = inferRoleFromServiceShadow(
args.agentType,
args.serviceId,
);
if (inferredRole && inferredRole !== args.storedRole) {
throw new Error(
`${args.context}: ${args.roleField} conflicts with ${args.serviceField}`,
);
}
}
export interface CanonicalServiceHandoffMetadata {
sourceRole: PairedRoomRole | null;
targetRole: PairedRoomRole | null;
sourceAgentType: AgentType | null;
targetAgentType: AgentType;
sourceServiceId: string;
targetServiceId: string;
}
export function canonicalizeServiceHandoffMetadata(input: {
id: number | string;
chat_jid: string;
source_service_id?: string | null;
target_service_id?: string | null;
source_role?: string | null;
target_role?: string | null;
intended_role?: string | null;
source_agent_type?: string | null;
target_agent_type?: string | null;
}): CanonicalServiceHandoffMetadata {
const context = `service_handoffs(${input.id})`;
const sourceRole =
normalizeHandoffRole(input.source_role) ??
normalizeHandoffRole(input.intended_role);
const targetRole =
normalizeHandoffRole(input.target_role) ??
normalizeHandoffRole(input.intended_role);
const storedSourceAgentType = normalizeStoredAgentType(
input.source_agent_type,
);
const inferredSourceAgentType = inferAgentTypeFromServiceShadow(
input.source_service_id,
);
if (
storedSourceAgentType &&
inferredSourceAgentType &&
storedSourceAgentType !== inferredSourceAgentType
) {
throw new Error(
`${context}: source_agent_type conflicts with source_service_id`,
);
}
const sourceAgentType =
storedSourceAgentType ?? inferredSourceAgentType ?? null;
const storedTargetAgentType = normalizeStoredAgentType(
input.target_agent_type,
);
const inferredTargetAgentType = inferAgentTypeFromServiceShadow(
input.target_service_id,
);
if (
storedTargetAgentType &&
inferredTargetAgentType &&
storedTargetAgentType !== inferredTargetAgentType
) {
throw new Error(
`${context}: target_agent_type conflicts with target_service_id`,
);
}
const targetAgentType = storedTargetAgentType ?? inferredTargetAgentType;
if (!targetAgentType) {
throw new Error(
`${context}: cannot resolve target_agent_type from stored row metadata`,
);
}
const targetServiceId =
input.target_service_id ??
(targetRole != null
? resolveRoleServiceShadow(targetRole, targetAgentType)
: null);
if (!targetServiceId) {
throw new Error(
`${context}: cannot resolve target_service_id from stored row metadata`,
);
}
const sourceServiceId =
input.source_service_id ??
(sourceRole != null && sourceAgentType != null
? resolveRoleServiceShadow(sourceRole, sourceAgentType)
: null) ??
SERVICE_SESSION_SCOPE;
assertStoredRoleMatchesServiceShadow({
context,
storedRole: sourceRole,
agentType: sourceAgentType,
serviceId: sourceServiceId,
roleField: 'source_role',
serviceField: 'source_service_id',
});
assertStoredRoleMatchesServiceShadow({
context,
storedRole: targetRole,
agentType: targetAgentType,
serviceId: targetServiceId,
roleField: 'target_role',
serviceField: 'target_service_id',
});
return {
sourceRole,
targetRole,
sourceAgentType,
targetAgentType,
sourceServiceId,
targetServiceId,
};
}
interface ExecutionLeaseMetadataRow {
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;
}
export function inferExecutionLeaseServiceIdFromCanonicalMetadata(
row: ExecutionLeaseMetadataRow,
): string | null {
switch (row.role) {
case 'owner': {
const owner = resolveOptionalRoleMetadata({
context: `paired_task_execution_leases(${row.rowid})`,
role: 'owner',
storedAgentType: row.owner_agent_type,
storedServiceId: row.owner_service_id,
});
return owner.serviceId;
}
case 'reviewer': {
const reviewer = resolveOptionalRoleMetadata({
context: `paired_task_execution_leases(${row.rowid})`,
role: 'reviewer',
storedAgentType: row.reviewer_agent_type,
storedServiceId: row.reviewer_service_id,
});
return reviewer.serviceId;
}
case 'arbiter': {
const arbiter = resolveOptionalRoleMetadata({
context: `paired_task_execution_leases(${row.rowid})`,
role: 'arbiter',
storedAgentType: row.arbiter_agent_type,
storedServiceId: row.arbiter_service_id,
});
return arbiter.serviceId;
}
default:
return null;
}
}

View File

@@ -1,17 +1,7 @@
import { Database } from 'bun:sqlite';
import {
ARBITER_AGENT_TYPE,
CLAUDE_SERVICE_ID,
OWNER_AGENT_TYPE,
} from '../config.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import { AgentType } from '../types.js';
import { resolveStableReviewerAgentType } from './legacy-rebuilds.js';
import { normalizeStoredAgentType } from './room-registration.js';
import { canonicalizeChannelOwnerLeaseMetadata } from './canonical-role-metadata.js';
export interface ChannelOwnerLeaseRow {
chat_jid: string;
@@ -52,55 +42,28 @@ export interface SetChannelOwnerLeaseInput {
function hydrateChannelOwnerLeaseRow(
row: StoredChannelOwnerLeaseRow,
): ChannelOwnerLeaseRow {
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 {
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,
});
return {
chat_jid: row.chat_jid,
owner_service_id:
row.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CLAUDE_SERVICE_ID,
reviewer_service_id:
row.reviewer_service_id ??
(reviewerAgentType == null
? null
: resolveRoleServiceShadow('reviewer', reviewerAgentType)),
arbiter_service_id:
row.arbiter_service_id ??
(arbiterAgentType == null
? null
: resolveRoleServiceShadow('arbiter', arbiterAgentType)),
owner_service_id: ownerServiceId,
reviewer_service_id: reviewerServiceId,
arbiter_service_id: arbiterServiceId,
owner_agent_type: ownerAgentType,
reviewer_agent_type: reviewerAgentType,
arbiter_agent_type: arbiterAgentType,
@@ -132,41 +95,22 @@ export function setChannelOwnerLeaseInDatabase(
database: Database,
input: SetChannelOwnerLeaseInput,
): void {
const ownerAgentType =
normalizeStoredAgentType(input.owner_agent_type) ??
inferAgentTypeFromServiceShadow(input.owner_service_id) ??
OWNER_AGENT_TYPE;
const reviewerAgentType =
input.reviewer_service_id == null && input.reviewer_agent_type == null
? null
: (normalizeStoredAgentType(input.reviewer_agent_type) ??
inferAgentTypeFromServiceShadow(input.reviewer_service_id ?? null) ??
resolveStableReviewerAgentType(ownerAgentType, null));
const arbiterAgentType =
input.arbiter_service_id == null && input.arbiter_agent_type == null
? null
: (normalizeStoredAgentType(input.arbiter_agent_type) ??
inferAgentTypeFromServiceShadow(input.arbiter_service_id ?? null) ??
ARBITER_AGENT_TYPE ??
null);
const ownerServiceId =
input.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CLAUDE_SERVICE_ID;
const reviewerServiceId =
input.reviewer_service_id == null && reviewerAgentType == null
? null
: (input.reviewer_service_id ??
(reviewerAgentType == null
? null
: resolveRoleServiceShadow('reviewer', reviewerAgentType)));
const arbiterServiceId =
input.arbiter_service_id == null && arbiterAgentType == null
? null
: (input.arbiter_service_id ??
(arbiterAgentType == null
? null
: resolveRoleServiceShadow('arbiter', arbiterAgentType)));
const {
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
ownerServiceId,
reviewerServiceId,
arbiterServiceId,
} = canonicalizeChannelOwnerLeaseMetadata({
chat_jid: input.chat_jid,
owner_service_id: input.owner_service_id,
reviewer_service_id: input.reviewer_service_id,
arbiter_service_id: input.arbiter_service_id,
owner_agent_type: input.owner_agent_type,
reviewer_agent_type: input.reviewer_agent_type,
arbiter_agent_type: input.arbiter_agent_type,
});
database
.prepare(

View File

@@ -1,96 +0,0 @@
import { Database } from 'bun:sqlite';
import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
import {
collectRegisteredAgentTypes,
collectRegisteredAgentTypesForFolder,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
normalizeStoredAgentType,
} from './room-registration.js';
import type { AgentType, PairedRoomRole } from '../types.js';
interface StablePairedTaskRowInput {
chat_jid: string;
group_folder: string;
owner_agent_type?: string | null;
}
export function resolveStablePairedTaskOwnerAgentType(
database: Database,
task: StablePairedTaskRowInput,
): AgentType | undefined {
const persistedOwnerAgentType = normalizeStoredAgentType(
task.owner_agent_type,
);
if (persistedOwnerAgentType) {
return persistedOwnerAgentType;
}
const stored = getStoredRoomSettingsRowFromDatabase(database, task.chat_jid);
if (stored?.ownerAgentType) {
return stored.ownerAgentType;
}
const jidAgentTypes = collectRegisteredAgentTypes(database, task.chat_jid);
if (jidAgentTypes.length > 0) {
return inferOwnerAgentTypeFromRegisteredAgentTypes(jidAgentTypes);
}
const folderAgentTypes = collectRegisteredAgentTypesForFolder(
database,
task.group_folder,
);
if (folderAgentTypes.length > 0) {
return inferOwnerAgentTypeFromRegisteredAgentTypes(folderAgentTypes);
}
return undefined;
}
export function resolveStableReviewerAgentType(
ownerAgentType: AgentType | undefined,
fallbackReviewerAgentType?: string | null,
): AgentType | null {
const persistedReviewerAgentType = normalizeStoredAgentType(
fallbackReviewerAgentType,
);
if (persistedReviewerAgentType) {
return persistedReviewerAgentType;
}
if (ownerAgentType) {
return REVIEWER_AGENT_TYPE !== ownerAgentType
? REVIEWER_AGENT_TYPE
: ownerAgentType;
}
return null;
}
export function resolveStableRoomRoleAgentType(
database: Database,
input: {
chatJid: string;
groupFolder: string;
role: PairedRoomRole;
},
): AgentType | null | undefined {
if (input.role === 'owner') {
return resolveStablePairedTaskOwnerAgentType(database, {
chat_jid: input.chatJid,
group_folder: input.groupFolder,
owner_agent_type: null,
});
}
if (input.role === 'reviewer') {
const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, {
chat_jid: input.chatJid,
group_folder: input.groupFolder,
owner_agent_type: null,
});
return resolveStableReviewerAgentType(ownerAgentType, null);
}
return ARBITER_AGENT_TYPE ?? null;
}

View File

@@ -1,21 +1,15 @@
import { Database } from 'bun:sqlite';
import { SERVICE_ID, normalizeServiceId } from '../config.js';
import {
ARBITER_AGENT_TYPE,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
SERVICE_ID,
normalizeServiceId,
} from '../config.js';
buildPairedTurnIdentity,
resolvePairedTurnRole,
} from '../paired-turn-identity.js';
import { canonicalizePairedTaskMetadata } from './canonical-role-metadata.js';
import {
resolveStablePairedTaskOwnerAgentType,
resolveStableReviewerAgentType,
} from './legacy-rebuilds.js';
import { normalizeStoredAgentType } from './room-registration.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
ensurePairedTurnQueuedInDatabase,
markPairedTurnRunningInDatabase,
} from './paired-turns.js';
import {
AgentType,
PairedProject,
@@ -66,69 +60,31 @@ function computeExecutionLeaseExpiry(now: string): string {
).toISOString();
}
function resolveExecutionLeaseRole(
intentKind: PairedTurnReservationIntentKind,
): PairedRoomRole {
switch (intentKind) {
case 'reviewer-turn':
return 'reviewer';
case 'arbiter-turn':
return 'arbiter';
case 'owner-turn':
case 'owner-follow-up':
case 'finalize-owner-turn':
default:
return 'owner';
}
}
function hydratePairedTaskRow(
database: Database,
row: StoredPairedTaskRow,
): PairedTask {
const persistedOwnerAgentType = normalizeStoredAgentType(
row.owner_agent_type ?? null,
);
const persistedReviewerAgentType = normalizeStoredAgentType(
row.reviewer_agent_type ?? null,
);
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 arbiterAgentType =
normalizeStoredAgentType(row.arbiter_agent_type) ??
ARBITER_AGENT_TYPE ??
null;
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,
});
return {
...row,
owner_service_id:
row.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CODEX_MAIN_SERVICE_ID,
reviewer_service_id:
row.reviewer_service_id ??
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
CODEX_REVIEW_SERVICE_ID,
owner_agent_type: ownerAgentType ?? null,
reviewer_agent_type: reviewerAgentType ?? null,
owner_service_id: ownerServiceId,
reviewer_service_id: reviewerServiceId,
owner_agent_type: ownerAgentType,
reviewer_agent_type: reviewerAgentType,
arbiter_agent_type: arbiterAgentType,
};
}
@@ -176,26 +132,20 @@ export function createPairedTaskInDatabase(
database: Database,
task: PairedTask,
): void {
const ownerAgentType =
normalizeStoredAgentType(task.owner_agent_type) ??
inferAgentTypeFromServiceShadow(task.owner_service_id) ??
null;
const reviewerAgentType =
normalizeStoredAgentType(task.reviewer_agent_type) ??
inferAgentTypeFromServiceShadow(task.reviewer_service_id) ??
resolveStableReviewerAgentType(ownerAgentType ?? undefined, null);
const arbiterAgentType =
normalizeStoredAgentType(task.arbiter_agent_type) ??
ARBITER_AGENT_TYPE ??
null;
const ownerServiceId =
task.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CODEX_MAIN_SERVICE_ID;
const reviewerServiceId =
task.reviewer_service_id ??
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
CODEX_REVIEW_SERVICE_ID;
const {
ownerAgentType,
reviewerAgentType,
arbiterAgentType,
ownerServiceId,
reviewerServiceId,
} = canonicalizePairedTaskMetadata({
id: task.id,
owner_service_id: task.owner_service_id,
reviewer_service_id: task.reviewer_service_id,
owner_agent_type: task.owner_agent_type,
reviewer_agent_type: task.reviewer_agent_type,
arbiter_agent_type: task.arbiter_agent_type,
});
database
.prepare(
@@ -426,6 +376,12 @@ export function reservePairedTurnReservationInDatabase(
},
): boolean {
const now = new Date().toISOString();
const turnIdentity = buildPairedTurnIdentity({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: args.intentKind,
role: resolvePairedTurnRole(args.intentKind),
});
const result = database
.prepare(
`
@@ -435,6 +391,10 @@ export function reservePairedTurnReservationInDatabase(
task_status,
round_trip_count,
task_updated_at,
turn_id,
turn_attempt_id,
turn_attempt_no,
turn_role,
intent_kind,
status,
scheduled_run_id,
@@ -443,8 +403,32 @@ export function reservePairedTurnReservationInDatabase(
updated_at,
consumed_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?, NULL)
ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO NOTHING
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'pending', ?, NULL, ?, ?, NULL)
ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO UPDATE SET
task_status = excluded.task_status,
round_trip_count = excluded.round_trip_count,
turn_id = excluded.turn_id,
turn_attempt_id = NULL,
turn_attempt_no = NULL,
turn_role = excluded.turn_role,
status = 'pending',
scheduled_run_id = excluded.scheduled_run_id,
consumed_run_id = NULL,
updated_at = excluded.updated_at,
consumed_at = NULL
WHERE paired_turn_reservations.status = 'completed'
AND EXISTS (
SELECT 1
FROM paired_turn_attempts latest_attempt
WHERE latest_attempt.turn_id = paired_turn_reservations.turn_id
AND latest_attempt.state = 'failed'
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts newer_attempt
WHERE newer_attempt.turn_id = latest_attempt.turn_id
AND newer_attempt.attempt_no > latest_attempt.attempt_no
)
)
`,
)
.run(
@@ -453,13 +437,20 @@ export function reservePairedTurnReservationInDatabase(
args.taskStatus,
args.roundTripCount,
args.taskUpdatedAt,
turnIdentity.turnId,
turnIdentity.role,
args.intentKind,
args.runId,
now,
now,
);
return result.changes > 0;
if (result.changes > 0) {
ensurePairedTurnQueuedInDatabase(database, turnIdentity);
return true;
}
return false;
}
class PairedTurnReservationClaimError extends Error {}
@@ -479,7 +470,12 @@ export function claimPairedTurnReservationInDatabase(
const tx = database.transaction(() => {
const now = new Date().toISOString();
const expiresAt = computeExecutionLeaseExpiry(now);
const leaseRole = resolveExecutionLeaseRole(args.intentKind);
const turnIdentity = buildPairedTurnIdentity({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: args.intentKind,
role: resolvePairedTurnRole(args.intentKind),
});
const existingLease = database
.prepare(
`
@@ -501,26 +497,30 @@ export function claimPairedTurnReservationInDatabase(
const insertedLease = database
.prepare(
`
INSERT INTO paired_task_execution_leases (
task_id,
chat_jid,
role,
intent_kind,
claimed_run_id,
claimed_service_id,
INSERT INTO paired_task_execution_leases (
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
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
args.taskId,
args.chatJid,
leaseRole,
turnIdentity.role,
turnIdentity.turnId,
args.intentKind,
args.runId,
CURRENT_SERVICE_ID,
@@ -543,6 +543,9 @@ export function claimPairedTurnReservationInDatabase(
UPDATE paired_task_execution_leases
SET chat_jid = ?,
role = ?,
turn_id = ?,
turn_attempt_id = NULL,
turn_attempt_no = NULL,
intent_kind = ?,
claimed_run_id = ?,
claimed_service_id = ?,
@@ -559,7 +562,8 @@ export function claimPairedTurnReservationInDatabase(
)
.run(
args.chatJid,
leaseRole,
turnIdentity.role,
turnIdentity.turnId,
args.intentKind,
args.runId,
CURRENT_SERVICE_ID,
@@ -579,31 +583,6 @@ export function claimPairedTurnReservationInDatabase(
}
}
database
.prepare(
`
UPDATE paired_turn_reservations
SET status = 'completed',
consumed_run_id = ?,
updated_at = ?,
consumed_at = ?
WHERE chat_jid = ?
AND task_id = ?
AND task_updated_at = ?
AND intent_kind = ?
AND status = 'pending'
`,
)
.run(
args.runId,
now,
now,
args.chatJid,
args.taskId,
args.taskUpdatedAt,
args.intentKind,
);
const claimedTask = database
.prepare(
`
@@ -619,6 +598,67 @@ export function claimPairedTurnReservationInDatabase(
if (claimedTask.changes === 0) {
throw new PairedTurnReservationClaimError();
}
const currentAttempt = markPairedTurnRunningInDatabase(database, {
turnIdentity,
executorServiceId: CURRENT_SERVICE_ID,
runId: args.runId,
});
if (!currentAttempt) {
throw new Error(
`paired_turns(${turnIdentity.turnId}) did not materialize a running attempt row`,
);
}
const turnAttemptNo = currentAttempt.attempt_no;
const turnAttemptId = currentAttempt.attempt_id;
database
.prepare(
`
UPDATE paired_task_execution_leases
SET turn_attempt_id = ?,
turn_attempt_no = ?
WHERE task_id = ?
AND claimed_service_id = ?
AND claimed_run_id = ?
`,
)
.run(
turnAttemptId,
turnAttemptNo,
args.taskId,
CURRENT_SERVICE_ID,
args.runId,
);
database
.prepare(
`
UPDATE paired_turn_reservations
SET status = 'completed',
turn_attempt_id = ?,
turn_attempt_no = ?,
consumed_run_id = ?,
updated_at = ?,
consumed_at = ?
WHERE chat_jid = ?
AND task_id = ?
AND task_updated_at = ?
AND intent_kind = ?
AND status = 'pending'
`,
)
.run(
turnAttemptId,
turnAttemptNo,
args.runId,
now,
now,
args.chatJid,
args.taskId,
args.taskUpdatedAt,
args.intentKind,
);
});
try {

View File

@@ -0,0 +1,451 @@
import { Database } from 'bun:sqlite';
import { normalizeServiceId } from '../config.js';
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
import type { AgentType, PairedRoomRole } from '../types.js';
export type PairedTurnAttemptState =
| 'running'
| 'delegated'
| 'completed'
| 'failed'
| 'cancelled';
export interface PairedTurnAttemptRecord {
attempt_id: string;
parent_attempt_id: string | null;
parent_handoff_id: number | null;
continuation_handoff_id: number | null;
turn_id: string;
attempt_no: number;
task_id: string;
task_updated_at: string;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
state: PairedTurnAttemptState;
executor_service_id: string | null;
executor_agent_type: AgentType | null;
active_run_id: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
last_error: string | null;
}
function resolveExecutorMetadata(args: {
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
}): {
executorServiceId: string | null;
executorAgentType: AgentType | null;
} {
const executorServiceId = args.executorServiceId
? normalizeServiceId(args.executorServiceId)
: null;
const executorAgentType =
args.executorAgentType ??
inferAgentTypeFromServiceShadow(executorServiceId) ??
null;
return {
executorServiceId,
executorAgentType,
};
}
export function buildPairedTurnAttemptId(
turnId: string,
attemptNo: number,
): string {
return `${turnId}:attempt:${attemptNo}`;
}
export function buildPairedTurnAttemptParentId(
turnId: string,
attemptNo: number,
): string | null {
if (attemptNo <= 1) {
return null;
}
return buildPairedTurnAttemptId(turnId, attemptNo - 1);
}
function getPairedTurnAttemptParentIdFromDatabase(
database: Database,
turnId: string,
attemptNo: number,
): string | null {
if (attemptNo <= 1) {
return null;
}
const parentAttemptId = getPairedTurnAttemptIdFromDatabase(
database,
turnId,
attemptNo - 1,
);
if (parentAttemptId === null) {
throw new Error(
`paired_turn_attempts(${turnId}, attempt=${attemptNo}) must preserve contiguous parent lineage`,
);
}
return parentAttemptId;
}
function tableHasColumn(
database: Database,
tableName: string,
columnName: string,
): boolean {
const rows = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as Array<{ name: string }>;
return rows.some((row) => row.name === columnName);
}
export function syncPairedTurnAttemptInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
attemptNo: number;
state: PairedTurnAttemptState;
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
activeRunId?: string | null;
parentHandoffId?: number | null;
continuationHandoffId?: number | null;
now?: string;
error?: string | null;
},
): void {
if (args.attemptNo < 1) {
return;
}
const now = args.now ?? new Date().toISOString();
const terminal =
args.state === 'completed' ||
args.state === 'failed' ||
args.state === 'cancelled';
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
executorServiceId: args.executorServiceId,
executorAgentType: args.executorAgentType,
});
const activeRunId =
args.state === 'running' ? (args.activeRunId ?? null) : null;
database
.prepare(
`
INSERT INTO paired_turn_attempts (
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
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id, attempt_no) DO UPDATE SET
attempt_id = excluded.attempt_id,
parent_attempt_id = excluded.parent_attempt_id,
parent_handoff_id = CASE
WHEN excluded.parent_handoff_id IS NOT NULL
THEN excluded.parent_handoff_id
ELSE paired_turn_attempts.parent_handoff_id
END,
continuation_handoff_id = CASE
WHEN excluded.continuation_handoff_id IS NOT NULL
THEN excluded.continuation_handoff_id
ELSE paired_turn_attempts.continuation_handoff_id
END,
task_id = excluded.task_id,
task_updated_at = excluded.task_updated_at,
role = excluded.role,
intent_kind = excluded.intent_kind,
state = excluded.state,
executor_service_id = COALESCE(
excluded.executor_service_id,
paired_turn_attempts.executor_service_id
),
executor_agent_type = COALESCE(
excluded.executor_agent_type,
paired_turn_attempts.executor_agent_type
),
active_run_id = CASE
WHEN excluded.state = 'running'
THEN excluded.active_run_id
ELSE NULL
END,
updated_at = excluded.updated_at,
completed_at = excluded.completed_at,
last_error = excluded.last_error
`,
)
.run(
buildPairedTurnAttemptId(args.turnIdentity.turnId, args.attemptNo),
getPairedTurnAttemptParentIdFromDatabase(
database,
args.turnIdentity.turnId,
args.attemptNo,
),
args.parentHandoffId ?? null,
args.continuationHandoffId ?? null,
args.turnIdentity.turnId,
args.attemptNo,
args.turnIdentity.taskId,
args.turnIdentity.taskUpdatedAt,
args.turnIdentity.role,
args.turnIdentity.intentKind,
args.state,
executorServiceId,
executorAgentType,
activeRunId,
now,
now,
terminal ? now : null,
args.error ?? null,
);
}
export function backfillPairedTurnAttemptsFromTurns(database: Database): void {
if (
!tableHasColumn(database, 'paired_turns', 'state') ||
!tableHasColumn(database, 'paired_turns', 'attempt_no')
) {
return;
}
const rows = database
.prepare(
`
SELECT *
FROM paired_turns
WHERE attempt_no >= 1
AND state IN ('running', 'delegated', 'completed', 'failed', 'cancelled')
AND NOT EXISTS (
SELECT 1
FROM paired_turn_attempts existing_attempt
WHERE existing_attempt.turn_id = paired_turns.turn_id
AND existing_attempt.attempt_no = paired_turns.attempt_no
)
`,
)
.all() as Array<{
attempt_id: string | null;
parent_attempt_id: string | null;
turn_id: string;
task_id: string;
task_updated_at: string;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
state: PairedTurnAttemptState;
executor_service_id: string | null;
executor_agent_type: AgentType | null;
active_run_id: string | null;
attempt_no: number;
created_at: string;
updated_at: string;
completed_at: string | null;
last_error: string | null;
}>;
if (rows.length === 0) {
return;
}
const insert = database.prepare(
`
INSERT INTO paired_turn_attempts (
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
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id, attempt_no) DO NOTHING
`,
);
const tx = database.transaction(
(
attemptRows: Array<{
attempt_id?: string | null;
parent_attempt_id?: string | null;
continuation_handoff_id?: number | null;
turn_id: string;
task_id: string;
task_updated_at: string;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
state: PairedTurnAttemptState;
executor_service_id: string | null;
executor_agent_type: AgentType | null;
active_run_id: string | null;
attempt_no: number;
created_at: string;
updated_at: string;
completed_at: string | null;
last_error: string | null;
}>,
) => {
for (const row of attemptRows) {
const { executorServiceId, executorAgentType } =
resolveExecutorMetadata({
executorServiceId: row.executor_service_id,
executorAgentType: row.executor_agent_type,
});
insert.run(
row.attempt_id ??
buildPairedTurnAttemptId(row.turn_id, row.attempt_no),
row.parent_attempt_id ??
getPairedTurnAttemptParentIdFromDatabase(
database,
row.turn_id,
row.attempt_no,
),
null,
row.continuation_handoff_id ?? null,
row.turn_id,
row.attempt_no,
row.task_id,
row.task_updated_at,
row.role,
row.intent_kind,
row.state,
executorServiceId,
executorAgentType,
row.state === 'running' ? (row.active_run_id ?? null) : null,
row.created_at,
row.updated_at,
row.completed_at,
row.last_error,
);
}
},
);
tx(rows);
}
export function getPairedTurnAttemptsForTurnFromDatabase(
database: Database,
turnId: string,
): PairedTurnAttemptRecord[] {
return database
.prepare(
`
SELECT *
FROM paired_turn_attempts
WHERE turn_id = ?
ORDER BY attempt_no ASC
`,
)
.all(turnId) as PairedTurnAttemptRecord[];
}
export function getCurrentPairedTurnAttemptForTurnFromDatabase(
database: Database,
turnId: string,
): PairedTurnAttemptRecord | undefined {
return database
.prepare(
`
SELECT *
FROM paired_turn_attempts
WHERE turn_id = ?
ORDER BY attempt_no DESC
LIMIT 1
`,
)
.get(turnId) as PairedTurnAttemptRecord | undefined;
}
export function getPairedTurnAttemptByNumberFromDatabase(
database: Database,
turnId: string,
attemptNo: number,
): PairedTurnAttemptRecord | undefined {
return database
.prepare(
`
SELECT *
FROM paired_turn_attempts
WHERE turn_id = ?
AND attempt_no = ?
`,
)
.get(turnId, attemptNo) as PairedTurnAttemptRecord | undefined;
}
export function getPairedTurnAttemptIdFromDatabase(
database: Database,
turnId: string,
attemptNo: number,
): string | null {
const row = database
.prepare(
`
SELECT COALESCE(attempt_id, ? || ':attempt:' || CAST(? AS TEXT)) AS attempt_id
FROM paired_turn_attempts
WHERE turn_id = ?
AND attempt_no = ?
`,
)
.get(turnId, attemptNo, turnId, attemptNo) as
| { attempt_id: string }
| undefined;
return row?.attempt_id ?? null;
}
export function setPairedTurnAttemptContinuationHandoffIdInDatabase(
database: Database,
args: {
turnId: string;
attemptNo: number;
handoffId: number | null;
},
): void {
if (args.attemptNo < 1) {
return;
}
database
.prepare(
`
UPDATE paired_turn_attempts
SET continuation_handoff_id = ?
WHERE turn_id = ?
AND attempt_no = ?
`,
)
.run(args.handoffId, args.turnId, args.attemptNo);
}
export function clearPairedTurnAttemptsInDatabase(database: Database): void {
database.prepare('DELETE FROM paired_turn_attempts').run();
}

515
src/db/paired-turns.ts Normal file
View File

@@ -0,0 +1,515 @@
import { Database } from 'bun:sqlite';
import { normalizeServiceId } from '../config.js';
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
import type { AgentType, PairedRoomRole } from '../types.js';
import {
getPairedTurnAttemptByNumberFromDatabase,
getCurrentPairedTurnAttemptForTurnFromDatabase,
type PairedTurnAttemptRecord,
syncPairedTurnAttemptInDatabase,
} from './paired-turn-attempts.js';
export type PairedTurnState =
| 'queued'
| 'running'
| 'delegated'
| 'completed'
| 'failed'
| 'cancelled';
export interface PairedTurnRecord {
turn_id: string;
task_id: string;
task_updated_at: string;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
state: PairedTurnState;
executor_service_id: string | null;
executor_agent_type: AgentType | null;
attempt_no: number;
created_at: string;
updated_at: string;
completed_at: string | null;
last_error: string | null;
}
interface StoredPairedTurnRow {
turn_id: string;
task_id: string;
task_updated_at: string;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
created_at: string;
updated_at: string;
}
function hydratePairedTurnRecord(
row: StoredPairedTurnRow,
currentAttemptRow?: PairedTurnAttemptRecord,
): PairedTurnRecord {
if (!currentAttemptRow) {
return {
...row,
state: 'queued',
executor_service_id: null,
executor_agent_type: null,
attempt_no: 0,
completed_at: null,
last_error: null,
};
}
return {
...row,
task_id: currentAttemptRow.task_id,
task_updated_at: currentAttemptRow.task_updated_at,
role: currentAttemptRow.role,
intent_kind: currentAttemptRow.intent_kind,
state: currentAttemptRow.state,
executor_service_id: currentAttemptRow.executor_service_id,
executor_agent_type: currentAttemptRow.executor_agent_type,
attempt_no: currentAttemptRow.attempt_no,
updated_at: currentAttemptRow.updated_at,
completed_at: currentAttemptRow.completed_at,
last_error: currentAttemptRow.last_error,
};
}
function resolveExecutorMetadata(args: {
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
}): {
executorServiceId: string | null;
executorAgentType: AgentType | null;
} {
const executorServiceId = args.executorServiceId
? normalizeServiceId(args.executorServiceId)
: null;
const executorAgentType =
args.executorAgentType ??
inferAgentTypeFromServiceShadow(executorServiceId) ??
null;
return {
executorServiceId,
executorAgentType,
};
}
function getRequiredPairedTurnAttemptByNumber(
database: Database,
turnIdentity: PairedTurnIdentity,
attemptNo: number,
): PairedTurnAttemptRecord {
const currentAttemptRow = getPairedTurnAttemptByNumberFromDatabase(
database,
turnIdentity.turnId,
attemptNo,
);
if (!currentAttemptRow) {
throw new Error(
`paired_turns(${turnIdentity.turnId}) did not materialize attempt ${attemptNo}`,
);
}
return currentAttemptRow;
}
export function ensurePairedTurnQueuedInDatabase(
database: Database,
turnIdentity: PairedTurnIdentity,
): void {
const now = new Date().toISOString();
database
.prepare(
`
INSERT INTO paired_turns (
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO UPDATE SET
task_id = excluded.task_id,
task_updated_at = excluded.task_updated_at,
role = excluded.role,
intent_kind = excluded.intent_kind,
updated_at = excluded.updated_at,
created_at = paired_turns.created_at
`,
)
.run(
turnIdentity.turnId,
turnIdentity.taskId,
turnIdentity.taskUpdatedAt,
turnIdentity.role,
turnIdentity.intentKind,
now,
now,
);
}
export function markPairedTurnRunningInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
runId?: string | null;
},
): PairedTurnAttemptRecord | undefined {
const now = new Date().toISOString();
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
executorServiceId: args.executorServiceId,
executorAgentType: args.executorAgentType,
});
const runId = args.runId ?? null;
return database.transaction(() => {
const previousTurnRow = getPairedTurnByIdFromDatabase(
database,
args.turnIdentity.turnId,
);
const previousAttemptRow = previousTurnRow
? getCurrentPairedTurnAttemptForTurnFromDatabase(
database,
args.turnIdentity.turnId,
)
: undefined;
const previousAttemptNo = previousAttemptRow?.attempt_no ?? 0;
const previousAttemptActiveRunId =
previousAttemptRow?.active_run_id ?? null;
const isSameRunContinuation =
previousAttemptRow?.state === 'running' &&
previousAttemptActiveRunId !== null &&
runId !== null &&
previousAttemptActiveRunId === runId;
const isDelegatedContinuation =
previousAttemptRow?.state === 'delegated' &&
(previousAttemptRow.executor_service_id ?? '') ===
(executorServiceId ?? '') &&
(previousAttemptRow.executor_agent_type ?? '') ===
(executorAgentType ?? '');
const nextAttemptNo = previousAttemptRow
? isSameRunContinuation || isDelegatedContinuation
? Math.max(previousAttemptNo, 1)
: Math.max(previousAttemptNo, 0) + 1
: 1;
const nextAttemptParentHandoffId =
previousAttemptRow && !isSameRunContinuation && !isDelegatedContinuation
? previousAttemptRow.continuation_handoff_id
: null;
if (
previousTurnRow &&
previousTurnRow.attempt_no >= 1 &&
!previousAttemptRow
) {
throw new Error(
`paired_turns(${args.turnIdentity.turnId}) cannot derive retry lineage because attempt ${previousTurnRow.attempt_no} is missing`,
);
}
database
.prepare(
`
INSERT INTO paired_turns (
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO UPDATE SET
task_id = excluded.task_id,
task_updated_at = excluded.task_updated_at,
role = excluded.role,
intent_kind = excluded.intent_kind,
updated_at = excluded.updated_at,
created_at = paired_turns.created_at
`,
)
.run(
args.turnIdentity.turnId,
args.turnIdentity.taskId,
args.turnIdentity.taskUpdatedAt,
args.turnIdentity.role,
args.turnIdentity.intentKind,
now,
now,
);
if (
previousAttemptRow?.state === 'running' &&
previousAttemptNo >= 1 &&
previousAttemptActiveRunId &&
runId &&
previousAttemptActiveRunId !== runId
) {
syncPairedTurnAttemptInDatabase(database, {
turnIdentity: args.turnIdentity,
attemptNo: previousAttemptNo,
state: 'cancelled',
executorServiceId: previousAttemptRow.executor_service_id,
executorAgentType: previousAttemptRow.executor_agent_type,
now,
});
}
syncPairedTurnAttemptInDatabase(database, {
turnIdentity: args.turnIdentity,
attemptNo: nextAttemptNo,
state: 'running',
executorServiceId,
executorAgentType,
activeRunId: runId,
parentHandoffId: nextAttemptParentHandoffId,
});
return getRequiredPairedTurnAttemptByNumber(
database,
args.turnIdentity,
nextAttemptNo,
);
})();
}
export function markPairedTurnDelegatedInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
},
): PairedTurnAttemptRecord | undefined {
const now = new Date().toISOString();
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
executorServiceId: args.executorServiceId,
executorAgentType: args.executorAgentType,
});
return database.transaction(() => {
const previousTurnRow = getPairedTurnByIdFromDatabase(
database,
args.turnIdentity.turnId,
);
const currentAttemptRow = previousTurnRow
? getCurrentPairedTurnAttemptForTurnFromDatabase(
database,
args.turnIdentity.turnId,
)
: undefined;
if (
previousTurnRow &&
previousTurnRow.attempt_no >= 1 &&
!currentAttemptRow
) {
throw new Error(
`paired_turns(${args.turnIdentity.turnId}) cannot mark delegated because attempt ${previousTurnRow.attempt_no} is missing`,
);
}
const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1);
database
.prepare(
`
INSERT INTO paired_turns (
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO UPDATE SET
task_id = excluded.task_id,
task_updated_at = excluded.task_updated_at,
role = excluded.role,
intent_kind = excluded.intent_kind,
updated_at = excluded.updated_at,
created_at = paired_turns.created_at
`,
)
.run(
args.turnIdentity.turnId,
args.turnIdentity.taskId,
args.turnIdentity.taskUpdatedAt,
args.turnIdentity.role,
args.turnIdentity.intentKind,
now,
now,
);
syncPairedTurnAttemptInDatabase(database, {
turnIdentity: args.turnIdentity,
attemptNo: currentAttemptNo,
state: 'delegated',
executorServiceId,
executorAgentType,
now,
});
return getRequiredPairedTurnAttemptByNumber(
database,
args.turnIdentity,
currentAttemptNo,
);
})();
}
function markPairedTurnTerminalStateInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
state: 'completed' | 'failed' | 'cancelled';
error?: string | null;
},
): void {
const now = new Date().toISOString();
database.transaction(() => {
const existingTurnRow = getPairedTurnByIdFromDatabase(
database,
args.turnIdentity.turnId,
);
const currentAttemptRow = existingTurnRow
? getCurrentPairedTurnAttemptForTurnFromDatabase(
database,
args.turnIdentity.turnId,
)
: undefined;
if (
existingTurnRow &&
existingTurnRow.attempt_no >= 1 &&
!currentAttemptRow
) {
throw new Error(
`paired_turns(${args.turnIdentity.turnId}) cannot mark ${args.state} because attempt ${existingTurnRow.attempt_no} is missing`,
);
}
const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1);
database
.prepare(
`
INSERT INTO paired_turns (
turn_id,
task_id,
task_updated_at,
role,
intent_kind,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO UPDATE SET
task_id = excluded.task_id,
task_updated_at = excluded.task_updated_at,
role = excluded.role,
intent_kind = excluded.intent_kind,
updated_at = excluded.updated_at,
created_at = paired_turns.created_at
`,
)
.run(
args.turnIdentity.turnId,
args.turnIdentity.taskId,
args.turnIdentity.taskUpdatedAt,
args.turnIdentity.role,
args.turnIdentity.intentKind,
now,
now,
);
syncPairedTurnAttemptInDatabase(database, {
turnIdentity: args.turnIdentity,
attemptNo: currentAttemptNo,
state: args.state,
executorServiceId: currentAttemptRow?.executor_service_id,
executorAgentType: currentAttemptRow?.executor_agent_type,
now,
error: args.error,
});
})();
}
export function completePairedTurnInDatabase(
database: Database,
turnIdentity: PairedTurnIdentity,
): void {
markPairedTurnTerminalStateInDatabase(database, {
turnIdentity,
state: 'completed',
});
}
export function failPairedTurnInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
error?: string | null;
},
): void {
markPairedTurnTerminalStateInDatabase(database, {
turnIdentity: args.turnIdentity,
state: 'failed',
error: args.error,
});
}
export function getPairedTurnByIdFromDatabase(
database: Database,
turnId: string,
): PairedTurnRecord | undefined {
const row = database
.prepare('SELECT * FROM paired_turns WHERE turn_id = ?')
.get(turnId) as StoredPairedTurnRow | undefined;
if (!row) {
return undefined;
}
return hydratePairedTurnRecord(
row,
getCurrentPairedTurnAttemptForTurnFromDatabase(database, turnId),
);
}
export function getPairedTurnsForTaskFromDatabase(
database: Database,
taskId: string,
): PairedTurnRecord[] {
const rows = database
.prepare(
`
SELECT *
FROM paired_turns
WHERE COALESCE(
(
SELECT paired_turn_attempts.task_id
FROM paired_turn_attempts
WHERE paired_turn_attempts.turn_id = paired_turns.turn_id
ORDER BY paired_turn_attempts.attempt_no DESC
LIMIT 1
),
paired_turns.task_id
) = ?
ORDER BY created_at ASC, turn_id ASC
`,
)
.all(taskId) as StoredPairedTurnRow[];
return rows.map((row) =>
hydratePairedTurnRecord(
row,
getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
),
);
}
export function clearPairedTurnsInDatabase(database: Database): void {
database.prepare('DELETE FROM paired_turns').run();
}

View File

@@ -47,6 +47,31 @@ export interface RegisteredGroupDatabaseRow {
work_dir: string | null;
}
export interface RoomRoleOverrideSnapshot {
role: 'owner' | 'reviewer' | 'arbiter';
agentType: AgentType;
agentConfig?: RegisteredGroup['agentConfig'];
createdAt: string;
updatedAt: string;
}
interface StoredRoomRoleOverrideRow {
role: 'owner' | 'reviewer' | 'arbiter';
agentType: AgentType;
agentConfig?: RegisteredGroup['agentConfig'];
createdAt: string;
updatedAt: string;
}
export interface LegacyRoomMigrationPlan {
chatJid: string;
roomMode: RoomMode;
createdAt: string;
updatedAt: string;
snapshot: RoomRegistrationSnapshot;
roleOverrides: RoomRoleOverrideSnapshot[];
}
export function normalizeRoomModeSource(
source: string | null | undefined,
): RoomModeSource | undefined {
@@ -196,7 +221,132 @@ export function getLegacyRegisteredGroup(
return parseRegisteredGroupRow(row);
}
export function getRegisteredGroupCapabilityMetadata(
export function getPendingLegacyRegisteredGroupJids(
database: Database,
): string[] {
const rows = database
.prepare(
`SELECT DISTINCT jid
FROM registered_groups
ORDER BY jid`,
)
.all() as Array<{ jid: string }>;
return rows
.map((row) => row.jid)
.filter((jid) => jidRequiresLegacyRoomMigration(database, jid));
}
export function countPendingLegacyRegisteredGroupRows(
database: Database,
): number {
let count = 0;
const countRows = database.prepare(
`SELECT COUNT(*) AS count
FROM registered_groups
WHERE jid = ?`,
);
for (const jid of getPendingLegacyRegisteredGroupJids(database)) {
const row = countRows.get(jid) as { count: number };
count += row.count;
}
return count;
}
export function deleteLegacyRegisteredGroupRowsForJid(
database: Database,
jid: string,
): void {
database
.prepare(
`DELETE FROM registered_groups
WHERE jid = ?`,
)
.run(jid);
}
function getStoredRoomRoleOverrideRows(
database: Database,
jid: string,
): Map<'owner' | 'reviewer' | 'arbiter', StoredRoomRoleOverrideRow> {
let rows: Array<{
role: 'owner' | 'reviewer' | 'arbiter';
agent_type: string | null;
agent_config_json: string | null;
created_at: string;
updated_at: string;
}> = [];
try {
rows = database
.prepare(
`SELECT role, agent_type, agent_config_json, created_at, updated_at
FROM room_role_overrides
WHERE chat_jid = ?`,
)
.all(jid) as Array<{
role: 'owner' | 'reviewer' | 'arbiter';
agent_type: string | null;
agent_config_json: string | null;
created_at: string;
updated_at: string;
}>;
} catch {
return new Map();
}
const result = new Map<
'owner' | 'reviewer' | 'arbiter',
StoredRoomRoleOverrideRow
>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (!agentType) continue;
result.set(row.role, {
role: row.role,
agentType,
agentConfig: row.agent_config_json
? JSON.parse(row.agent_config_json)
: undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
return result;
}
function normalizeAgentConfigValue(
agentConfig: RegisteredGroup['agentConfig'] | undefined,
): string {
return JSON.stringify(agentConfig ?? null);
}
function roomRoleOverrideMatches(
actual: StoredRoomRoleOverrideRow | undefined,
expected: RoomRoleOverrideSnapshot,
): boolean {
return (
actual?.agentType === expected.agentType &&
normalizeAgentConfigValue(actual.agentConfig) ===
normalizeAgentConfigValue(expected.agentConfig)
);
}
function storedRoomSettingsMatchesLegacySnapshot(
stored: StoredRoomSettings,
snapshot: RoomRegistrationSnapshot,
): boolean {
return (
stored.name === snapshot.name &&
stored.folder === snapshot.folder &&
(stored.requiresTrigger ?? true) === snapshot.requiresTrigger &&
(stored.isMain ?? false) === snapshot.isMain &&
(stored.workDir ?? null) === (snapshot.workDir ?? null)
);
}
function getRegisteredGroupCapabilityMetadata(
database: Database,
jid: string,
preferredAgentType?: AgentType,
@@ -229,6 +379,130 @@ export function getRegisteredGroupCapabilityMetadata(
};
}
function resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
ownerAgentType: AgentType,
): AgentType | undefined {
return agentTypes.find((agentType) => agentType !== ownerAgentType);
}
export function buildLegacyRoomMigrationPlan(
database: Database,
jid: string,
): LegacyRoomMigrationPlan | undefined {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const snapshot = collectRoomRegistrationSnapshot(
database,
jid,
existingStored,
);
if (!snapshot) return undefined;
const rows = database
.prepare(
`SELECT added_at
FROM registered_groups
WHERE jid = ?
ORDER BY added_at, agent_type`,
)
.all(jid) as Array<{ added_at: string }>;
if (rows.length === 0) return undefined;
const agentTypes = collectRegisteredAgentTypes(database, jid);
const roomMode =
existingStored?.roomMode ??
inferRoomModeFromRegisteredAgentTypes(agentTypes);
const createdAt = rows[0].added_at;
const updatedAt = rows[rows.length - 1].added_at;
const roleOverrides: RoomRoleOverrideSnapshot[] = [];
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
database,
jid,
snapshot.ownerAgentType,
);
roleOverrides.push({
role: 'owner',
agentType: snapshot.ownerAgentType,
agentConfig: ownerMetadata?.agentConfig,
createdAt: ownerMetadata?.added_at ?? createdAt,
updatedAt,
});
if (roomMode === 'tribunal') {
const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes,
snapshot.ownerAgentType,
);
if (!reviewerAgentType) {
throw new Error(
`Missing reviewer agent type for tribunal legacy room ${jid}`,
);
}
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
database,
jid,
reviewerAgentType,
);
roleOverrides.push({
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig: reviewerMetadata?.agentConfig,
createdAt: reviewerMetadata?.added_at ?? createdAt,
updatedAt,
});
}
return {
chatJid: jid,
roomMode,
createdAt,
updatedAt,
snapshot,
roleOverrides,
};
}
function jidRequiresLegacyRoomMigration(
database: Database,
jid: string,
): boolean {
let stored: StoredRoomSettings | undefined;
try {
stored = getStoredRoomSettingsRowFromDatabase(database, jid);
} catch {
return true;
}
if (!stored) {
return true;
}
let plan: LegacyRoomMigrationPlan | undefined;
try {
plan = buildLegacyRoomMigrationPlan(database, jid);
} catch {
return true;
}
if (!plan) {
return false;
}
if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) {
return true;
}
const existingOverrides = getStoredRoomRoleOverrideRows(database, jid);
for (const override of plan.roleOverrides) {
if (
!roomRoleOverrideMatches(existingOverrides.get(override.role), override)
) {
return true;
}
}
return false;
}
export function getStoredRoomSettingsRowFromDatabase(
database: Database,
chatJid: string,
@@ -300,14 +574,16 @@ export function buildRegisteredGroupFromStoredSettings(
requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const ownerAgentType =
stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
const resolvedAgentType = requestedAgentType
? capabilityTypes.includes(requestedAgentType)
? requestedAgentType
: undefined
: stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
: ownerAgentType;
if (!resolvedAgentType) return undefined;
if (!stored.folder || !isValidGroupFolder(stored.folder)) {
@@ -318,18 +594,19 @@ export function buildRegisteredGroupFromStoredSettings(
return undefined;
}
const capabilityMetadata = getRegisteredGroupCapabilityMetadata(
const role: 'owner' | 'reviewer' =
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer';
const capabilityMetadata = getStoredRoomRoleOverrideRows(
database,
stored.chatJid,
resolvedAgentType,
);
).get(role);
return {
jid: stored.chatJid,
name: stored.name || stored.chatJid,
folder: stored.folder,
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
added_at: capabilityMetadata?.added_at || new Date(0).toISOString(),
added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(),
agentConfig: capabilityMetadata?.agentConfig,
requiresTrigger: stored.requiresTrigger,
isMain: stored.isMain ? true : undefined,
@@ -338,23 +615,43 @@ export function buildRegisteredGroupFromStoredSettings(
};
}
export function inferStoredRoomCapabilityTypes(
database: Database,
stored: StoredRoomSettings,
): AgentType[] {
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
const inferredTypes = new Set<AgentType>();
for (const override of overrides.values()) {
inferredTypes.add(override.agentType);
}
if (inferredTypes.size === 0 && stored.ownerAgentType) {
inferredTypes.add(stored.ownerAgentType);
}
return [...inferredTypes];
}
export function resolveStoredRoomCapabilityTypes(
database: Database,
stored: StoredRoomSettings,
): AgentType[] {
const projectedTypes = collectRegisteredAgentTypes(database, stored.chatJid);
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
const ownerAgentType =
stored.ownerAgentType ??
overrides.get('owner')?.agentType ??
OWNER_AGENT_TYPE;
if (stored.modeSource !== 'explicit') {
return projectedTypes;
return inferStoredRoomCapabilityTypes(database, stored);
}
const ownerAgentType =
stored.ownerAgentType ||
(projectedTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(projectedTypes)
: OWNER_AGENT_TYPE);
const types = new Set<AgentType>([ownerAgentType]);
if (stored.roomMode === 'tribunal') {
types.add(REVIEWER_AGENT_TYPE);
const reviewerAgentType =
overrides.get('reviewer')?.agentType ??
(ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE);
types.add(reviewerAgentType);
}
return [...types];
}
@@ -659,6 +956,7 @@ export function insertStoredRoomSettings(
source: RoomModeSource,
snapshot: RoomRegistrationSnapshot,
): void {
const now = new Date().toISOString();
database
.prepare(
`INSERT INTO room_settings (
@@ -672,8 +970,9 @@ export function insertStoredRoomSettings(
is_main,
owner_agent_type,
work_dir,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
chatJid,
@@ -686,10 +985,150 @@ export function insertStoredRoomSettings(
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
new Date().toISOString(),
now,
now,
);
}
export function insertStoredRoomSettingsFromMigration(
database: Database,
plan: LegacyRoomMigrationPlan,
): void {
database
.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
name,
folder,
trigger_pattern,
requires_trigger,
is_main,
owner_agent_type,
work_dir,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid) DO NOTHING`,
)
.run(
plan.chatJid,
plan.roomMode,
'inferred',
plan.snapshot.name,
plan.snapshot.folder,
plan.snapshot.triggerPattern,
plan.snapshot.requiresTrigger ? 1 : 0,
plan.snapshot.isMain ? 1 : 0,
plan.snapshot.ownerAgentType,
plan.snapshot.workDir,
plan.createdAt,
plan.updatedAt,
);
}
export function upsertRoomRoleOverride(
database: Database,
chatJid: string,
override: RoomRoleOverrideSnapshot,
): void {
database
.prepare(
`INSERT INTO room_role_overrides (
chat_jid,
role,
agent_type,
agent_config_json,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid, role) DO UPDATE SET
agent_type = excluded.agent_type,
agent_config_json = excluded.agent_config_json,
updated_at = excluded.updated_at`,
)
.run(
chatJid,
override.role,
override.agentType,
override.agentConfig ? JSON.stringify(override.agentConfig) : null,
override.createdAt,
override.updatedAt,
);
}
export function syncRoomRoleOverridesForRoom(
database: Database,
chatJid: string,
roomMode: RoomMode,
ownerAgentType: AgentType,
ownerAgentConfig?: RegisteredGroup['agentConfig'],
addedAt?: string,
): void {
const now = addedAt ?? new Date().toISOString();
const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid);
const existingOwnerOverride = existingOverrides.get('owner');
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
ownerAgentType,
);
upsertRoomRoleOverride(database, chatJid, {
role: 'owner',
agentType: ownerAgentType,
agentConfig:
ownerAgentConfig ??
(existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.agentConfig
: undefined) ??
ownerMetadata?.agentConfig,
createdAt:
(existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.createdAt
: undefined) ??
ownerMetadata?.added_at ??
now,
updatedAt: now,
});
if (roomMode === 'tribunal') {
const reviewerAgentType =
ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE;
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
reviewerAgentType,
);
const existingReviewerOverride = existingOverrides.get('reviewer');
upsertRoomRoleOverride(database, chatJid, {
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig:
(existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.agentConfig
: undefined) ?? reviewerMetadata?.agentConfig,
createdAt:
(existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.createdAt
: undefined) ??
reviewerMetadata?.added_at ??
now,
updatedAt: now,
});
} else {
database
.prepare(
`DELETE FROM room_role_overrides
WHERE chat_jid = ?
AND role = 'reviewer'`,
)
.run(chatJid);
}
}
export function updateStoredRoomMetadata(
database: Database,
chatJid: string,

View File

@@ -65,6 +65,23 @@ export function setRouterStateForServiceInDatabase(
.run(prefixedKey, value);
}
export function getLegacyRouterStateKeysFromDatabase(
database: Database,
): string[] {
const rows = database
.prepare(
`SELECT key
FROM router_state
WHERE key IN ('last_timestamp', 'last_agent_timestamp')
OR key LIKE '%:last_timestamp'
OR key LIKE '%:last_agent_timestamp'
ORDER BY key`,
)
.all() as Array<{ key: string }>;
return rows.map((row) => row.key);
}
export function getLastRespondingAgentTypeFromDatabase(
database: Database,
chatJid: string,

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,21 @@ import {
SERVICE_ID,
SERVICE_SESSION_SCOPE,
} from '../config.js';
import { resolveRoleServiceShadow } from '../role-service-shadow.js';
import {
buildPairedTurnIdentity,
type PairedTurnIdentity,
} from '../paired-turn-identity.js';
import { AgentType, PairedRoomRole } from '../types.js';
import { setPairedTurnAttemptContinuationHandoffIdInDatabase } from './paired-turn-attempts.js';
import {
failPairedTurnInDatabase,
markPairedTurnDelegatedInDatabase,
} from './paired-turns.js';
import {
getLatestMessageSeqAtOrBeforeFromDatabase,
normalizeSeqCursor,
} from './messages.js';
import { resolveStableRoomRoleAgentType } from './legacy-rebuilds.js';
import { normalizeStoredAgentType } from './room-registration.js';
import { canonicalizeServiceHandoffMetadata } from './canonical-role-metadata.js';
import {
getRouterStateFromDatabase,
setRouterStateInDatabase,
@@ -22,6 +29,13 @@ export interface ServiceHandoff {
id: number;
chat_jid: string;
group_folder: string;
paired_task_id?: string | null;
paired_task_updated_at?: string | null;
turn_id?: string | null;
turn_attempt_id?: string | null;
turn_attempt_no?: number | null;
turn_intent_kind?: PairedTurnIdentity['intentKind'] | null;
turn_role?: PairedRoomRole | null;
source_service_id: string;
target_service_id: string;
source_role: PairedRoomRole | null;
@@ -43,6 +57,12 @@ export interface ServiceHandoff {
export interface CreateServiceHandoffInput {
chat_jid: string;
group_folder: string;
paired_task_id?: string | null;
paired_task_updated_at?: string | null;
turn_id?: string | null;
turn_attempt_id?: string | null;
turn_intent_kind?: PairedTurnIdentity['intentKind'] | null;
turn_role?: PairedRoomRole | null;
source_service_id?: string;
target_service_id?: string;
source_role?: PairedRoomRole | null;
@@ -76,46 +96,72 @@ interface StoredServiceHandoffRow extends Omit<
target_agent_type: string;
}
function hydrateStoredTurnIdentity(
row: Pick<
StoredServiceHandoffRow,
| 'paired_task_id'
| 'paired_task_updated_at'
| 'turn_id'
| 'turn_intent_kind'
| 'turn_role'
>,
): PairedTurnIdentity | null {
if (
!row.paired_task_id ||
!row.paired_task_updated_at ||
!row.turn_intent_kind
) {
return null;
}
return buildPairedTurnIdentity({
taskId: row.paired_task_id,
taskUpdatedAt: row.paired_task_updated_at,
intentKind: row.turn_intent_kind,
role: row.turn_role ?? undefined,
turnId: row.turn_id ?? undefined,
});
}
function hydrateServiceHandoffRow(
database: Database,
row: StoredServiceHandoffRow,
): ServiceHandoff {
const sourceAgentType =
normalizeStoredAgentType(row.source_agent_type) ??
(row.source_role
? resolveStableRoomRoleAgentType(database, {
chatJid: row.chat_jid,
groupFolder: row.group_folder,
role: row.source_role,
})
: null);
const targetAgentType =
normalizeStoredAgentType(row.target_agent_type) ??
(row.target_role
? resolveStableRoomRoleAgentType(database, {
chatJid: row.chat_jid,
groupFolder: row.group_folder,
role: row.target_role,
})
: null) ??
'claude-code';
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,
});
const turnIdentity = hydrateStoredTurnIdentity(row);
return {
...row,
paired_task_id: turnIdentity?.taskId ?? row.paired_task_id ?? null,
paired_task_updated_at:
turnIdentity?.taskUpdatedAt ?? row.paired_task_updated_at ?? null,
turn_id: turnIdentity?.turnId ?? row.turn_id ?? null,
turn_attempt_id: row.turn_attempt_id ?? null,
turn_attempt_no: row.turn_attempt_no ?? null,
turn_intent_kind: turnIdentity?.intentKind ?? row.turn_intent_kind ?? null,
turn_role: turnIdentity?.role ?? row.turn_role ?? null,
source_role: sourceRole,
target_role: targetRole,
source_agent_type: sourceAgentType ?? null,
target_agent_type: targetAgentType,
source_service_id:
row.source_service_id ??
(row.source_role != null
? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ??
SERVICE_SESSION_SCOPE)
: SERVICE_SESSION_SCOPE),
target_service_id:
row.target_service_id ??
(row.target_role != null
? (resolveRoleServiceShadow(row.target_role, targetAgentType) ??
SERVICE_SESSION_SCOPE)
: SERVICE_SESSION_SCOPE),
source_service_id: sourceServiceId,
target_service_id: targetServiceId,
};
}
@@ -184,35 +230,66 @@ export function createServiceHandoffInDatabase(
database: Database,
input: CreateServiceHandoffInput,
): ServiceHandoff {
const sourceRole = input.source_role ?? input.intended_role ?? null;
const targetRole = input.target_role ?? input.intended_role ?? null;
const sourceAgentType =
normalizeStoredAgentType(input.source_agent_type) ??
(sourceRole
? resolveStableRoomRoleAgentType(database, {
chatJid: input.chat_jid,
groupFolder: input.group_folder,
role: sourceRole,
const turnIdentity =
input.paired_task_id &&
input.paired_task_updated_at &&
input.turn_intent_kind
? buildPairedTurnIdentity({
taskId: input.paired_task_id,
taskUpdatedAt: input.paired_task_updated_at,
intentKind: input.turn_intent_kind,
role: input.turn_role ?? undefined,
turnId: input.turn_id ?? undefined,
})
: null);
const sourceServiceId =
input.source_service_id ??
(sourceRole != null
? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ?? null)
: null) ??
SERVICE_SESSION_SCOPE;
const targetServiceId =
input.target_service_id ??
(targetRole != null
? (resolveRoleServiceShadow(targetRole, input.target_agent_type) ?? null)
: null) ??
SERVICE_SESSION_SCOPE;
: null;
const {
sourceRole,
targetRole,
sourceAgentType,
targetAgentType,
sourceServiceId,
targetServiceId,
} = canonicalizeServiceHandoffMetadata({
id: 'new',
chat_jid: input.chat_jid,
source_service_id: input.source_service_id,
target_service_id: input.target_service_id,
source_role: input.source_role,
target_role: input.target_role,
intended_role: input.intended_role,
source_agent_type: input.source_agent_type,
target_agent_type: input.target_agent_type,
});
const currentAttempt = turnIdentity
? markPairedTurnDelegatedInDatabase(database, {
turnIdentity,
executorServiceId: targetServiceId,
executorAgentType: targetAgentType,
})
: undefined;
if (turnIdentity) {
if (!currentAttempt) {
throw new Error(
`paired_turns(${turnIdentity.turnId}) did not materialize a delegated attempt row`,
);
}
}
const turnAttemptNo = currentAttempt?.attempt_no ?? null;
const turnAttemptId = currentAttempt?.attempt_id ?? null;
database
.prepare(
`INSERT INTO service_handoffs (
chat_jid,
group_folder,
paired_task_id,
paired_task_updated_at,
turn_id,
turn_attempt_id,
turn_attempt_no,
turn_intent_kind,
turn_role,
source_service_id,
target_service_id,
source_role,
@@ -224,17 +301,24 @@ export function createServiceHandoffInDatabase(
end_seq,
reason,
intended_role
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
input.chat_jid,
input.group_folder,
turnIdentity?.taskId ?? null,
turnIdentity?.taskUpdatedAt ?? null,
turnIdentity?.turnId ?? null,
turnAttemptId,
turnAttemptNo,
turnIdentity?.intentKind ?? null,
turnIdentity?.role ?? null,
sourceServiceId,
targetServiceId,
sourceRole,
sourceAgentType ?? null,
targetRole,
input.target_agent_type,
targetAgentType,
input.prompt,
input.start_seq ?? null,
input.end_seq ?? null,
@@ -245,8 +329,14 @@ export function createServiceHandoffInDatabase(
const lastId = (
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
).id;
if (turnIdentity && currentAttempt) {
setPairedTurnAttemptContinuationHandoffIdInDatabase(database, {
turnId: turnIdentity.turnId,
attemptNo: currentAttempt.attempt_no,
handoffId: lastId,
});
}
return hydrateServiceHandoffRow(
database,
database
.prepare('SELECT * FROM service_handoffs WHERE id = ?')
.get(lastId) as StoredServiceHandoffRow,
@@ -258,7 +348,7 @@ export function getPendingServiceHandoffsFromDatabase(
targetServiceId: string = SERVICE_SESSION_SCOPE,
): ServiceHandoff[] {
return getPendingServiceHandoffRows(database)
.map((row) => hydrateServiceHandoffRow(database, row))
.map((row) => hydrateServiceHandoffRow(row))
.filter(
(handoff) =>
normalizeServiceId(handoff.target_service_id) ===
@@ -270,7 +360,7 @@ export function getAllPendingServiceHandoffsFromDatabase(
database: Database,
): ServiceHandoff[] {
return getPendingServiceHandoffRows(database).map((row) =>
hydrateServiceHandoffRow(database, row),
hydrateServiceHandoffRow(row),
);
}
@@ -312,15 +402,36 @@ export function failServiceHandoffInDatabase(
id: number,
error: string,
): void {
database
.prepare(
`UPDATE service_handoffs
SET status = 'failed',
completed_at = datetime('now'),
last_error = ?
WHERE id = ?`,
)
.run(error, id);
database.transaction(() => {
const row = database
.prepare('SELECT * FROM service_handoffs WHERE id = ?')
.get(id) as StoredServiceHandoffRow | undefined;
let turnIdentity: PairedTurnIdentity | null = null;
if (row) {
try {
turnIdentity = hydrateStoredTurnIdentity(row);
} catch {
turnIdentity = null;
}
}
database
.prepare(
`UPDATE service_handoffs
SET status = 'failed',
completed_at = datetime('now'),
last_error = ?
WHERE id = ?`,
)
.run(error, id);
if (turnIdentity) {
failPairedTurnInDatabase(database, {
turnIdentity,
error,
});
}
})();
}
export function completeServiceHandoffAndAdvanceTargetCursorInDatabase(