Finalize turn-role invariants and legacy migration cutover

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

File diff suppressed because it is too large Load Diff

211
src/db.ts
View File

@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite';
import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
@@ -7,6 +8,7 @@ import {
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
DATA_DIR,
normalizeServiceId,
OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE,
@@ -23,29 +25,33 @@ import { logger } from './logger.js';
import {
type RoomModeSource,
type RoomRegistrationSnapshot,
countPendingLegacyRegisteredGroupRows,
type StoredRoomSettings,
buildRegisteredGroupFromStoredSettings,
buildLegacyRoomMigrationPlan,
collectRegisteredAgentTypes,
collectRegisteredAgentTypesForFolder,
collectRoomRegistrationSnapshot,
getLegacyRegisteredGroup,
getLegacyRegisteredGroupRows,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase,
inferStoredRoomCapabilityTypes,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom,
normalizeRoomModeSource,
normalizeStoredAgentType,
parseRegisteredGroupRow,
resolveStoredRoomCapabilityTypes,
resolveAssignedRoomFolder,
syncRoomRoleOverridesForRoom,
upsertRoomRoleOverride,
updateStoredRoomMetadata,
} from './db/room-registration.js';
import {
initializeDatabaseSchema,
migrateJsonStateFromFiles,
openDatabaseFromFile,
openInMemoryDatabase,
openPersistentDatabase,
@@ -119,6 +125,20 @@ import {
upsertPairedProjectInDatabase,
upsertPairedWorkspaceInDatabase,
} from './db/paired-state.js';
import {
clearPairedTurnAttemptsInDatabase,
getPairedTurnAttemptsForTurnFromDatabase,
type PairedTurnAttemptRecord,
} from './db/paired-turn-attempts.js';
import {
clearPairedTurnsInDatabase,
completePairedTurnInDatabase,
failPairedTurnInDatabase,
getPairedTurnByIdFromDatabase,
getPairedTurnsForTaskFromDatabase,
markPairedTurnRunningInDatabase,
type PairedTurnRecord,
} from './db/paired-turns.js';
import {
getLatestTurnNumberFromDatabase,
getPairedTurnOutputsFromDatabase,
@@ -138,15 +158,12 @@ import {
} from './db/service-handoffs.js';
import {
getLastRespondingAgentTypeFromDatabase,
getLegacyRouterStateKeysFromDatabase,
getRouterStateForServiceFromDatabase,
getRouterStateFromDatabase,
setRouterStateForServiceInDatabase,
setRouterStateInDatabase,
} from './db/router-state.js';
import {
resolveStablePairedTaskOwnerAgentType,
resolveStableRoomRoleAgentType,
} from './db/legacy-rebuilds.js';
import {
deleteAllSessionsForGroupFromDatabase,
deleteSessionFromDatabase,
@@ -219,6 +236,8 @@ export type {
MemorySourceKind,
RecallMemoryQuery,
} from './db/memories.js';
export type { PairedTurnAttemptRecord } from './db/paired-turn-attempts.js';
export type { PairedTurnRecord } from './db/paired-turns.js';
export type { ChatInfo } from './db/messages.js';
export type { WorkItem } from './db/work-items.js';
export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.js';
@@ -227,24 +246,20 @@ export type { ServiceHandoff } from './db/service-handoffs.js';
export function initDatabase(): void {
db = openPersistentDatabase();
initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms();
assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyJsonStateMigration();
assertNoPendingLegacyRouterStateDbMigration();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
);
clearExpiredPairedTaskExecutionLeasesInDatabase(db);
migrateJsonStateFromFiles({
setRouterState,
setSession,
writeLegacyRegisteredGroupAndSyncRoomSettings,
});
}
/** @internal - for tests only. Creates a fresh in-memory database. */
export function _initTestDatabase(): void {
db = openInMemoryDatabase();
initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
@@ -256,7 +271,8 @@ export function _initTestDatabase(): void {
export function _initTestDatabaseFromFile(dbPath: string): void {
db = openDatabaseFromFile(dbPath);
initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms();
assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyRouterStateDbMigration();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
@@ -278,6 +294,15 @@ export function _setStoredRoomOwnerAgentTypeForTests(
updated_at = ?
WHERE chat_jid = ?`,
).run(ownerAgentType, new Date().toISOString(), chatJid);
if (ownerAgentType) {
const now = new Date().toISOString();
upsertRoomRoleOverride(db, chatJid, {
role: 'owner',
agentType: ownerAgentType,
createdAt: now,
updatedAt: now,
});
}
}
/** @internal - for tests only. */
@@ -733,7 +758,7 @@ export function getRegisteredGroup(
requestedAgentType,
);
}
return getLegacyRegisteredGroup(db, jid, agentType);
return undefined;
}
function writeLegacyRegisteredGroupAndSyncRoomSettings(
@@ -792,6 +817,14 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
syncRoomRoleOverridesForRoom(
db,
jid,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
});
tx();
}
@@ -807,6 +840,34 @@ export function _setRegisteredGroupForTests(
writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group);
}
export function _migrateLegacyRoomRegistrationsForTests(): {
migratedRooms: number;
migratedRoleOverrides: number;
} {
if (!db) {
throw new Error('Database not initialized');
}
const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(db);
let migratedRooms = 0;
let migratedRoleOverrides = 0;
db.transaction(() => {
for (const jid of jids) {
const plan = buildLegacyRoomMigrationPlan(db, jid);
if (!plan) continue;
insertStoredRoomSettingsFromMigration(db, plan);
for (const override of plan.roleOverrides) {
upsertRoomRoleOverride(db, jid, override);
migratedRoleOverrides += 1;
}
migratedRooms += 1;
}
})();
return { migratedRooms, migratedRoleOverrides };
}
export function assignRoom(
chatJid: string,
input: AssignRoomInput,
@@ -863,15 +924,15 @@ export function assignRoom(
insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot);
}
materializeRegisteredGroupsForRoom(
syncRoomRoleOverridesForRoom(
db,
chatJid,
snapshot,
roomMode,
ownerAgentType,
input.ownerAgentConfig,
input.addedAt ?? now,
);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
})();
return getRegisteredGroup(chatJid);
@@ -894,13 +955,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void {
plan.snapshot,
);
}
materializeRegisteredGroupsForRoom(
db,
jid,
plan.snapshot,
plan.roomMode,
plan.snapshot.ownerAgentType,
);
deleteLegacyRegisteredGroupRowsForJid(db, jid);
})();
}
@@ -923,15 +978,6 @@ export function getAllRegisteredGroups(
}
}
for (const legacyRow of getLegacyRegisteredGroupRows(db, agentTypeFilter)) {
if (result[legacyRow.jid]) continue;
const group = parseRegisteredGroupRow(legacyRow);
if (group) {
const { jid, ...rest } = group;
result[jid] = rest;
}
}
return result;
}
@@ -997,23 +1043,42 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
updateStoredRoomMetadata(db, chatJid, snapshot);
}
function syncLegacyRegisteredGroupsIntoStoredRooms(): void {
const rows = db
.prepare(
`SELECT DISTINCT jid
FROM registered_groups
ORDER BY jid`,
)
.all() as Array<{ jid: string }>;
if (rows.length === 0) {
function assertNoPendingLegacyRoomMigration(): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
const hasLegacyRegisteredGroupsJson = fs.existsSync(
path.join(DATA_DIR, 'registered_groups.json'),
);
if (pendingLegacyRows === 0 && !hasLegacyRegisteredGroupsJson) {
return;
}
db.transaction((registeredGroupRows: Array<{ jid: string }>) => {
for (const row of registeredGroupRows) {
syncStoredRoomRegistrationSnapshotForJid(row.jid);
}
})(rows);
throw new Error(
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows}, legacy_json=${hasLegacyRegisteredGroupsJson})`,
);
}
function assertNoPendingLegacyJsonStateMigration(): void {
const pendingFiles = ['router_state.json', 'sessions.json'].filter(
(filename) => fs.existsSync(path.join(DATA_DIR, filename)),
);
if (pendingFiles.length === 0) {
return;
}
throw new Error(
`Legacy JSON state migration required before startup (files=${pendingFiles.join(',')})`,
);
}
function assertNoPendingLegacyRouterStateDbMigration(): void {
const legacyKeys = getLegacyRouterStateKeysFromDatabase(db);
if (legacyKeys.length === 0) {
return;
}
throw new Error(
`Legacy router_state DB migration required before startup (keys=${legacyKeys.join(',')})`,
);
}
function buildRoomRegistrationSnapshotFromStoredRoom(
@@ -1103,12 +1168,17 @@ export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
upsertStoredRoomMode(chatJid, roomMode, 'explicit');
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
}
export function clearExplicitRoomMode(chatJid: string): void {
const agentTypes = collectRegisteredAgentTypes(db, chatJid);
const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid);
const agentTypes = stored
? inferStoredRoomCapabilityTypes(db, stored)
: collectRegisteredAgentTypes(db, chatJid);
if (agentTypes.length === 0) {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
return;
}
upsertStoredRoomMode(
@@ -1116,6 +1186,7 @@ export function clearExplicitRoomMode(chatJid: string): void {
inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred',
);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
}
export function getEffectiveRoomMode(chatJid: string): RoomMode {
@@ -1232,6 +1303,44 @@ export function refreshPairedTaskExecutionLease(args: {
return refreshPairedTaskExecutionLeaseInDatabase(db, args);
}
export function markPairedTurnRunning(args: {
turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity;
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
runId?: string | null;
}): void {
markPairedTurnRunningInDatabase(db, args);
}
export function completePairedTurn(
turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity,
): void {
completePairedTurnInDatabase(db, turnIdentity);
}
export function failPairedTurn(args: {
turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity;
error?: string | null;
}): void {
failPairedTurnInDatabase(db, args);
}
export function getPairedTurnById(
turnId: string,
): PairedTurnRecord | undefined {
return getPairedTurnByIdFromDatabase(db, turnId);
}
export function getPairedTurnsForTask(taskId: string): PairedTurnRecord[] {
return getPairedTurnsForTaskFromDatabase(db, taskId);
}
export function getPairedTurnAttempts(
turnId: string,
): PairedTurnAttemptRecord[] {
return getPairedTurnAttemptsForTurnFromDatabase(db, turnId);
}
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
upsertPairedWorkspaceInDatabase(db, workspace);
}
@@ -1254,6 +1363,8 @@ export function _clearPairedTurnReservationsForTests(): void {
}
clearPairedTurnReservationsInDatabase(db);
clearPairedTaskExecutionLeasesInDatabase(db);
clearPairedTurnAttemptsInDatabase(db);
clearPairedTurnsInDatabase(db);
}
/**

View File

@@ -178,6 +178,10 @@ export function applyBaseSchema(database: Database): void {
task_status TEXT NOT NULL,
round_trip_count INTEGER NOT NULL DEFAULT 0,
task_updated_at TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
scheduled_run_id TEXT,
@@ -186,7 +190,11 @@ export function applyBaseSchema(database: Database): void {
updated_at TEXT NOT NULL,
consumed_at TEXT,
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'completed')),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
@@ -199,10 +207,89 @@ export function applyBaseSchema(database: Database): void {
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
ON paired_turn_reservations(task_id, task_updated_at, status);
CREATE TABLE IF NOT EXISTS paired_turns (
turn_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
ON paired_turns(task_id, task_updated_at, updated_at);
CREATE TABLE IF NOT EXISTS paired_turn_attempts (
attempt_id TEXT NOT NULL PRIMARY KEY,
parent_attempt_id TEXT,
parent_handoff_id INTEGER,
continuation_handoff_id INTEGER,
turn_id TEXT NOT NULL,
attempt_no INTEGER NOT NULL,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
state TEXT NOT NULL,
executor_service_id TEXT,
executor_agent_type TEXT,
active_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
last_error TEXT,
UNIQUE (turn_id, attempt_no),
FOREIGN KEY (parent_attempt_id)
REFERENCES paired_turn_attempts(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (parent_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (continuation_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
),
CHECK (
state IN (
'running',
'delegated',
'completed',
'failed',
'cancelled'
)
),
CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn
ON paired_turn_attempts(turn_id, attempt_no);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task
ON paired_turn_attempts(task_id, task_updated_at, attempt_no);
CREATE TABLE IF NOT EXISTS paired_task_execution_leases (
task_id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
intent_kind TEXT NOT NULL,
claimed_run_id TEXT NOT NULL,
claimed_service_id TEXT NOT NULL,
@@ -211,6 +298,9 @@ export function applyBaseSchema(database: Database): void {
claimed_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
@@ -249,16 +339,35 @@ export function applyBaseSchema(database: Database): void {
is_main INTEGER DEFAULT 0,
owner_agent_type TEXT,
work_dir TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
);
CREATE TABLE IF NOT EXISTS room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, role),
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (agent_type IN ('claude-code', 'codex'))
);
CREATE TABLE IF NOT EXISTS service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
source_service_id TEXT NOT NULL,
target_service_id TEXT NOT NULL,
paired_task_id TEXT,
paired_task_updated_at TEXT,
turn_id TEXT,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_intent_kind TEXT,
turn_role TEXT,
source_role TEXT,
source_agent_type TEXT,
target_role TEXT,
@@ -273,8 +382,21 @@ export function applyBaseSchema(database: Database): void {
claimed_at TEXT,
completed_at TEXT,
last_error TEXT,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
CHECK (
turn_intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
) OR turn_intent_kind IS NULL
),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL),
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -168,11 +168,8 @@ const runtime = createMessageRuntime({
});
function loadState(): void {
lastTimestamp = normalizeStoredSeqCursor(
getRouterState('last_seq') || getRouterState('last_timestamp'),
);
const agentTs =
getRouterState('last_agent_seq') || getRouterState('last_agent_timestamp');
lastTimestamp = normalizeStoredSeqCursor(getRouterState('last_seq'));
const agentTs = getRouterState('last_agent_seq');
try {
const parsed = agentTs
? (JSON.parse(agentTs) as Record<string, string>)

View File

@@ -1,10 +1,13 @@
import type { AgentOutput } from './agent-runner.js';
import {
completePairedTurn,
failPairedTurn,
getLastHumanMessageSender,
getLatestTurnNumber,
getPairedTaskById,
insertPairedTurnOutput,
refreshPairedTaskExecutionLease,
releasePairedTaskExecutionLease,
} from './db.js';
import { logger } from './logger.js';
import {
@@ -13,6 +16,7 @@ import {
} from './paired-execution-context.js';
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import type { PairedRoomRole } from './types.js';
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
@@ -26,6 +30,7 @@ export interface PairedExecutionLifecycle {
}): void;
recordFinalOutputBeforeDelivery(outputText: string): void;
completeImmediately(args: { status: 'succeeded' | 'failed' }): void;
markDelegated(): void;
markStatus(status: 'succeeded' | 'failed'): void;
markSawOutput(sawOutput: boolean): void;
getSummary(): string | null;
@@ -34,6 +39,7 @@ export interface PairedExecutionLifecycle {
export function createPairedExecutionLifecycle(args: {
pairedExecutionContext?: PreparedPairedExecutionContext;
pairedTurnIdentity?: PairedTurnIdentity;
completedRole: PairedRoomRole;
chatJid: string;
runId: string;
@@ -43,6 +49,7 @@ export function createPairedExecutionLifecycle(args: {
}): PairedExecutionLifecycle {
const {
pairedExecutionContext,
pairedTurnIdentity,
completedRole,
chatJid,
runId,
@@ -55,10 +62,30 @@ export function createPairedExecutionLifecycle(args: {
let pairedExecutionSummary: string | null = null;
let pairedFinalOutput: string | null = null;
let pairedExecutionCompleted = false;
let pairedExecutionDelegated = false;
let pairedSawOutput = false;
let pairedTurnOutputPersisted = false;
let pairedTurnStateFinalized = false;
let leaseHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
const finalizePairedTurnState = (
status: 'succeeded' | 'failed',
errorText?: string | null,
) => {
if (!pairedTurnIdentity || pairedTurnStateFinalized) {
return;
}
if (status === 'succeeded') {
completePairedTurn(pairedTurnIdentity);
} else {
failPairedTurn({
turnIdentity: pairedTurnIdentity,
error: errorText ?? pairedExecutionSummary,
});
}
pairedTurnStateFinalized = true;
};
const clearLeaseHeartbeat = () => {
if (!leaseHeartbeatTimer) {
return;
@@ -188,6 +215,10 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionCompleted = true;
},
markDelegated() {
pairedExecutionDelegated = true;
},
markStatus(status) {
pairedExecutionStatus = status;
},
@@ -203,14 +234,34 @@ export function createPairedExecutionLifecycle(args: {
async asyncFinalize() {
clearLeaseHeartbeat();
if (pairedExecutionContext && !pairedExecutionCompleted) {
const effectiveStatus =
completedRole === 'owner' &&
pairedExecutionStatus === 'succeeded' &&
!pairedSawOutput
? 'failed'
: pairedExecutionStatus;
if (pairedExecutionContext && pairedExecutionDelegated) {
try {
releasePairedTaskExecutionLease({
taskId: pairedExecutionContext.task.id,
runId,
});
} catch (err) {
log.warn(
{
pairedTaskId: pairedExecutionContext.task.id,
runId,
err,
},
'Failed to release paired execution lease for delegated fallback handoff',
);
}
pairedExecutionCompleted = true;
return;
}
const effectiveStatus =
completedRole === 'owner' &&
pairedExecutionStatus === 'succeeded' &&
!pairedSawOutput
? 'failed'
: pairedExecutionStatus;
if (pairedExecutionContext && !pairedExecutionCompleted) {
if (effectiveStatus === 'succeeded') {
try {
persistPairedTurnOutputIfNeeded();
@@ -232,6 +283,8 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionCompleted = true;
}
finalizePairedTurnState(effectiveStatus);
if (!pairedExecutionContext) {
return;
}
@@ -260,7 +313,7 @@ export function createPairedExecutionLifecycle(args: {
const queueAction = resolvePairedFollowUpQueueAction({
completedRole,
executionStatus: pairedExecutionStatus,
executionStatus: effectiveStatus,
sawOutput: pairedSawOutput,
taskStatus: finishedTask?.status ?? null,
});
@@ -274,7 +327,7 @@ export function createPairedExecutionLifecycle(args: {
task: finishedTask,
source: 'executor-recovery',
completedRole,
executionStatus: pairedExecutionStatus,
executionStatus: effectiveStatus,
sawOutput: pairedSawOutput,
fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null,
enqueueMessageCheck,
@@ -286,7 +339,7 @@ export function createPairedExecutionLifecycle(args: {
{
taskId: pairedExecutionContext.task.id,
role: completedRole,
pairedExecutionStatus,
pairedExecutionStatus: effectiveStatus,
taskStatus: finishedTask.status,
intentKind: followUpResult.intentKind,
scheduled: followUpResult.scheduled,

View File

@@ -60,7 +60,9 @@ vi.mock('./db.js', () => {
[args.chatJid, args.taskId, args.taskUpdatedAt, args.intentKind].join(':');
return {
completePairedTurn: vi.fn(),
createServiceHandoff: vi.fn(),
failPairedTurn: vi.fn(),
getAllTasks: vi.fn(() => []),
getLastHumanMessageSender: vi.fn(() => null),
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
@@ -68,7 +70,9 @@ vi.mock('./db.js', () => {
getPairedTaskById: vi.fn(() => undefined),
getPairedTurnOutputs: vi.fn(() => []),
insertPairedTurnOutput: vi.fn(),
markPairedTurnRunning: vi.fn(),
refreshPairedTaskExecutionLease: vi.fn(() => true),
releasePairedTaskExecutionLease: vi.fn(),
reservePairedTurnReservation: vi.fn((args) => {
const key = buildReservationKey(args);
if (pairedTurnReservations.has(key)) {
@@ -680,10 +684,169 @@ describe('runAgentForGroup room memory', () => {
expect.any(Object),
expect.any(Function),
expect.any(Function),
{},
expect.objectContaining({
EJCLAW_PAIRED_TURN_ID:
'paired-task-reviewer-failover-model:2026-03-31T00:00:00.000Z:reviewer-turn',
EJCLAW_PAIRED_TURN_ROLE: 'reviewer',
EJCLAW_PAIRED_TURN_INTENT: 'reviewer-turn',
}),
);
});
it('fails closed when a persisted paired turn revision mismatches the prepared execution context', async () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-stale-revision',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
updated_at: '2026-04-10T01:00:00.000Z',
},
workspace: null,
envOverrides: {},
});
await expect(
runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-stale-paired-turn-revision',
pairedTurnIdentity: {
turnId:
'paired-task-stale-revision:2026-04-10T00:00:00.000Z:owner-follow-up',
taskId: 'paired-task-stale-revision',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
},
}),
).rejects.toThrow(
/task_updated_at does not match the prepared execution context/,
);
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
});
it('fails closed when a persisted paired turn revision mismatches the latest paired task fallback', async () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue(undefined);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'paired-task-stale-fallback',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
updated_at: '2026-04-10T01:00:00.000Z',
});
await expect(
runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-stale-paired-turn-fallback',
pairedTurnIdentity: {
turnId:
'paired-task-stale-fallback:2026-04-10T00:00:00.000Z:owner-follow-up',
taskId: 'paired-task-stale-fallback',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
},
}),
).rejects.toThrow(/task_updated_at does not match the latest paired task/);
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
});
it('persists logical turn state transitions while a paired turn runs successfully', async () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-stateful-owner',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
updated_at: '2026-04-10T00:00:00.000Z',
},
workspace: null,
envOverrides: {},
});
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-paired-turn-stateful-owner',
});
expect(result).toBe('success');
expect(db.markPairedTurnRunning).toHaveBeenCalledWith({
turnIdentity: {
turnId:
'paired-task-stateful-owner:2026-04-10T00:00:00.000Z:owner-follow-up',
taskId: 'paired-task-stateful-owner',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
},
executorServiceId: 'claude',
executorAgentType: 'claude-code',
runId: 'run-paired-turn-stateful-owner',
});
expect(db.completePairedTurn).toHaveBeenCalledWith({
turnId:
'paired-task-stateful-owner:2026-04-10T00:00:00.000Z:owner-follow-up',
taskId: 'paired-task-stateful-owner',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
});
});
it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
@@ -2615,6 +2778,109 @@ describe('runAgentForGroup Claude rotation', () => {
);
});
it('does not enqueue generic paired reviewer recovery after delegating to a fallback handoff', async () => {
vi.mocked(
sessionRecovery.shouldRetryFreshSessionOnAgentFailure,
).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-reviewer-handoff-delegated',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 1,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
},
workspace: null,
envOverrides: {},
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'paired-task-reviewer-handoff-delegated',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 1,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: null,
});
return {
status: 'success',
result: null,
};
},
);
const deps = makeDeps();
const result = await runAgentForGroup(deps, {
group: makeGroup(),
prompt: 'please review',
chatJid: 'group@test',
runId: 'run-reviewer-delegated-handoff',
forcedRole: 'reviewer',
onOutput: async () => {},
});
expect(result).toBe('success');
expect(db.createServiceHandoff).toHaveBeenCalledWith(
expect.objectContaining({
chat_jid: 'group@test',
paired_task_id: 'paired-task-reviewer-handoff-delegated',
paired_task_updated_at: '2026-03-31T00:00:00.000Z',
turn_id:
'paired-task-reviewer-handoff-delegated:2026-03-31T00:00:00.000Z:reviewer-turn',
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_role: 'reviewer',
target_role: 'reviewer',
intended_role: 'reviewer',
}),
);
expect(
pairedExecutionContext.completePairedExecutionContext,
).not.toHaveBeenCalled();
expect(deps.queue.enqueueMessageCheck).not.toHaveBeenCalled();
});
it('drops a stale Claude session id before retrying a fresh session', async () => {
const deps = {
...makeDeps(),

View File

@@ -22,6 +22,7 @@ import {
createServiceHandoff,
getAllTasks,
getLatestOpenPairedTaskForChat,
markPairedTurnRunning,
} from './db.js';
import { GroupQueue } from './group-queue.js';
import { createScopedLogger } from './logger.js';
@@ -29,6 +30,10 @@ import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
import { preparePairedExecutionContext } from './paired-execution-context.js';
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
import {
resolveRuntimePairedTurnIdentity,
type PairedTurnIdentity,
} from './paired-turn-identity.js';
import { resolveExecutionTarget } from './message-runtime-rules.js';
import { buildRoomRoleContext } from './room-role-context.js';
import { type AgentTriggerReason } from './agent-error-detection.js';
@@ -82,6 +87,7 @@ export async function runAgentForGroup(
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
onOutput?: (output: AgentOutput) => Promise<void>;
},
): Promise<'success' | 'error'> {
@@ -189,6 +195,75 @@ export async function runAgentForGroup(
roomRoleContext,
hasHumanMessage: args.hasHumanMessage,
});
const runtimePairedTurnIdentity =
args.pairedTurnIdentity ??
(pairedExecutionContext
? resolveRuntimePairedTurnIdentity({
taskId: pairedExecutionContext.task.id,
taskUpdatedAt: pairedExecutionContext.task.updated_at,
role: activeRole,
taskStatus: pairedExecutionContext.task.status,
hasHumanMessage: args.hasHumanMessage,
})
: pairedTask
? resolveRuntimePairedTurnIdentity({
taskId: pairedTask.id,
taskUpdatedAt: pairedTask.updated_at,
role: activeRole,
taskStatus: pairedTask.status,
hasHumanMessage: args.hasHumanMessage,
})
: undefined);
if (runtimePairedTurnIdentity) {
if (runtimePairedTurnIdentity.role !== activeRole) {
throw new Error(
`Paired turn ${runtimePairedTurnIdentity.turnId} cannot execute as ${activeRole}`,
);
}
if (
pairedExecutionContext &&
runtimePairedTurnIdentity.taskId !== pairedExecutionContext.task.id
) {
throw new Error(
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the prepared execution context`,
);
}
if (
pairedExecutionContext &&
runtimePairedTurnIdentity.taskUpdatedAt !==
pairedExecutionContext.task.updated_at
) {
throw new Error(
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the prepared execution context`,
);
}
if (
!pairedExecutionContext &&
pairedTask &&
runtimePairedTurnIdentity.taskId !== pairedTask.id
) {
throw new Error(
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the latest paired task`,
);
}
if (
!pairedExecutionContext &&
pairedTask &&
runtimePairedTurnIdentity.taskUpdatedAt !== pairedTask.updated_at
) {
throw new Error(
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the latest paired task`,
);
}
}
if (runtimePairedTurnIdentity) {
markPairedTurnRunning({
turnIdentity: runtimePairedTurnIdentity,
executorServiceId: effectiveServiceId,
executorAgentType: effectiveAgentType,
runId,
});
}
// Forced fallbacks run under a different agent runtime, so keep the
// fallback session on its default model/effort unless explicitly configured
// for that runtime elsewhere.
@@ -203,6 +278,16 @@ export async function runAgentForGroup(
pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort;
}
}
if (pairedExecutionContext && runtimePairedTurnIdentity) {
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ID =
runtimePairedTurnIdentity.turnId;
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ROLE =
runtimePairedTurnIdentity.role;
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_INTENT =
runtimePairedTurnIdentity.intentKind;
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TASK_UPDATED_AT =
runtimePairedTurnIdentity.taskUpdatedAt;
}
const log = createScopedLogger({
chatJid,
@@ -213,6 +298,7 @@ export async function runAgentForGroup(
messageSeqEnd: endSeq ?? undefined,
role: activeRole,
serviceId: effectiveServiceId,
turnId: runtimePairedTurnIdentity?.turnId,
});
log.info(
{
@@ -306,6 +392,7 @@ export async function runAgentForGroup(
const effectivePrompt = moaEnrichedPrompt;
const pairedExecutionLifecycle = createPairedExecutionLifecycle({
pairedExecutionContext,
pairedTurnIdentity: runtimePairedTurnIdentity,
completedRole: roomRoleContext?.role ?? 'owner',
chatJid,
runId,
@@ -349,8 +436,14 @@ export async function runAgentForGroup(
createServiceHandoff({
chat_jid: chatJid,
group_folder: group.folder,
paired_task_id: runtimePairedTurnIdentity?.taskId,
paired_task_updated_at: runtimePairedTurnIdentity?.taskUpdatedAt,
turn_id: runtimePairedTurnIdentity?.turnId,
turn_intent_kind: runtimePairedTurnIdentity?.intentKind,
turn_role: runtimePairedTurnIdentity?.role,
...handoffResolution.plan.handoff,
});
pairedExecutionLifecycle.markDelegated();
log.warn({ reason }, handoffResolution.plan.logMessage);
return true;
};

View File

@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { _initTestDatabase } from './db.js';
import { executeBotOnlyPairedFollowUpAction } from './message-runtime-flow.js';
import {
executeBotOnlyPairedFollowUpAction,
executePendingPairedTurn,
} from './message-runtime-flow.js';
import {
resetPairedFollowUpScheduleState,
schedulePairedFollowUpOnce,
@@ -121,3 +124,56 @@ describe('executeBotOnlyPairedFollowUpAction', () => {
);
});
});
describe('executePendingPairedTurn', () => {
it('passes the explicit pending role through as forcedRole', async () => {
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
const result = await executePendingPairedTurn({
pendingTurn: {
prompt: 'pending owner follow-up',
channel: {} as any,
cursor: null,
taskId: 'task-pending-owner-follow-up',
taskUpdatedAt: '2026-03-30T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
},
chatJid: 'group@test',
group: {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2026-03-30T00:00:00.000Z',
requiresTrigger: false,
agentType: 'codex',
},
runId: 'run-pending-owner-forced-role',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
saveState: vi.fn(),
lastAgentTimestamps: {},
executeTurn,
getFixedRoleChannelName: () => 'discord-review',
});
expect(result).toBe(true);
expect(executeTurn).toHaveBeenCalledWith(
expect.objectContaining({
deliveryRole: 'owner',
forcedRole: 'owner',
pairedTurnIdentity: {
turnId:
'task-pending-owner-follow-up:2026-03-30T00:00:00.000Z:owner-follow-up',
taskId: 'task-pending-owner-follow-up',
taskUpdatedAt: '2026-03-30T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
},
}),
);
});
});

View File

@@ -20,6 +20,8 @@ import {
resolveCursorKey,
resolveNextTurnAction,
} from './message-runtime-rules.js';
import type { ExecuteTurnFn } from './message-runtime-types.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { hasReviewerLease } from './service-routing.js';
import type {
@@ -35,9 +37,11 @@ export type PendingPairedTurn = {
prompt: string;
channel: Channel | null;
cursor: string | number | null;
taskId: string;
taskUpdatedAt: string;
intentKind: PairedTurnReservationIntentKind;
cursorKey?: string;
role?: 'reviewer' | 'arbiter';
role: PairedRoomRole;
} | null;
export type BotOnlyPairedFollowUpAction =
@@ -127,6 +131,8 @@ export function buildPendingPairedTurn(args: {
}),
channel: resolveChannel(taskStatus),
cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'reviewer-turn',
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'reviewer',
@@ -145,6 +151,8 @@ export function buildPendingPairedTurn(args: {
}),
channel: resolveChannel(taskStatus),
cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'arbiter-turn',
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'arbiter',
@@ -156,7 +164,10 @@ export function buildPendingPairedTurn(args: {
prompt: buildFinalizePendingPrompt({ turnOutputs }),
channel: resolveChannel(taskStatus),
cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'finalize-owner-turn',
role: 'owner',
};
}
@@ -171,7 +182,10 @@ export function buildPendingPairedTurn(args: {
}),
channel: resolveChannel(taskStatus),
cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'owner-follow-up',
role: 'owner',
};
}
@@ -186,16 +200,7 @@ export async function executePendingPairedTurn(args: {
log: typeof logger;
saveState: () => void;
lastAgentTimestamps: Record<string, string>;
executeTurn: (args: {
group: RegisteredGroup;
prompt: string;
chatJid: string;
runId: string;
channel: Channel;
startSeq: number | null;
endSeq: number | null;
deliveryRole?: PairedRoomRole;
}) => Promise<{ deliverySucceeded: boolean }>;
executeTurn: ExecuteTurnFn;
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
}): Promise<boolean> {
const {
@@ -211,7 +216,17 @@ export async function executePendingPairedTurn(args: {
} = args;
if (!pendingTurn.channel) {
const missingRole = pendingTurn.role ?? 'reviewer';
if (pendingTurn.role === 'owner') {
log.error(
{
role: 'owner',
},
'Skipping paired turn because the owner channel is not available',
);
return false;
}
const missingRole = pendingTurn.role;
log.error(
{
role: missingRole,
@@ -239,6 +254,13 @@ export async function executePendingPairedTurn(args: {
runId,
channel: pendingTurn.channel,
deliveryRole: pendingTurn.role,
forcedRole: pendingTurn.role,
pairedTurnIdentity: buildPairedTurnIdentity({
taskId: pendingTurn.taskId,
taskUpdatedAt: pendingTurn.taskUpdatedAt,
intentKind: pendingTurn.intentKind,
role: pendingTurn.role,
}),
startSeq: null,
endSeq: null,
});
@@ -321,6 +343,8 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
chatJid: string;
runId: string;
channel: Channel;
forcedRole?: PairedRoomRole;
pairedTurnIdentity?: ReturnType<typeof buildPairedTurnIdentity>;
startSeq: number | null;
endSeq: number | null;
}) => Promise<{ deliverySucceeded: boolean }>;
@@ -395,6 +419,13 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
chatJid,
runId,
channel,
forcedRole: 'owner',
pairedTurnIdentity: buildPairedTurnIdentity({
taskId: action.task.id,
taskUpdatedAt: action.task.updated_at,
intentKind: 'finalize-owner-turn',
role: 'owner',
}),
startSeq: null,
endSeq: null,
});

View File

@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
SERVICE_SESSION_SCOPE: 'codex-main',
}));
vi.mock('./db.js', () => ({
claimServiceHandoff: vi.fn(() => true),
claimPairedTurnReservation: vi.fn(() => true),
completeServiceHandoffAndAdvanceTargetCursor: vi.fn(() => null),
failServiceHandoff: vi.fn(),
getPairedTurnOutputs: vi.fn(() => []),
getPendingServiceHandoffs: vi.fn(() => []),
reservePairedTurnReservation: vi.fn(() => true),
_clearPairedTurnReservationsForTests: vi.fn(),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import * as db from './db.js';
import {
enqueuePendingHandoffs,
processClaimedHandoff,
} from './message-runtime-handoffs.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
return {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2026-04-10T00:00:00.000Z',
requiresTrigger: false,
agentType: 'codex',
};
}
function makeChannel(name: string, ownsChatJid = false): Channel {
return {
name,
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn(() => true),
ownsJid: vi.fn((jid: string) => ownsChatJid && jid === 'group@test'),
sendMessage: vi.fn(),
} as unknown as Channel;
}
describe('message-runtime-handoffs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not expose reviewer-targeted handoffs to the codex-main poller', () => {
vi.mocked(db.getPendingServiceHandoffs).mockImplementation(
(targetServiceId?: string) =>
targetServiceId === 'codex-main'
? []
: ([
{
id: 1,
chat_jid: 'group@test',
group_folder: 'test-group',
source_service_id: 'claude',
target_service_id: 'codex-review',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: 'reviewer',
target_agent_type: 'codex',
prompt: 'review retry',
status: 'pending',
start_seq: 1,
end_seq: 2,
reason: 'reviewer-auth-failure',
intended_role: 'reviewer',
created_at: '2026-04-10T00:00:00.000Z',
claimed_at: null,
completed_at: null,
last_error: null,
},
] as any),
);
const enqueueTask = vi.fn();
enqueuePendingHandoffs({
enqueueTask,
processClaimedHandoff: vi.fn(),
});
expect(db.getPendingServiceHandoffs).toHaveBeenCalledWith('codex-main');
expect(db.claimServiceHandoff).not.toHaveBeenCalled();
expect(enqueueTask).not.toHaveBeenCalled();
});
it('fails a claimed handoff closed when its intended role cannot be resolved', async () => {
const executeTurn = vi.fn();
await processClaimedHandoff({
handoff: {
id: 7,
chat_jid: 'group@test',
group_folder: 'test-group',
source_service_id: 'claude',
target_service_id: 'codex-main',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: null,
target_agent_type: 'codex',
prompt: 'review retry',
status: 'claimed',
start_seq: 3,
end_seq: 4,
reason: 'unknown-failure',
intended_role: null,
created_at: '2026-04-10T00:00:00.000Z',
claimed_at: '2026-04-10T00:00:01.000Z',
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
'group@test': makeGroup(),
}),
channels: [makeChannel('discord-main', true)],
executeTurn,
lastAgentTimestamps: {},
saveState: vi.fn(),
});
expect(db.failServiceHandoff).toHaveBeenCalledWith(
7,
'Cannot resolve intended handoff role',
);
expect(executeTurn).not.toHaveBeenCalled();
});
it('executes a reviewer handoff with a fixed reviewer role and channel', async () => {
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
await processClaimedHandoff({
handoff: {
id: 9,
chat_jid: 'group@test',
group_folder: 'test-group',
paired_task_id: 'task-reviewer-handoff',
paired_task_updated_at: '2026-04-10T00:00:00.000Z',
turn_id: 'task-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn',
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_service_id: 'claude',
target_service_id: 'codex-review',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: 'reviewer',
target_agent_type: 'codex',
prompt: 'review retry',
status: 'claimed',
start_seq: 5,
end_seq: 6,
reason: 'reviewer-auth-failure',
intended_role: 'reviewer',
created_at: '2026-04-10T00:00:00.000Z',
claimed_at: '2026-04-10T00:00:01.000Z',
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
'group@test': makeGroup(),
}),
channels: [
makeChannel('discord-main', true),
makeChannel('discord-review'),
],
executeTurn,
lastAgentTimestamps: {},
saveState: vi.fn(),
});
expect(executeTurn).toHaveBeenCalledWith(
expect.objectContaining({
chatJid: 'group@test',
forcedRole: 'reviewer',
forcedAgentType: 'codex',
pairedTurnIdentity: {
turnId:
'task-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn',
taskId: 'task-reviewer-handoff',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
channel: expect.objectContaining({
name: 'discord-review',
}),
}),
);
expect(db.failServiceHandoff).not.toHaveBeenCalled();
});
it('recreates a pending reviewer retry after a claimed handoff delivery failure', async () => {
const executeTurn = vi.fn(async () => ({
outputStatus: 'error' as const,
deliverySucceeded: false,
visiblePhase: 'final',
}));
const enqueueMessageCheck = vi.fn();
const getPairedTaskById = vi.fn(() => ({
id: 'task-reviewer-handoff-retry',
status: 'review_ready' as const,
round_trip_count: 1,
updated_at: '2026-04-10T00:00:00.000Z',
}));
await processClaimedHandoff({
handoff: {
id: 11,
chat_jid: 'group@test',
group_folder: 'test-group',
paired_task_id: 'task-reviewer-handoff-retry',
paired_task_updated_at: '2026-04-10T00:00:00.000Z',
turn_id:
'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn',
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_service_id: 'claude',
target_service_id: 'codex-review',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: 'reviewer',
target_agent_type: 'codex',
prompt: 'review retry after claimed handoff failure',
status: 'claimed',
start_seq: 7,
end_seq: 8,
reason: 'reviewer-auth-failure',
intended_role: 'reviewer',
created_at: '2026-04-10T00:00:00.000Z',
claimed_at: '2026-04-10T00:00:01.000Z',
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
'group@test': makeGroup(),
}),
channels: [
makeChannel('discord-main', true),
makeChannel('discord-review'),
],
executeTurn,
lastAgentTimestamps: {},
saveState: vi.fn(),
getPairedTaskById,
enqueueMessageCheck,
});
expect(db.failServiceHandoff).toHaveBeenCalledWith(
11,
'Handoff delivery failed',
);
expect(getPairedTaskById).toHaveBeenCalledWith(
'task-reviewer-handoff-retry',
);
expect(db.reservePairedTurnReservation).toHaveBeenCalledWith(
expect.objectContaining({
chatJid: 'group@test',
taskId: 'task-reviewer-handoff-retry',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'reviewer-turn',
}),
);
expect(enqueueMessageCheck).toHaveBeenCalledWith('group@test');
});
});

View File

@@ -1,21 +1,25 @@
import { SERVICE_SESSION_SCOPE } from './config.js';
import { getErrorMessage } from './utils.js';
import {
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
failServiceHandoff,
getAllPendingServiceHandoffs,
getPendingServiceHandoffs,
type ServiceHandoff,
} from './db.js';
import { findChannel, findChannelByName } from './router.js';
import { logger } from './logger.js';
import { schedulePairedFollowUpWithMessageCheck } from './message-runtime-follow-up.js';
import {
getFixedRoleChannelName,
getMissingRoleChannelMessage,
resolveHandoffCursorKey,
resolveHandoffRoleOverride,
} from './message-runtime-shared.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import type { ExecuteTurnFn } from './message-runtime-types.js';
import type { Channel, RegisteredGroup } from './types.js';
import type { Channel, PairedTask, RegisteredGroup } from './types.js';
export function enqueuePendingHandoffs(args: {
enqueueTask: (
@@ -25,7 +29,7 @@ export function enqueuePendingHandoffs(args: {
) => void;
processClaimedHandoff: (handoff: ServiceHandoff) => Promise<void>;
}): void {
for (const handoff of getAllPendingServiceHandoffs()) {
for (const handoff of getPendingServiceHandoffs(SERVICE_SESSION_SCOPE)) {
if (!claimServiceHandoff(handoff.id)) {
continue;
}
@@ -36,6 +40,124 @@ export function enqueuePendingHandoffs(args: {
}
}
function requeueFailedClaimedPairedTurn(args: {
handoff: ServiceHandoff;
error: string;
getPairedTaskById?:
| ((
id: string,
) =>
| Pick<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| undefined)
| undefined;
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): void {
const { handoff, error, getPairedTaskById, enqueueMessageCheck } = args;
if (
!handoff.paired_task_id ||
!handoff.paired_task_updated_at ||
!handoff.turn_intent_kind ||
!getPairedTaskById ||
!enqueueMessageCheck
) {
return;
}
const task = getPairedTaskById(handoff.paired_task_id);
if (!task) {
logger.warn(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
taskId: handoff.paired_task_id,
error,
},
'Skipped paired turn retry after claimed service handoff failure because the paired task no longer exists',
);
return;
}
if (!isScheduledPairedFollowUpIntentKind(handoff.turn_intent_kind)) {
logger.warn(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
taskId: handoff.paired_task_id,
intentKind: handoff.turn_intent_kind,
error,
},
'Skipped paired turn retry after claimed service handoff failure because the persisted turn intent is not schedulable',
);
return;
}
if (task.updated_at !== handoff.paired_task_updated_at) {
logger.warn(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
taskId: handoff.paired_task_id,
expectedTaskUpdatedAt: handoff.paired_task_updated_at,
actualTaskUpdatedAt: task.updated_at,
error,
},
'Skipped paired turn retry after claimed service handoff failure because the paired task revision changed',
);
return;
}
const scheduled = schedulePairedFollowUpWithMessageCheck({
chatJid: handoff.chat_jid,
runId: `handoff-${handoff.id}-retry`,
task,
intentKind: handoff.turn_intent_kind,
enqueueMessageCheck: () => enqueueMessageCheck(handoff.chat_jid),
});
logger.info(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
taskId: task.id,
taskStatus: task.status,
taskUpdatedAt: task.updated_at,
intentKind: handoff.turn_intent_kind,
turnId: handoff.turn_id ?? null,
scheduled,
error,
},
scheduled
? 'Queued paired turn retry after claimed service handoff failure'
: 'Skipped duplicate paired turn retry after claimed service handoff failure while task state was unchanged',
);
}
function isScheduledPairedFollowUpIntentKind(
intentKind: ServiceHandoff['turn_intent_kind'],
): intentKind is ScheduledPairedFollowUpIntentKind {
return (
intentKind === 'reviewer-turn' ||
intentKind === 'arbiter-turn' ||
intentKind === 'owner-follow-up' ||
intentKind === 'finalize-owner-turn'
);
}
function failClaimedHandoff(args: {
handoff: ServiceHandoff;
error: string;
getPairedTaskById?:
| ((
id: string,
) =>
| Pick<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| undefined)
| undefined;
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): void {
failServiceHandoff(args.handoff.id, args.error);
requeueFailedClaimedPairedTurn(args);
}
export async function processClaimedHandoff(args: {
handoff: ServiceHandoff;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
@@ -43,21 +165,80 @@ export async function processClaimedHandoff(args: {
executeTurn: ExecuteTurnFn;
lastAgentTimestamps: Record<string, string>;
saveState: () => void;
getPairedTaskById?:
| ((
id: string,
) =>
| Pick<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| undefined)
| undefined;
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): Promise<void> {
const { handoff } = args;
const group = args.getRegisteredGroups()[handoff.chat_jid];
if (!group) {
failServiceHandoff(handoff.id, 'Group not registered on target service');
failClaimedHandoff({
handoff,
error: 'Group not registered on target service',
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return;
}
const channel = findChannel(args.channels, handoff.chat_jid);
if (!channel) {
failServiceHandoff(handoff.id, 'No channel owns handoff jid');
failClaimedHandoff({
handoff,
error: 'No channel owns handoff jid',
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return;
}
const handoffRole = resolveHandoffRoleOverride(handoff);
if (!handoffRole) {
failClaimedHandoff({
handoff,
error: 'Cannot resolve intended handoff role',
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
logger.error(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
targetServiceId: handoff.target_service_id,
targetRole: handoff.target_role ?? null,
intendedRole: handoff.intended_role ?? null,
reason: handoff.reason ?? null,
},
'Failed claimed service handoff because its intended role could not be resolved',
);
return;
}
if (handoff.turn_role && handoff.turn_role !== handoffRole) {
failClaimedHandoff({
handoff,
error: `Stored handoff turn_role ${handoff.turn_role} conflicts with resolved role ${handoffRole}`,
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
logger.error(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
turnId: handoff.turn_id ?? null,
turnRole: handoff.turn_role,
resolvedRole: handoffRole,
targetServiceId: handoff.target_service_id,
},
'Failed claimed service handoff because its persisted logical turn role conflicts with the resolved role',
);
return;
}
let handoffChannel = channel;
if (handoffRole === 'reviewer') {
const reviewerChannel = findChannelByName(
@@ -65,7 +246,12 @@ export async function processClaimedHandoff(args: {
getFixedRoleChannelName('reviewer'),
);
if (!reviewerChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('reviewer'));
failClaimedHandoff({
handoff,
error: getMissingRoleChannelMessage('reviewer'),
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return;
}
handoffChannel = reviewerChannel;
@@ -75,19 +261,37 @@ export async function processClaimedHandoff(args: {
getFixedRoleChannelName('arbiter'),
);
if (!arbiterChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter'));
failClaimedHandoff({
handoff,
error: getMissingRoleChannelMessage('arbiter'),
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return;
}
handoffChannel = arbiterChannel;
}
const runId = `handoff-${handoff.id}`;
const pairedTurnIdentity =
handoff.paired_task_id &&
handoff.paired_task_updated_at &&
handoff.turn_intent_kind
? buildPairedTurnIdentity({
taskId: handoff.paired_task_id,
taskUpdatedAt: handoff.paired_task_updated_at,
intentKind: handoff.turn_intent_kind,
role: handoff.turn_role ?? handoffRole,
turnId: handoff.turn_id,
})
: undefined;
try {
logger.info(
{
chatJid: handoff.chat_jid,
handoffId: handoff.id,
runId,
turnId: pairedTurnIdentity?.turnId ?? handoff.turn_id ?? null,
handoffRole,
targetRole: handoff.target_role ?? null,
targetServiceId: handoff.target_service_id,
@@ -108,10 +312,16 @@ export async function processClaimedHandoff(args: {
endSeq: handoff.end_seq,
forcedRole: handoffRole,
forcedAgentType: handoff.target_agent_type,
pairedTurnIdentity,
});
if (!result.deliverySucceeded) {
failServiceHandoff(handoff.id, 'Handoff delivery failed');
failClaimedHandoff({
handoff,
error: 'Handoff delivery failed',
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return;
}
@@ -143,7 +353,12 @@ export async function processClaimedHandoff(args: {
);
} catch (err) {
const errorMessage = getErrorMessage(err);
failServiceHandoff(handoff.id, errorMessage);
failClaimedHandoff({
handoff,
error: errorMessage,
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
logger.error(
{ chatJid: handoff.chat_jid, handoffId: handoff.id, err },
'Claimed service handoff failed',

View File

@@ -12,6 +12,7 @@ import {
runPendingPairedTurnIfNeeded,
runQueuedGroupTurn,
} from './message-runtime-queue.js';
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
import type { Channel, PairedTask, RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
@@ -64,6 +65,7 @@ function makeChannel(): Channel {
describe('message-runtime-queue', () => {
beforeEach(() => {
_initTestDatabase();
resetPairedFollowUpScheduleState();
});
it('skips a pending paired turn when another run already claimed the same task revision', async () => {
@@ -222,4 +224,123 @@ describe('message-runtime-queue', () => {
expect(outcome).toBe(true);
expect(executeTurn).not.toHaveBeenCalled();
});
it('always passes the explicit reviewer role for queued paired reviewer turns', async () => {
const task = makeTask();
createPairedTask(task);
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
const outcome = await runQueuedGroupTurn({
chatJid: task.chat_jid,
group: makeGroup(),
runId: 'run-reviewer-forced-role',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
timezone: 'UTC',
missedMessages: [
{
id: 'bot-reviewer-1',
chat_jid: task.chat_jid,
sender: 'reviewer-bot@test',
sender_name: 'reviewer',
content: 'review pending',
timestamp: '2026-03-30T00:00:02.000Z',
seq: 45,
is_bot_message: true,
},
],
task,
roleToChannel: {
owner: null,
reviewer: makeChannel(),
arbiter: null,
},
ownerChannel: makeChannel(),
lastAgentTimestamps: {},
saveState: vi.fn(),
executeTurn,
getFixedRoleChannelName: () => 'discord-review',
labelPairedSenders: (_chatJid, messages) => messages,
formatMessages: () => 'formatted prompt',
});
expect(outcome).toBe(true);
expect(executeTurn).toHaveBeenCalledWith(
expect.objectContaining({
deliveryRole: 'reviewer',
forcedRole: 'reviewer',
pairedTurnIdentity: {
turnId: 'task-queue-claim:2026-03-30T00:00:00.000Z:reviewer-turn',
taskId: 'task-queue-claim',
taskUpdatedAt: '2026-03-30T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
}),
);
});
it('always passes the explicit owner role for queued paired owner turns', async () => {
const task = makeTask({
status: 'active',
review_requested_at: null,
});
createPairedTask(task);
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
const outcome = await runQueuedGroupTurn({
chatJid: task.chat_jid,
group: makeGroup(),
runId: 'run-owner-forced-role',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
timezone: 'UTC',
missedMessages: [
{
id: 'human-owner-1',
chat_jid: task.chat_jid,
sender: 'user@test',
sender_name: 'User',
content: '다시 고쳐줘',
timestamp: '2026-03-30T00:00:03.000Z',
seq: 46,
is_bot_message: false,
},
],
task,
roleToChannel: {
owner: null,
reviewer: makeChannel(),
arbiter: null,
},
ownerChannel: makeChannel(),
lastAgentTimestamps: {},
saveState: vi.fn(),
executeTurn,
getFixedRoleChannelName: () => 'discord-review',
labelPairedSenders: (_chatJid, messages) => messages,
formatMessages: () => 'formatted prompt',
});
expect(outcome).toBe(true);
expect(executeTurn).toHaveBeenCalledWith(
expect.objectContaining({
deliveryRole: 'owner',
forcedRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-queue-claim:2026-03-30T00:00:00.000Z:owner-turn',
taskId: 'task-queue-claim',
taskUpdatedAt: '2026-03-30T00:00:00.000Z',
intentKind: 'owner-turn',
role: 'owner',
},
}),
);
});
});

View File

@@ -9,6 +9,7 @@ import {
executePendingPairedTurn,
isBotOnlyPairedRoomTurn,
} from './message-runtime-flow.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import {
advanceLastAgentCursor,
resolveActiveRole,
@@ -202,8 +203,23 @@ export async function runQueuedGroupTurn(args: {
const turnChannel =
turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole];
const cursorKey = resolveCursorKeyForRole(chatJid, turnRole);
const forcedRole =
task && turnRole !== resolveActiveRole(taskStatus) ? turnRole : undefined;
const forcedRole = task ? turnRole : undefined;
const queuedIntentKind = task
? resolveQueuedTurnReservationIntent({
task,
turnRole,
hasHumanMessage: hasHumanMsg,
})
: null;
const pairedTurnIdentity =
task && queuedIntentKind
? buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: queuedIntentKind,
role: turnRole,
})
: undefined;
let prompt: string;
if (turnRole === 'arbiter' && task) {
@@ -257,16 +273,11 @@ export async function runQueuedGroupTurn(args: {
}
if (task) {
const intentKind = resolveQueuedTurnReservationIntent({
task,
turnRole,
hasHumanMessage: hasHumanMsg,
});
const claimed = claimPairedTurnExecution({
chatJid,
runId,
task,
intentKind,
intentKind: queuedIntentKind!,
});
if (!claimed) {
log.info(
@@ -274,7 +285,7 @@ export async function runQueuedGroupTurn(args: {
taskId: task.id,
taskStatus,
taskUpdatedAt: task.updated_at,
intentKind,
intentKind: queuedIntentKind,
turnRole,
},
'Skipped queued paired turn because the task revision was already claimed elsewhere',
@@ -304,6 +315,7 @@ export async function runQueuedGroupTurn(args: {
endSeq,
hasHumanMessage: hasHumanMsg,
forcedRole,
pairedTurnIdentity,
});
if (!deliverySucceeded) {

View File

@@ -4,6 +4,7 @@ import type {
PairedRoomRole,
RegisteredGroup,
} from './types.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
export type ExecuteTurnFn = (args: {
group: RegisteredGroup;
@@ -17,6 +18,7 @@ export type ExecuteTurnFn = (args: {
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}) => Promise<{
outputStatus: 'success' | 'error';
deliverySucceeded: boolean;

View File

@@ -79,7 +79,9 @@ vi.mock('./db.js', () => {
claimServiceHandoff: vi.fn(() => true),
completeServiceHandoff: vi.fn(),
completeServiceHandoffAndAdvanceTargetCursor: vi.fn(),
completePairedTurn: vi.fn(),
failServiceHandoff: vi.fn(),
failPairedTurn: vi.fn(),
getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []),
getAllPendingServiceHandoffs: vi.fn(() => []),
@@ -135,6 +137,7 @@ vi.mock('./db.js', () => {
getOpenWorkItem(chatJid),
),
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
getPairedTaskById: vi.fn(() => undefined),
getPairedTurnOutputs: vi.fn(() => []),
getRecentChatMessages: vi.fn(() => []),
createProducedWorkItem: vi.fn((input) => ({
@@ -157,6 +160,7 @@ vi.mock('./db.js', () => {
})),
markWorkItemDelivered: vi.fn(),
markWorkItemDeliveryRetry: vi.fn(),
markPairedTurnRunning: vi.fn(),
getLastBotFinalMessage: vi.fn(() => []),
reservePairedTurnReservation: vi.fn((args) => {
const key = buildReservationKey(args);
@@ -193,8 +197,13 @@ vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
getEffectiveChannelLease: vi.fn((chatJid: string) => ({
chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'claude-code',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: 'claude-arbiter',
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -202,9 +211,13 @@ vi.mock('./service-routing.js', () => ({
resolveLeaseServiceId: vi.fn(
(
lease: {
owner_agent_type?: string;
reviewer_agent_type?: string | null;
arbiter_agent_type?: string | null;
owner_service_id: string;
reviewer_service_id: string | null;
arbiter_service_id?: string | null;
owner_failover_active?: boolean;
},
role: 'owner' | 'reviewer' | 'arbiter',
) => {
@@ -2987,9 +3000,13 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -3099,9 +3116,13 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'claude-code',
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
arbiter_service_id: 'claude-arbiter',
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,

View File

@@ -6,6 +6,7 @@ import {
getMessagesSinceSeq,
getLastBotFinalMessage,
getLatestOpenPairedTaskForChat,
getPairedTaskById,
} from './db.js';
import {
isSessionCommandSenderAllowed,
@@ -51,6 +52,7 @@ import {
} from './message-runtime-follow-up.js';
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
import {
@@ -208,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
},
): Promise<'success' | 'error'> =>
runAgentForGroup(
@@ -229,6 +232,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage: options?.hasHumanMessage,
forcedRole: options?.forcedRole,
forcedAgentType: options?.forcedAgentType,
pairedTurnIdentity: options?.pairedTurnIdentity,
onOutput,
},
);
@@ -245,6 +249,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}): Promise<{
outputStatus: 'success' | 'error';
deliverySucceeded: boolean;
@@ -346,6 +351,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage: args.hasHumanMessage,
forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType,
pairedTurnIdentity: args.pairedTurnIdentity,
},
);
@@ -421,6 +427,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
executeTurn,
lastAgentTimestamps: deps.getLastAgentTimestamps(),
saveState: deps.saveState,
getPairedTaskById,
enqueueMessageCheck: (chatJid) =>
deps.queue.enqueueMessageCheck(chatJid),
});
},
});

View File

@@ -16,7 +16,12 @@ import {
_initTestDatabase,
_initTestDatabaseFromFile,
createPairedTask,
createServiceHandoff,
failServiceHandoff,
getPairedTaskById,
getPairedTurnAttempts,
getPairedTurnById,
releasePairedTaskExecutionLease,
} from './db.js';
import {
buildPairedFollowUpKey,
@@ -242,6 +247,140 @@ describe('paired follow-up scheduler', () => {
expect(second).toBe(false);
expect(enqueue).toHaveBeenCalledTimes(1);
});
it('persists the same logical turn id across reservation and execution lease rows', () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-paired-turn-id-'),
);
const dbPath = path.join(tempDir, 'paired-state.db');
try {
const emptyDb = new Database(dbPath);
emptyDb.close();
_initTestDatabaseFromFile(dbPath);
resetPairedFollowUpScheduleState();
const task = {
id: 'task-turn-id-persistence',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
status: 'review_ready',
round_trip_count: 1,
updated_at: '2026-04-10T00:00:00.000Z',
} as const;
createPairedTask(task as any);
expect(
schedulePairedFollowUpOnce({
chatJid: task.chat_jid,
runId: 'run-turn-id-reservation',
task,
intentKind: 'reviewer-turn',
enqueue: vi.fn(),
}),
).toBe(true);
expect(
claimPairedTurnExecution({
chatJid: task.chat_jid,
runId: 'run-turn-id-claim',
task,
intentKind: 'reviewer-turn',
}),
).toBe(true);
const rawDatabase = new Database(dbPath, { readonly: true });
const reservationRow = rawDatabase
.prepare(
`
SELECT turn_id, turn_attempt_no, turn_role
FROM paired_turn_reservations
WHERE task_id = ?
AND intent_kind = ?
`,
)
.get(task.id, 'reviewer-turn') as
| { turn_id: string; turn_attempt_no: number | null; turn_role: string }
| undefined;
const leaseRow = rawDatabase
.prepare(
`
SELECT turn_id, turn_attempt_no, role
FROM paired_task_execution_leases
WHERE task_id = ?
`,
)
.get(task.id) as
| { turn_id: string; turn_attempt_no: number | null; role: string }
| undefined;
const turnRow = rawDatabase
.prepare(
`
SELECT turn_id, task_id, task_updated_at, role, intent_kind
FROM paired_turns
WHERE task_id = ?
`,
)
.get(task.id) as
| {
turn_id: string;
task_id: string;
task_updated_at: string;
role: string;
intent_kind: string;
}
| undefined;
rawDatabase.close();
expect(reservationRow).toEqual({
turn_id:
'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn',
turn_attempt_no: 1,
turn_role: 'reviewer',
});
expect(leaseRow).toEqual({
turn_id:
'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn',
turn_attempt_no: 1,
role: 'reviewer',
});
expect(turnRow).toEqual({
turn_id:
'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn',
task_id: task.id,
task_updated_at: task.updated_at,
role: 'reviewer',
intent_kind: 'reviewer-turn',
});
expect(
getPairedTurnById(
'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn',
),
).toMatchObject({
turn_id:
'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn',
state: 'running',
executor_service_id: CURRENT_SERVICE_ID,
attempt_no: 1,
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
_initTestDatabase();
resetPairedFollowUpScheduleState();
}
});
it('keeps different round trips schedulable', () => {
const enqueue = vi.fn();
@@ -374,6 +513,137 @@ describe('paired follow-up scheduler', () => {
expect(enqueue).toHaveBeenCalledTimes(1);
});
it('requeues the same reviewer logical turn after a failed fallback handoff', () => {
const firstEnqueue = vi.fn();
const secondEnqueue = vi.fn();
const currentReviewerServiceId =
CURRENT_AGENT_TYPE === 'codex'
? CODEX_REVIEW_SERVICE_ID
: CLAUDE_SERVICE_ID;
const otherReviewerServiceId =
OTHER_AGENT_TYPE === 'codex'
? CODEX_REVIEW_SERVICE_ID
: CLAUDE_SERVICE_ID;
const task = {
id: 'task-reviewer-handoff-retry',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: CURRENT_SERVICE_ID,
reviewer_service_id: OTHER_SERVICE_ID,
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
status: 'review_ready',
round_trip_count: 1,
updated_at: '2026-04-10T00:00:00.000Z',
} as const;
createPairedTask(task as any);
expect(
schedulePairedFollowUpOnce({
chatJid: task.chat_jid,
runId: 'run-reviewer-schedule-1',
task,
intentKind: 'reviewer-turn',
enqueue: firstEnqueue,
}),
).toBe(true);
expect(
claimPairedTurnExecution({
chatJid: task.chat_jid,
runId: 'run-reviewer-claim-1',
task,
intentKind: 'reviewer-turn',
}),
).toBe(true);
const handoff = createServiceHandoff({
chat_jid: task.chat_jid,
group_folder: task.group_folder,
paired_task_id: task.id,
paired_task_updated_at: task.updated_at,
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_service_id: currentReviewerServiceId,
target_service_id: otherReviewerServiceId,
source_role: 'reviewer',
target_role: 'reviewer',
source_agent_type: CURRENT_AGENT_TYPE,
target_agent_type: OTHER_AGENT_TYPE,
prompt: 'review retry after fallback',
intended_role: 'reviewer',
});
failServiceHandoff(handoff.id, 'fallback handoff failed');
releasePairedTaskExecutionLease({
taskId: task.id,
runId: 'run-reviewer-claim-1',
});
expect(
getPairedTurnById(
'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn',
),
).toMatchObject({
state: 'failed',
attempt_no: 1,
last_error: 'fallback handoff failed',
});
expect(
schedulePairedFollowUpOnce({
chatJid: task.chat_jid,
runId: 'run-reviewer-schedule-2',
task,
intentKind: 'reviewer-turn',
enqueue: secondEnqueue,
}),
).toBe(true);
expect(secondEnqueue).toHaveBeenCalledTimes(1);
expect(
getPairedTurnById(
'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn',
),
).toMatchObject({
state: 'failed',
attempt_no: 1,
last_error: 'fallback handoff failed',
});
expect(
getPairedTurnAttempts(
'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn',
),
).toMatchObject([
{
attempt_no: 1,
state: 'failed',
last_error: 'fallback handoff failed',
},
]);
expect(
claimPairedTurnExecution({
chatJid: task.chat_jid,
runId: 'run-reviewer-claim-2',
task,
intentKind: 'reviewer-turn',
}),
).toBe(true);
expect(
getPairedTurnById(
'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn',
),
).toMatchObject({
state: 'running',
attempt_no: 2,
});
});
it('blocks a fresh-refetched task revision from reclaiming the same turn while the execution lease is active', () => {
const task = {
id: 'task-1',
@@ -560,6 +830,91 @@ describe('paired follow-up scheduler', () => {
}
});
it('creates attempt 2 after restart reclaim without overwriting attempt 1', () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-paired-attempt-restart-'),
);
const dbPath = path.join(tempDir, 'paired-state.db');
try {
_initTestDatabaseFromFile(dbPath);
resetPairedFollowUpScheduleState();
const task = {
id: 'task-restart-attempt-history',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
status: 'review_ready',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
} as const;
createPairedTask(task as any);
expect(
claimPairedTurnExecution({
chatJid: task.chat_jid,
runId: 'run-restart-attempt-1',
task,
intentKind: 'reviewer-turn',
}),
).toBe(true);
_initTestDatabaseFromFile(dbPath);
const recoveredTask = getPairedTaskById(task.id);
expect(recoveredTask).toBeDefined();
expect(
claimPairedTurnExecution({
chatJid: task.chat_jid,
runId: 'run-restart-attempt-2',
task: recoveredTask ?? task,
intentKind: 'reviewer-turn',
}),
).toBe(true);
expect(
getPairedTurnById(
'task-restart-attempt-history:2026-03-30T00:00:00.000Z:reviewer-turn',
),
).toMatchObject({
state: 'running',
attempt_no: 2,
});
expect(
getPairedTurnAttempts(
'task-restart-attempt-history:2026-03-30T00:00:00.000Z:reviewer-turn',
),
).toMatchObject([
{
attempt_no: 1,
state: 'cancelled',
executor_service_id: CURRENT_SERVICE_ID,
executor_agent_type: CURRENT_AGENT_TYPE,
},
{
attempt_no: 2,
state: 'running',
executor_service_id: CURRENT_SERVICE_ID,
executor_agent_type: CURRENT_AGENT_TYPE,
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
_initTestDatabase();
resetPairedFollowUpScheduleState();
}
});
it('preserves legacy execution leases that belong to another service during startup cleanup', () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-paired-lease-preserve-'),

103
src/paired-turn-identity.ts Normal file
View File

@@ -0,0 +1,103 @@
import type {
PairedRoomRole,
PairedTaskStatus,
PairedTurnReservationIntentKind,
} from './types.js';
export interface PairedTurnIdentity {
turnId: string;
taskId: string;
taskUpdatedAt: string;
intentKind: PairedTurnReservationIntentKind;
role: PairedRoomRole;
}
export function resolvePairedTurnRole(
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';
}
}
export function buildPairedTurnId(args: {
taskId: string;
taskUpdatedAt: string;
intentKind: PairedTurnReservationIntentKind;
}): string {
return [args.taskId, args.taskUpdatedAt, args.intentKind].join(':');
}
export function buildPairedTurnIdentity(args: {
taskId: string;
taskUpdatedAt: string;
intentKind: PairedTurnReservationIntentKind;
role?: PairedRoomRole;
turnId?: string | null;
}): PairedTurnIdentity {
const role = args.role ?? resolvePairedTurnRole(args.intentKind);
if (role !== resolvePairedTurnRole(args.intentKind)) {
throw new Error(
`paired turn identity role mismatch: ${role} does not match ${args.intentKind}`,
);
}
return {
turnId:
args.turnId ??
buildPairedTurnId({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: args.intentKind,
}),
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: args.intentKind,
role,
};
}
export function resolveOwnerTurnIntentKind(args: {
taskStatus?: PairedTaskStatus | null;
hasHumanMessage?: boolean;
}): 'owner-turn' | 'owner-follow-up' | 'finalize-owner-turn' {
if (args.hasHumanMessage) {
return 'owner-turn';
}
if (args.taskStatus === 'merge_ready') {
return 'finalize-owner-turn';
}
return 'owner-follow-up';
}
export function resolveRuntimePairedTurnIdentity(args: {
taskId: string;
taskUpdatedAt: string;
role: PairedRoomRole;
taskStatus?: PairedTaskStatus | null;
hasHumanMessage?: boolean;
}): PairedTurnIdentity {
const intentKind =
args.role === 'reviewer'
? 'reviewer-turn'
: args.role === 'arbiter'
? 'arbiter-turn'
: resolveOwnerTurnIntentKind({
taskStatus: args.taskStatus,
hasHumanMessage: args.hasHumanMessage,
});
return buildPairedTurnIdentity({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind,
role: args.role,
});
}