Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
374
src/db/canonical-role-metadata.ts
Normal file
374
src/db/canonical-role-metadata.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
451
src/db/paired-turn-attempts.ts
Normal file
451
src/db/paired-turn-attempts.ts
Normal 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
515
src/db/paired-turns.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
2504
src/db/schema.ts
2504
src/db/schema.ts
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user