Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
5078
src/db.test.ts
5078
src/db.test.ts
File diff suppressed because it is too large
Load Diff
211
src/db.ts
211
src/db.ts
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -178,6 +178,10 @@ export function applyBaseSchema(database: Database): void {
|
||||
task_status TEXT NOT NULL,
|
||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||
task_updated_at TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
turn_attempt_id TEXT,
|
||||
turn_attempt_no INTEGER,
|
||||
turn_role TEXT NOT NULL,
|
||||
intent_kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
scheduled_run_id TEXT,
|
||||
@@ -186,7 +190,11 @@ export function applyBaseSchema(database: Database): void {
|
||||
updated_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
|
||||
FOREIGN KEY (turn_id, turn_attempt_no)
|
||||
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (status IN ('pending', 'completed')),
|
||||
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
|
||||
CHECK (
|
||||
intent_kind IN (
|
||||
'owner-turn',
|
||||
@@ -199,10 +207,89 @@ export function applyBaseSchema(database: Database): void {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
|
||||
ON paired_turn_reservations(task_id, task_updated_at, status);
|
||||
CREATE TABLE IF NOT EXISTS paired_turns (
|
||||
turn_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
task_updated_at TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
intent_kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
||||
CHECK (
|
||||
intent_kind IN (
|
||||
'owner-turn',
|
||||
'reviewer-turn',
|
||||
'arbiter-turn',
|
||||
'owner-follow-up',
|
||||
'finalize-owner-turn'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
|
||||
ON paired_turns(task_id, task_updated_at, updated_at);
|
||||
CREATE TABLE IF NOT EXISTS paired_turn_attempts (
|
||||
attempt_id TEXT NOT NULL PRIMARY KEY,
|
||||
parent_attempt_id TEXT,
|
||||
parent_handoff_id INTEGER,
|
||||
continuation_handoff_id INTEGER,
|
||||
turn_id TEXT NOT NULL,
|
||||
attempt_no INTEGER NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_updated_at TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
intent_kind TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
executor_service_id TEXT,
|
||||
executor_agent_type TEXT,
|
||||
active_run_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
UNIQUE (turn_id, attempt_no),
|
||||
FOREIGN KEY (parent_attempt_id)
|
||||
REFERENCES paired_turn_attempts(attempt_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_handoff_id)
|
||||
REFERENCES service_handoffs(id)
|
||||
ON DELETE SET NULL,
|
||||
FOREIGN KEY (continuation_handoff_id)
|
||||
REFERENCES service_handoffs(id)
|
||||
ON DELETE SET NULL,
|
||||
FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE,
|
||||
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
||||
CHECK (
|
||||
intent_kind IN (
|
||||
'owner-turn',
|
||||
'reviewer-turn',
|
||||
'arbiter-turn',
|
||||
'owner-follow-up',
|
||||
'finalize-owner-turn'
|
||||
)
|
||||
),
|
||||
CHECK (
|
||||
state IN (
|
||||
'running',
|
||||
'delegated',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
)
|
||||
),
|
||||
CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn
|
||||
ON paired_turn_attempts(turn_id, attempt_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task
|
||||
ON paired_turn_attempts(task_id, task_updated_at, attempt_no);
|
||||
CREATE TABLE IF NOT EXISTS paired_task_execution_leases (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
turn_attempt_id TEXT,
|
||||
turn_attempt_no INTEGER,
|
||||
intent_kind TEXT NOT NULL,
|
||||
claimed_run_id TEXT NOT NULL,
|
||||
claimed_service_id TEXT NOT NULL,
|
||||
@@ -211,6 +298,9 @@ export function applyBaseSchema(database: Database): void {
|
||||
claimed_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (turn_id, turn_attempt_no)
|
||||
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
||||
CHECK (
|
||||
intent_kind IN (
|
||||
@@ -249,16 +339,35 @@ export function applyBaseSchema(database: Database): void {
|
||||
is_main INTEGER DEFAULT 0,
|
||||
owner_agent_type TEXT,
|
||||
work_dir TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (room_mode IN ('single', 'tribunal')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS room_role_overrides (
|
||||
chat_jid TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_config_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chat_jid, role),
|
||||
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
||||
CHECK (agent_type IN ('claude-code', 'codex'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS service_handoffs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
source_service_id TEXT NOT NULL,
|
||||
target_service_id TEXT NOT NULL,
|
||||
paired_task_id TEXT,
|
||||
paired_task_updated_at TEXT,
|
||||
turn_id TEXT,
|
||||
turn_attempt_id TEXT,
|
||||
turn_attempt_no INTEGER,
|
||||
turn_intent_kind TEXT,
|
||||
turn_role TEXT,
|
||||
source_role TEXT,
|
||||
source_agent_type TEXT,
|
||||
target_role TEXT,
|
||||
@@ -273,8 +382,21 @@ export function applyBaseSchema(database: Database): void {
|
||||
claimed_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
FOREIGN KEY (turn_id, turn_attempt_no)
|
||||
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
||||
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
|
||||
CHECK (
|
||||
turn_intent_kind IN (
|
||||
'owner-turn',
|
||||
'reviewer-turn',
|
||||
'arbiter-turn',
|
||||
'owner-follow-up',
|
||||
'finalize-owner-turn'
|
||||
) OR turn_intent_kind IS NULL
|
||||
),
|
||||
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL),
|
||||
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
|
||||
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
|
||||
);
|
||||
|
||||
@@ -2,27 +2,41 @@ import { Database } from 'bun:sqlite';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { RegisteredGroup } from '../types.js';
|
||||
import { readJsonFile } from '../utils.js';
|
||||
import { ASSISTANT_NAME, STORE_DIR } from '../config.js';
|
||||
import { applyBaseSchema } from './base-schema.js';
|
||||
import { applySchemaMigrations } from './schema.js';
|
||||
|
||||
export interface JsonStateMigrationHooks {
|
||||
setRouterState(key: string, value: string): void;
|
||||
setSession(groupFolder: string, sessionId: string): void;
|
||||
writeLegacyRegisteredGroupAndSyncRoomSettings(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void;
|
||||
function setForeignKeys(database: Database, enabled: boolean): void {
|
||||
database.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`);
|
||||
}
|
||||
|
||||
function assertNoForeignKeyViolations(database: Database): void {
|
||||
const violations = database
|
||||
.prepare('PRAGMA foreign_key_check')
|
||||
.all() as Array<{
|
||||
table: string;
|
||||
rowid: number;
|
||||
parent: string;
|
||||
fkid: number;
|
||||
}>;
|
||||
if (violations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const violation = violations[0]!;
|
||||
throw new Error(
|
||||
`Foreign key integrity check failed for ${violation.table}(rowid=${violation.rowid}) referencing ${violation.parent} [fk=${violation.fkid}]`,
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeDatabaseSchema(database: Database): void {
|
||||
setForeignKeys(database, false);
|
||||
applyBaseSchema(database);
|
||||
applySchemaMigrations(database, {
|
||||
assistantName: ASSISTANT_NAME,
|
||||
});
|
||||
setForeignKeys(database, true);
|
||||
assertNoForeignKeyViolations(database);
|
||||
}
|
||||
|
||||
export function openPersistentDatabase(): Database {
|
||||
@@ -42,62 +56,3 @@ export function openInMemoryDatabase(): Database {
|
||||
export function openDatabaseFromFile(dbPath: string): Database {
|
||||
return new Database(dbPath);
|
||||
}
|
||||
|
||||
export function migrateJsonStateFromFiles(
|
||||
hooks: JsonStateMigrationHooks,
|
||||
): void {
|
||||
const migrateFile = (filename: string) => {
|
||||
const filePath = path.join(DATA_DIR, filename);
|
||||
const data = readJsonFile(filePath);
|
||||
if (data === null) return null;
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.migrated`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const routerState = migrateFile('router_state.json') as {
|
||||
last_timestamp?: string;
|
||||
last_agent_timestamp?: Record<string, string>;
|
||||
} | null;
|
||||
if (routerState) {
|
||||
if (routerState.last_timestamp) {
|
||||
hooks.setRouterState('last_timestamp', routerState.last_timestamp);
|
||||
}
|
||||
if (routerState.last_agent_timestamp) {
|
||||
hooks.setRouterState(
|
||||
'last_agent_timestamp',
|
||||
JSON.stringify(routerState.last_agent_timestamp),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = migrateFile('sessions.json') as Record<
|
||||
string,
|
||||
string
|
||||
> | null;
|
||||
if (sessions) {
|
||||
for (const [folder, sessionId] of Object.entries(sessions)) {
|
||||
hooks.setSession(folder, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = migrateFile('registered_groups.json') as Record<
|
||||
string,
|
||||
RegisteredGroup
|
||||
> | null;
|
||||
if (groups) {
|
||||
for (const [jid, group] of Object.entries(groups)) {
|
||||
try {
|
||||
hooks.writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ jid, folder: group.folder, err },
|
||||
'Skipping migrated registered group with invalid folder',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
374
src/db/canonical-role-metadata.ts
Normal file
374
src/db/canonical-role-metadata.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { ARBITER_AGENT_TYPE, SERVICE_SESSION_SCOPE } from '../config.js';
|
||||
import {
|
||||
inferAgentTypeFromServiceShadow,
|
||||
inferRoleFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
import { normalizeStoredAgentType } from './room-registration.js';
|
||||
|
||||
interface RequiredRoleMetadataInput {
|
||||
context: string;
|
||||
role: PairedRoomRole;
|
||||
storedAgentType?: string | null;
|
||||
storedServiceId?: string | null;
|
||||
}
|
||||
|
||||
interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {}
|
||||
|
||||
function resolveRequiredAgentType(input: RequiredRoleMetadataInput): AgentType {
|
||||
const persistedAgentType = normalizeStoredAgentType(input.storedAgentType);
|
||||
const inferredAgentType = inferAgentTypeFromServiceShadow(
|
||||
input.storedServiceId,
|
||||
);
|
||||
if (
|
||||
persistedAgentType &&
|
||||
inferredAgentType &&
|
||||
persistedAgentType !== inferredAgentType
|
||||
) {
|
||||
throw new Error(
|
||||
`${input.context}: ${input.role}_agent_type conflicts with ${input.role}_service_id`,
|
||||
);
|
||||
}
|
||||
|
||||
const agentType = persistedAgentType ?? inferredAgentType;
|
||||
if (agentType) {
|
||||
return agentType;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${input.context}: cannot resolve ${input.role}_agent_type from stored row metadata`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequiredServiceId(
|
||||
context: string,
|
||||
role: PairedRoomRole,
|
||||
agentType: AgentType,
|
||||
storedServiceId?: string | null,
|
||||
): string {
|
||||
return (
|
||||
storedServiceId ??
|
||||
resolveRoleServiceShadow(role, agentType) ??
|
||||
(() => {
|
||||
throw new Error(
|
||||
`${context}: cannot resolve ${role}_service_id from stored row metadata`,
|
||||
);
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): {
|
||||
agentType: AgentType | null;
|
||||
serviceId: string | null;
|
||||
} {
|
||||
if (!input.storedAgentType && !input.storedServiceId) {
|
||||
return { agentType: null, serviceId: null };
|
||||
}
|
||||
|
||||
const agentType = resolveRequiredAgentType(input);
|
||||
return {
|
||||
agentType,
|
||||
serviceId: resolveRequiredServiceId(
|
||||
input.context,
|
||||
input.role,
|
||||
agentType,
|
||||
input.storedServiceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanonicalPairedTaskMetadata {
|
||||
ownerAgentType: AgentType;
|
||||
reviewerAgentType: AgentType;
|
||||
arbiterAgentType: AgentType | null;
|
||||
ownerServiceId: string;
|
||||
reviewerServiceId: string;
|
||||
}
|
||||
|
||||
export function canonicalizePairedTaskMetadata(input: {
|
||||
id: string;
|
||||
owner_service_id?: string | null;
|
||||
reviewer_service_id?: string | null;
|
||||
owner_agent_type?: string | null;
|
||||
reviewer_agent_type?: string | null;
|
||||
arbiter_agent_type?: string | null;
|
||||
}): CanonicalPairedTaskMetadata {
|
||||
const context = `paired_tasks(${input.id})`;
|
||||
const ownerAgentType = resolveRequiredAgentType({
|
||||
context,
|
||||
role: 'owner',
|
||||
storedAgentType: input.owner_agent_type,
|
||||
storedServiceId: input.owner_service_id,
|
||||
});
|
||||
const reviewerAgentType = resolveRequiredAgentType({
|
||||
context,
|
||||
role: 'reviewer',
|
||||
storedAgentType: input.reviewer_agent_type,
|
||||
storedServiceId: input.reviewer_service_id,
|
||||
});
|
||||
|
||||
return {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
arbiterAgentType:
|
||||
normalizeStoredAgentType(input.arbiter_agent_type) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null,
|
||||
ownerServiceId: resolveRequiredServiceId(
|
||||
context,
|
||||
'owner',
|
||||
ownerAgentType,
|
||||
input.owner_service_id,
|
||||
),
|
||||
reviewerServiceId: resolveRequiredServiceId(
|
||||
context,
|
||||
'reviewer',
|
||||
reviewerAgentType,
|
||||
input.reviewer_service_id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanonicalChannelOwnerLeaseMetadata {
|
||||
ownerAgentType: AgentType;
|
||||
reviewerAgentType: AgentType | null;
|
||||
arbiterAgentType: AgentType | null;
|
||||
ownerServiceId: string;
|
||||
reviewerServiceId: string | null;
|
||||
arbiterServiceId: string | null;
|
||||
}
|
||||
|
||||
export function canonicalizeChannelOwnerLeaseMetadata(input: {
|
||||
chat_jid: string;
|
||||
owner_service_id?: string | null;
|
||||
reviewer_service_id?: string | null;
|
||||
arbiter_service_id?: string | null;
|
||||
owner_agent_type?: string | null;
|
||||
reviewer_agent_type?: string | null;
|
||||
arbiter_agent_type?: string | null;
|
||||
}): CanonicalChannelOwnerLeaseMetadata {
|
||||
const context = `channel_owner(${input.chat_jid})`;
|
||||
const ownerAgentType = resolveRequiredAgentType({
|
||||
context,
|
||||
role: 'owner',
|
||||
storedAgentType: input.owner_agent_type,
|
||||
storedServiceId: input.owner_service_id,
|
||||
});
|
||||
const reviewer = resolveOptionalRoleMetadata({
|
||||
context,
|
||||
role: 'reviewer',
|
||||
storedAgentType: input.reviewer_agent_type,
|
||||
storedServiceId: input.reviewer_service_id,
|
||||
});
|
||||
const arbiter = resolveOptionalRoleMetadata({
|
||||
context,
|
||||
role: 'arbiter',
|
||||
storedAgentType: input.arbiter_agent_type,
|
||||
storedServiceId: input.arbiter_service_id,
|
||||
});
|
||||
|
||||
return {
|
||||
ownerAgentType,
|
||||
reviewerAgentType: reviewer.agentType,
|
||||
arbiterAgentType: arbiter.agentType,
|
||||
ownerServiceId: resolveRequiredServiceId(
|
||||
context,
|
||||
'owner',
|
||||
ownerAgentType,
|
||||
input.owner_service_id,
|
||||
),
|
||||
reviewerServiceId: reviewer.serviceId,
|
||||
arbiterServiceId: arbiter.serviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHandoffRole(
|
||||
role: string | null | undefined,
|
||||
): PairedRoomRole | null {
|
||||
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
|
||||
? role
|
||||
: null;
|
||||
}
|
||||
|
||||
function assertStoredRoleMatchesServiceShadow(args: {
|
||||
context: string;
|
||||
storedRole: PairedRoomRole | null;
|
||||
agentType: AgentType | null;
|
||||
serviceId: string | null;
|
||||
roleField: 'source_role' | 'target_role';
|
||||
serviceField: 'source_service_id' | 'target_service_id';
|
||||
}): void {
|
||||
if (!args.storedRole || !args.agentType || !args.serviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inferredRole = inferRoleFromServiceShadow(
|
||||
args.agentType,
|
||||
args.serviceId,
|
||||
);
|
||||
if (inferredRole && inferredRole !== args.storedRole) {
|
||||
throw new Error(
|
||||
`${args.context}: ${args.roleField} conflicts with ${args.serviceField}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CanonicalServiceHandoffMetadata {
|
||||
sourceRole: PairedRoomRole | null;
|
||||
targetRole: PairedRoomRole | null;
|
||||
sourceAgentType: AgentType | null;
|
||||
targetAgentType: AgentType;
|
||||
sourceServiceId: string;
|
||||
targetServiceId: string;
|
||||
}
|
||||
|
||||
export function canonicalizeServiceHandoffMetadata(input: {
|
||||
id: number | string;
|
||||
chat_jid: string;
|
||||
source_service_id?: string | null;
|
||||
target_service_id?: string | null;
|
||||
source_role?: string | null;
|
||||
target_role?: string | null;
|
||||
intended_role?: string | null;
|
||||
source_agent_type?: string | null;
|
||||
target_agent_type?: string | null;
|
||||
}): CanonicalServiceHandoffMetadata {
|
||||
const context = `service_handoffs(${input.id})`;
|
||||
const sourceRole =
|
||||
normalizeHandoffRole(input.source_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
const targetRole =
|
||||
normalizeHandoffRole(input.target_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
const storedSourceAgentType = normalizeStoredAgentType(
|
||||
input.source_agent_type,
|
||||
);
|
||||
const inferredSourceAgentType = inferAgentTypeFromServiceShadow(
|
||||
input.source_service_id,
|
||||
);
|
||||
if (
|
||||
storedSourceAgentType &&
|
||||
inferredSourceAgentType &&
|
||||
storedSourceAgentType !== inferredSourceAgentType
|
||||
) {
|
||||
throw new Error(
|
||||
`${context}: source_agent_type conflicts with source_service_id`,
|
||||
);
|
||||
}
|
||||
const sourceAgentType =
|
||||
storedSourceAgentType ?? inferredSourceAgentType ?? null;
|
||||
const storedTargetAgentType = normalizeStoredAgentType(
|
||||
input.target_agent_type,
|
||||
);
|
||||
const inferredTargetAgentType = inferAgentTypeFromServiceShadow(
|
||||
input.target_service_id,
|
||||
);
|
||||
if (
|
||||
storedTargetAgentType &&
|
||||
inferredTargetAgentType &&
|
||||
storedTargetAgentType !== inferredTargetAgentType
|
||||
) {
|
||||
throw new Error(
|
||||
`${context}: target_agent_type conflicts with target_service_id`,
|
||||
);
|
||||
}
|
||||
const targetAgentType = storedTargetAgentType ?? inferredTargetAgentType;
|
||||
|
||||
if (!targetAgentType) {
|
||||
throw new Error(
|
||||
`${context}: cannot resolve target_agent_type from stored row metadata`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetServiceId =
|
||||
input.target_service_id ??
|
||||
(targetRole != null
|
||||
? resolveRoleServiceShadow(targetRole, targetAgentType)
|
||||
: null);
|
||||
if (!targetServiceId) {
|
||||
throw new Error(
|
||||
`${context}: cannot resolve target_service_id from stored row metadata`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceServiceId =
|
||||
input.source_service_id ??
|
||||
(sourceRole != null && sourceAgentType != null
|
||||
? resolveRoleServiceShadow(sourceRole, sourceAgentType)
|
||||
: null) ??
|
||||
SERVICE_SESSION_SCOPE;
|
||||
|
||||
assertStoredRoleMatchesServiceShadow({
|
||||
context,
|
||||
storedRole: sourceRole,
|
||||
agentType: sourceAgentType,
|
||||
serviceId: sourceServiceId,
|
||||
roleField: 'source_role',
|
||||
serviceField: 'source_service_id',
|
||||
});
|
||||
assertStoredRoleMatchesServiceShadow({
|
||||
context,
|
||||
storedRole: targetRole,
|
||||
agentType: targetAgentType,
|
||||
serviceId: targetServiceId,
|
||||
roleField: 'target_role',
|
||||
serviceField: 'target_service_id',
|
||||
});
|
||||
|
||||
return {
|
||||
sourceRole,
|
||||
targetRole,
|
||||
sourceAgentType,
|
||||
targetAgentType,
|
||||
sourceServiceId,
|
||||
targetServiceId,
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionLeaseMetadataRow {
|
||||
rowid: number;
|
||||
role: string;
|
||||
owner_service_id?: string | null;
|
||||
reviewer_service_id?: string | null;
|
||||
arbiter_service_id?: string | null;
|
||||
owner_agent_type?: string | null;
|
||||
reviewer_agent_type?: string | null;
|
||||
arbiter_agent_type?: string | null;
|
||||
}
|
||||
|
||||
export function inferExecutionLeaseServiceIdFromCanonicalMetadata(
|
||||
row: ExecutionLeaseMetadataRow,
|
||||
): string | null {
|
||||
switch (row.role) {
|
||||
case 'owner': {
|
||||
const owner = resolveOptionalRoleMetadata({
|
||||
context: `paired_task_execution_leases(${row.rowid})`,
|
||||
role: 'owner',
|
||||
storedAgentType: row.owner_agent_type,
|
||||
storedServiceId: row.owner_service_id,
|
||||
});
|
||||
return owner.serviceId;
|
||||
}
|
||||
case 'reviewer': {
|
||||
const reviewer = resolveOptionalRoleMetadata({
|
||||
context: `paired_task_execution_leases(${row.rowid})`,
|
||||
role: 'reviewer',
|
||||
storedAgentType: row.reviewer_agent_type,
|
||||
storedServiceId: row.reviewer_service_id,
|
||||
});
|
||||
return reviewer.serviceId;
|
||||
}
|
||||
case 'arbiter': {
|
||||
const arbiter = resolveOptionalRoleMetadata({
|
||||
context: `paired_task_execution_leases(${row.rowid})`,
|
||||
role: 'arbiter',
|
||||
storedAgentType: row.arbiter_agent_type,
|
||||
storedServiceId: row.arbiter_service_id,
|
||||
});
|
||||
return arbiter.serviceId;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
CLAUDE_SERVICE_ID,
|
||||
OWNER_AGENT_TYPE,
|
||||
} from '../config.js';
|
||||
import {
|
||||
inferAgentTypeFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
import { AgentType } from '../types.js';
|
||||
import { resolveStableReviewerAgentType } from './legacy-rebuilds.js';
|
||||
import { normalizeStoredAgentType } from './room-registration.js';
|
||||
import { canonicalizeChannelOwnerLeaseMetadata } from './canonical-role-metadata.js';
|
||||
|
||||
export interface ChannelOwnerLeaseRow {
|
||||
chat_jid: string;
|
||||
@@ -52,55 +42,28 @@ export interface SetChannelOwnerLeaseInput {
|
||||
function hydrateChannelOwnerLeaseRow(
|
||||
row: StoredChannelOwnerLeaseRow,
|
||||
): ChannelOwnerLeaseRow {
|
||||
const persistedOwnerAgentType = normalizeStoredAgentType(
|
||||
row.owner_agent_type,
|
||||
);
|
||||
const persistedReviewerAgentType = normalizeStoredAgentType(
|
||||
row.reviewer_agent_type,
|
||||
);
|
||||
const persistedArbiterAgentType = normalizeStoredAgentType(
|
||||
row.arbiter_agent_type,
|
||||
);
|
||||
const ownerAgentType =
|
||||
persistedOwnerAgentType ??
|
||||
(row.owner_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.owner_service_id)
|
||||
: undefined) ??
|
||||
OWNER_AGENT_TYPE;
|
||||
const reviewerAgentType =
|
||||
row.reviewer_agent_type == null && row.reviewer_service_id == null
|
||||
? null
|
||||
: (persistedReviewerAgentType ??
|
||||
(row.reviewer_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.reviewer_service_id)
|
||||
: null) ??
|
||||
resolveStableReviewerAgentType(ownerAgentType, null));
|
||||
const arbiterAgentType =
|
||||
row.arbiter_agent_type == null && row.arbiter_service_id == null
|
||||
? null
|
||||
: (persistedArbiterAgentType ??
|
||||
(row.arbiter_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
|
||||
: undefined) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null);
|
||||
const {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
arbiterAgentType,
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
arbiterServiceId,
|
||||
} = canonicalizeChannelOwnerLeaseMetadata({
|
||||
chat_jid: row.chat_jid,
|
||||
owner_service_id: row.owner_service_id,
|
||||
reviewer_service_id: row.reviewer_service_id,
|
||||
arbiter_service_id: row.arbiter_service_id,
|
||||
owner_agent_type: row.owner_agent_type,
|
||||
reviewer_agent_type: row.reviewer_agent_type,
|
||||
arbiter_agent_type: row.arbiter_agent_type,
|
||||
});
|
||||
|
||||
return {
|
||||
chat_jid: row.chat_jid,
|
||||
owner_service_id:
|
||||
row.owner_service_id ??
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
CLAUDE_SERVICE_ID,
|
||||
reviewer_service_id:
|
||||
row.reviewer_service_id ??
|
||||
(reviewerAgentType == null
|
||||
? null
|
||||
: resolveRoleServiceShadow('reviewer', reviewerAgentType)),
|
||||
arbiter_service_id:
|
||||
row.arbiter_service_id ??
|
||||
(arbiterAgentType == null
|
||||
? null
|
||||
: resolveRoleServiceShadow('arbiter', arbiterAgentType)),
|
||||
owner_service_id: ownerServiceId,
|
||||
reviewer_service_id: reviewerServiceId,
|
||||
arbiter_service_id: arbiterServiceId,
|
||||
owner_agent_type: ownerAgentType,
|
||||
reviewer_agent_type: reviewerAgentType,
|
||||
arbiter_agent_type: arbiterAgentType,
|
||||
@@ -132,41 +95,22 @@ export function setChannelOwnerLeaseInDatabase(
|
||||
database: Database,
|
||||
input: SetChannelOwnerLeaseInput,
|
||||
): void {
|
||||
const ownerAgentType =
|
||||
normalizeStoredAgentType(input.owner_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(input.owner_service_id) ??
|
||||
OWNER_AGENT_TYPE;
|
||||
const reviewerAgentType =
|
||||
input.reviewer_service_id == null && input.reviewer_agent_type == null
|
||||
? null
|
||||
: (normalizeStoredAgentType(input.reviewer_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(input.reviewer_service_id ?? null) ??
|
||||
resolveStableReviewerAgentType(ownerAgentType, null));
|
||||
const arbiterAgentType =
|
||||
input.arbiter_service_id == null && input.arbiter_agent_type == null
|
||||
? null
|
||||
: (normalizeStoredAgentType(input.arbiter_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(input.arbiter_service_id ?? null) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null);
|
||||
const ownerServiceId =
|
||||
input.owner_service_id ??
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
CLAUDE_SERVICE_ID;
|
||||
const reviewerServiceId =
|
||||
input.reviewer_service_id == null && reviewerAgentType == null
|
||||
? null
|
||||
: (input.reviewer_service_id ??
|
||||
(reviewerAgentType == null
|
||||
? null
|
||||
: resolveRoleServiceShadow('reviewer', reviewerAgentType)));
|
||||
const arbiterServiceId =
|
||||
input.arbiter_service_id == null && arbiterAgentType == null
|
||||
? null
|
||||
: (input.arbiter_service_id ??
|
||||
(arbiterAgentType == null
|
||||
? null
|
||||
: resolveRoleServiceShadow('arbiter', arbiterAgentType)));
|
||||
const {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
arbiterAgentType,
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
arbiterServiceId,
|
||||
} = canonicalizeChannelOwnerLeaseMetadata({
|
||||
chat_jid: input.chat_jid,
|
||||
owner_service_id: input.owner_service_id,
|
||||
reviewer_service_id: input.reviewer_service_id,
|
||||
arbiter_service_id: input.arbiter_service_id,
|
||||
owner_agent_type: input.owner_agent_type,
|
||||
reviewer_agent_type: input.reviewer_agent_type,
|
||||
arbiter_agent_type: input.arbiter_agent_type,
|
||||
});
|
||||
|
||||
database
|
||||
.prepare(
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
|
||||
import {
|
||||
collectRegisteredAgentTypes,
|
||||
collectRegisteredAgentTypesForFolder,
|
||||
getStoredRoomSettingsRowFromDatabase,
|
||||
inferOwnerAgentTypeFromRegisteredAgentTypes,
|
||||
normalizeStoredAgentType,
|
||||
} from './room-registration.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
|
||||
interface StablePairedTaskRowInput {
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
owner_agent_type?: string | null;
|
||||
}
|
||||
|
||||
export function resolveStablePairedTaskOwnerAgentType(
|
||||
database: Database,
|
||||
task: StablePairedTaskRowInput,
|
||||
): AgentType | undefined {
|
||||
const persistedOwnerAgentType = normalizeStoredAgentType(
|
||||
task.owner_agent_type,
|
||||
);
|
||||
if (persistedOwnerAgentType) {
|
||||
return persistedOwnerAgentType;
|
||||
}
|
||||
|
||||
const stored = getStoredRoomSettingsRowFromDatabase(database, task.chat_jid);
|
||||
if (stored?.ownerAgentType) {
|
||||
return stored.ownerAgentType;
|
||||
}
|
||||
|
||||
const jidAgentTypes = collectRegisteredAgentTypes(database, task.chat_jid);
|
||||
if (jidAgentTypes.length > 0) {
|
||||
return inferOwnerAgentTypeFromRegisteredAgentTypes(jidAgentTypes);
|
||||
}
|
||||
|
||||
const folderAgentTypes = collectRegisteredAgentTypesForFolder(
|
||||
database,
|
||||
task.group_folder,
|
||||
);
|
||||
if (folderAgentTypes.length > 0) {
|
||||
return inferOwnerAgentTypeFromRegisteredAgentTypes(folderAgentTypes);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveStableReviewerAgentType(
|
||||
ownerAgentType: AgentType | undefined,
|
||||
fallbackReviewerAgentType?: string | null,
|
||||
): AgentType | null {
|
||||
const persistedReviewerAgentType = normalizeStoredAgentType(
|
||||
fallbackReviewerAgentType,
|
||||
);
|
||||
if (persistedReviewerAgentType) {
|
||||
return persistedReviewerAgentType;
|
||||
}
|
||||
|
||||
if (ownerAgentType) {
|
||||
return REVIEWER_AGENT_TYPE !== ownerAgentType
|
||||
? REVIEWER_AGENT_TYPE
|
||||
: ownerAgentType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveStableRoomRoleAgentType(
|
||||
database: Database,
|
||||
input: {
|
||||
chatJid: string;
|
||||
groupFolder: string;
|
||||
role: PairedRoomRole;
|
||||
},
|
||||
): AgentType | null | undefined {
|
||||
if (input.role === 'owner') {
|
||||
return resolveStablePairedTaskOwnerAgentType(database, {
|
||||
chat_jid: input.chatJid,
|
||||
group_folder: input.groupFolder,
|
||||
owner_agent_type: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.role === 'reviewer') {
|
||||
const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, {
|
||||
chat_jid: input.chatJid,
|
||||
group_folder: input.groupFolder,
|
||||
owner_agent_type: null,
|
||||
});
|
||||
return resolveStableReviewerAgentType(ownerAgentType, null);
|
||||
}
|
||||
|
||||
return ARBITER_AGENT_TYPE ?? null;
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { SERVICE_ID, normalizeServiceId } from '../config.js';
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
SERVICE_ID,
|
||||
normalizeServiceId,
|
||||
} from '../config.js';
|
||||
buildPairedTurnIdentity,
|
||||
resolvePairedTurnRole,
|
||||
} from '../paired-turn-identity.js';
|
||||
import { canonicalizePairedTaskMetadata } from './canonical-role-metadata.js';
|
||||
import {
|
||||
resolveStablePairedTaskOwnerAgentType,
|
||||
resolveStableReviewerAgentType,
|
||||
} from './legacy-rebuilds.js';
|
||||
import { normalizeStoredAgentType } from './room-registration.js';
|
||||
import {
|
||||
inferAgentTypeFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
ensurePairedTurnQueuedInDatabase,
|
||||
markPairedTurnRunningInDatabase,
|
||||
} from './paired-turns.js';
|
||||
import {
|
||||
AgentType,
|
||||
PairedProject,
|
||||
@@ -66,69 +60,31 @@ function computeExecutionLeaseExpiry(now: string): string {
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
function resolveExecutionLeaseRole(
|
||||
intentKind: PairedTurnReservationIntentKind,
|
||||
): PairedRoomRole {
|
||||
switch (intentKind) {
|
||||
case 'reviewer-turn':
|
||||
return 'reviewer';
|
||||
case 'arbiter-turn':
|
||||
return 'arbiter';
|
||||
case 'owner-turn':
|
||||
case 'owner-follow-up':
|
||||
case 'finalize-owner-turn':
|
||||
default:
|
||||
return 'owner';
|
||||
}
|
||||
}
|
||||
|
||||
function hydratePairedTaskRow(
|
||||
database: Database,
|
||||
row: StoredPairedTaskRow,
|
||||
): PairedTask {
|
||||
const persistedOwnerAgentType = normalizeStoredAgentType(
|
||||
row.owner_agent_type ?? null,
|
||||
);
|
||||
const persistedReviewerAgentType = normalizeStoredAgentType(
|
||||
row.reviewer_agent_type ?? null,
|
||||
);
|
||||
const stableOwnerAgentType = resolveStablePairedTaskOwnerAgentType(
|
||||
database,
|
||||
row,
|
||||
);
|
||||
const ownerAgentType =
|
||||
stableOwnerAgentType ??
|
||||
(row.owner_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.owner_service_id)
|
||||
: undefined);
|
||||
const stableReviewerAgentType = resolveStableReviewerAgentType(
|
||||
stableOwnerAgentType,
|
||||
row.reviewer_agent_type ?? null,
|
||||
);
|
||||
const reviewerAgentType =
|
||||
persistedReviewerAgentType ??
|
||||
stableReviewerAgentType ??
|
||||
(row.reviewer_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.reviewer_service_id)
|
||||
: null) ??
|
||||
resolveStableReviewerAgentType(ownerAgentType, null);
|
||||
const arbiterAgentType =
|
||||
normalizeStoredAgentType(row.arbiter_agent_type) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null;
|
||||
const {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
arbiterAgentType,
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
} = canonicalizePairedTaskMetadata({
|
||||
id: row.id,
|
||||
owner_service_id: row.owner_service_id,
|
||||
reviewer_service_id: row.reviewer_service_id,
|
||||
owner_agent_type: row.owner_agent_type,
|
||||
reviewer_agent_type: row.reviewer_agent_type,
|
||||
arbiter_agent_type: row.arbiter_agent_type,
|
||||
});
|
||||
|
||||
return {
|
||||
...row,
|
||||
owner_service_id:
|
||||
row.owner_service_id ??
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
reviewer_service_id:
|
||||
row.reviewer_service_id ??
|
||||
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
owner_agent_type: ownerAgentType ?? null,
|
||||
reviewer_agent_type: reviewerAgentType ?? null,
|
||||
owner_service_id: ownerServiceId,
|
||||
reviewer_service_id: reviewerServiceId,
|
||||
owner_agent_type: ownerAgentType,
|
||||
reviewer_agent_type: reviewerAgentType,
|
||||
arbiter_agent_type: arbiterAgentType,
|
||||
};
|
||||
}
|
||||
@@ -176,26 +132,20 @@ export function createPairedTaskInDatabase(
|
||||
database: Database,
|
||||
task: PairedTask,
|
||||
): void {
|
||||
const ownerAgentType =
|
||||
normalizeStoredAgentType(task.owner_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(task.owner_service_id) ??
|
||||
null;
|
||||
const reviewerAgentType =
|
||||
normalizeStoredAgentType(task.reviewer_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(task.reviewer_service_id) ??
|
||||
resolveStableReviewerAgentType(ownerAgentType ?? undefined, null);
|
||||
const arbiterAgentType =
|
||||
normalizeStoredAgentType(task.arbiter_agent_type) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null;
|
||||
const ownerServiceId =
|
||||
task.owner_service_id ??
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
CODEX_MAIN_SERVICE_ID;
|
||||
const reviewerServiceId =
|
||||
task.reviewer_service_id ??
|
||||
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
|
||||
CODEX_REVIEW_SERVICE_ID;
|
||||
const {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
arbiterAgentType,
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
} = canonicalizePairedTaskMetadata({
|
||||
id: task.id,
|
||||
owner_service_id: task.owner_service_id,
|
||||
reviewer_service_id: task.reviewer_service_id,
|
||||
owner_agent_type: task.owner_agent_type,
|
||||
reviewer_agent_type: task.reviewer_agent_type,
|
||||
arbiter_agent_type: task.arbiter_agent_type,
|
||||
});
|
||||
|
||||
database
|
||||
.prepare(
|
||||
@@ -426,6 +376,12 @@ export function reservePairedTurnReservationInDatabase(
|
||||
},
|
||||
): boolean {
|
||||
const now = new Date().toISOString();
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: args.taskId,
|
||||
taskUpdatedAt: args.taskUpdatedAt,
|
||||
intentKind: args.intentKind,
|
||||
role: resolvePairedTurnRole(args.intentKind),
|
||||
});
|
||||
const result = database
|
||||
.prepare(
|
||||
`
|
||||
@@ -435,6 +391,10 @@ export function reservePairedTurnReservationInDatabase(
|
||||
task_status,
|
||||
round_trip_count,
|
||||
task_updated_at,
|
||||
turn_id,
|
||||
turn_attempt_id,
|
||||
turn_attempt_no,
|
||||
turn_role,
|
||||
intent_kind,
|
||||
status,
|
||||
scheduled_run_id,
|
||||
@@ -443,8 +403,32 @@ export function reservePairedTurnReservationInDatabase(
|
||||
updated_at,
|
||||
consumed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?, NULL)
|
||||
ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO NOTHING
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'pending', ?, NULL, ?, ?, NULL)
|
||||
ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO UPDATE SET
|
||||
task_status = excluded.task_status,
|
||||
round_trip_count = excluded.round_trip_count,
|
||||
turn_id = excluded.turn_id,
|
||||
turn_attempt_id = NULL,
|
||||
turn_attempt_no = NULL,
|
||||
turn_role = excluded.turn_role,
|
||||
status = 'pending',
|
||||
scheduled_run_id = excluded.scheduled_run_id,
|
||||
consumed_run_id = NULL,
|
||||
updated_at = excluded.updated_at,
|
||||
consumed_at = NULL
|
||||
WHERE paired_turn_reservations.status = 'completed'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM paired_turn_attempts latest_attempt
|
||||
WHERE latest_attempt.turn_id = paired_turn_reservations.turn_id
|
||||
AND latest_attempt.state = 'failed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paired_turn_attempts newer_attempt
|
||||
WHERE newer_attempt.turn_id = latest_attempt.turn_id
|
||||
AND newer_attempt.attempt_no > latest_attempt.attempt_no
|
||||
)
|
||||
)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -453,13 +437,20 @@ export function reservePairedTurnReservationInDatabase(
|
||||
args.taskStatus,
|
||||
args.roundTripCount,
|
||||
args.taskUpdatedAt,
|
||||
turnIdentity.turnId,
|
||||
turnIdentity.role,
|
||||
args.intentKind,
|
||||
args.runId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
return result.changes > 0;
|
||||
if (result.changes > 0) {
|
||||
ensurePairedTurnQueuedInDatabase(database, turnIdentity);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
class PairedTurnReservationClaimError extends Error {}
|
||||
@@ -479,7 +470,12 @@ export function claimPairedTurnReservationInDatabase(
|
||||
const tx = database.transaction(() => {
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = computeExecutionLeaseExpiry(now);
|
||||
const leaseRole = resolveExecutionLeaseRole(args.intentKind);
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: args.taskId,
|
||||
taskUpdatedAt: args.taskUpdatedAt,
|
||||
intentKind: args.intentKind,
|
||||
role: resolvePairedTurnRole(args.intentKind),
|
||||
});
|
||||
const existingLease = database
|
||||
.prepare(
|
||||
`
|
||||
@@ -501,26 +497,30 @@ export function claimPairedTurnReservationInDatabase(
|
||||
const insertedLease = database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_task_execution_leases (
|
||||
task_id,
|
||||
chat_jid,
|
||||
role,
|
||||
intent_kind,
|
||||
claimed_run_id,
|
||||
claimed_service_id,
|
||||
INSERT INTO paired_task_execution_leases (
|
||||
task_id,
|
||||
chat_jid,
|
||||
role,
|
||||
turn_id,
|
||||
turn_attempt_id,
|
||||
turn_attempt_no,
|
||||
intent_kind,
|
||||
claimed_run_id,
|
||||
claimed_service_id,
|
||||
task_status,
|
||||
task_updated_at,
|
||||
claimed_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.taskId,
|
||||
args.chatJid,
|
||||
leaseRole,
|
||||
turnIdentity.role,
|
||||
turnIdentity.turnId,
|
||||
args.intentKind,
|
||||
args.runId,
|
||||
CURRENT_SERVICE_ID,
|
||||
@@ -543,6 +543,9 @@ export function claimPairedTurnReservationInDatabase(
|
||||
UPDATE paired_task_execution_leases
|
||||
SET chat_jid = ?,
|
||||
role = ?,
|
||||
turn_id = ?,
|
||||
turn_attempt_id = NULL,
|
||||
turn_attempt_no = NULL,
|
||||
intent_kind = ?,
|
||||
claimed_run_id = ?,
|
||||
claimed_service_id = ?,
|
||||
@@ -559,7 +562,8 @@ export function claimPairedTurnReservationInDatabase(
|
||||
)
|
||||
.run(
|
||||
args.chatJid,
|
||||
leaseRole,
|
||||
turnIdentity.role,
|
||||
turnIdentity.turnId,
|
||||
args.intentKind,
|
||||
args.runId,
|
||||
CURRENT_SERVICE_ID,
|
||||
@@ -579,31 +583,6 @@ export function claimPairedTurnReservationInDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_turn_reservations
|
||||
SET status = 'completed',
|
||||
consumed_run_id = ?,
|
||||
updated_at = ?,
|
||||
consumed_at = ?
|
||||
WHERE chat_jid = ?
|
||||
AND task_id = ?
|
||||
AND task_updated_at = ?
|
||||
AND intent_kind = ?
|
||||
AND status = 'pending'
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.runId,
|
||||
now,
|
||||
now,
|
||||
args.chatJid,
|
||||
args.taskId,
|
||||
args.taskUpdatedAt,
|
||||
args.intentKind,
|
||||
);
|
||||
|
||||
const claimedTask = database
|
||||
.prepare(
|
||||
`
|
||||
@@ -619,6 +598,67 @@ export function claimPairedTurnReservationInDatabase(
|
||||
if (claimedTask.changes === 0) {
|
||||
throw new PairedTurnReservationClaimError();
|
||||
}
|
||||
|
||||
const currentAttempt = markPairedTurnRunningInDatabase(database, {
|
||||
turnIdentity,
|
||||
executorServiceId: CURRENT_SERVICE_ID,
|
||||
runId: args.runId,
|
||||
});
|
||||
if (!currentAttempt) {
|
||||
throw new Error(
|
||||
`paired_turns(${turnIdentity.turnId}) did not materialize a running attempt row`,
|
||||
);
|
||||
}
|
||||
const turnAttemptNo = currentAttempt.attempt_no;
|
||||
const turnAttemptId = currentAttempt.attempt_id;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_task_execution_leases
|
||||
SET turn_attempt_id = ?,
|
||||
turn_attempt_no = ?
|
||||
WHERE task_id = ?
|
||||
AND claimed_service_id = ?
|
||||
AND claimed_run_id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
turnAttemptId,
|
||||
turnAttemptNo,
|
||||
args.taskId,
|
||||
CURRENT_SERVICE_ID,
|
||||
args.runId,
|
||||
);
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_turn_reservations
|
||||
SET status = 'completed',
|
||||
turn_attempt_id = ?,
|
||||
turn_attempt_no = ?,
|
||||
consumed_run_id = ?,
|
||||
updated_at = ?,
|
||||
consumed_at = ?
|
||||
WHERE chat_jid = ?
|
||||
AND task_id = ?
|
||||
AND task_updated_at = ?
|
||||
AND intent_kind = ?
|
||||
AND status = 'pending'
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
turnAttemptId,
|
||||
turnAttemptNo,
|
||||
args.runId,
|
||||
now,
|
||||
now,
|
||||
args.chatJid,
|
||||
args.taskId,
|
||||
args.taskUpdatedAt,
|
||||
args.intentKind,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
451
src/db/paired-turn-attempts.ts
Normal file
451
src/db/paired-turn-attempts.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { normalizeServiceId } from '../config.js';
|
||||
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
|
||||
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
|
||||
export type PairedTurnAttemptState =
|
||||
| 'running'
|
||||
| 'delegated'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface PairedTurnAttemptRecord {
|
||||
attempt_id: string;
|
||||
parent_attempt_id: string | null;
|
||||
parent_handoff_id: number | null;
|
||||
continuation_handoff_id: number | null;
|
||||
turn_id: string;
|
||||
attempt_no: number;
|
||||
task_id: string;
|
||||
task_updated_at: string;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
state: PairedTurnAttemptState;
|
||||
executor_service_id: string | null;
|
||||
executor_agent_type: AgentType | null;
|
||||
active_run_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
function resolveExecutorMetadata(args: {
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
}): {
|
||||
executorServiceId: string | null;
|
||||
executorAgentType: AgentType | null;
|
||||
} {
|
||||
const executorServiceId = args.executorServiceId
|
||||
? normalizeServiceId(args.executorServiceId)
|
||||
: null;
|
||||
const executorAgentType =
|
||||
args.executorAgentType ??
|
||||
inferAgentTypeFromServiceShadow(executorServiceId) ??
|
||||
null;
|
||||
return {
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPairedTurnAttemptId(
|
||||
turnId: string,
|
||||
attemptNo: number,
|
||||
): string {
|
||||
return `${turnId}:attempt:${attemptNo}`;
|
||||
}
|
||||
|
||||
export function buildPairedTurnAttemptParentId(
|
||||
turnId: string,
|
||||
attemptNo: number,
|
||||
): string | null {
|
||||
if (attemptNo <= 1) {
|
||||
return null;
|
||||
}
|
||||
return buildPairedTurnAttemptId(turnId, attemptNo - 1);
|
||||
}
|
||||
|
||||
function getPairedTurnAttemptParentIdFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
attemptNo: number,
|
||||
): string | null {
|
||||
if (attemptNo <= 1) {
|
||||
return null;
|
||||
}
|
||||
const parentAttemptId = getPairedTurnAttemptIdFromDatabase(
|
||||
database,
|
||||
turnId,
|
||||
attemptNo - 1,
|
||||
);
|
||||
if (parentAttemptId === null) {
|
||||
throw new Error(
|
||||
`paired_turn_attempts(${turnId}, attempt=${attemptNo}) must preserve contiguous parent lineage`,
|
||||
);
|
||||
}
|
||||
return parentAttemptId;
|
||||
}
|
||||
|
||||
function tableHasColumn(
|
||||
database: Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
const rows = database
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as Array<{ name: string }>;
|
||||
return rows.some((row) => row.name === columnName);
|
||||
}
|
||||
|
||||
export function syncPairedTurnAttemptInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnIdentity: PairedTurnIdentity;
|
||||
attemptNo: number;
|
||||
state: PairedTurnAttemptState;
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
activeRunId?: string | null;
|
||||
parentHandoffId?: number | null;
|
||||
continuationHandoffId?: number | null;
|
||||
now?: string;
|
||||
error?: string | null;
|
||||
},
|
||||
): void {
|
||||
if (args.attemptNo < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = args.now ?? new Date().toISOString();
|
||||
const terminal =
|
||||
args.state === 'completed' ||
|
||||
args.state === 'failed' ||
|
||||
args.state === 'cancelled';
|
||||
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
|
||||
executorServiceId: args.executorServiceId,
|
||||
executorAgentType: args.executorAgentType,
|
||||
});
|
||||
const activeRunId =
|
||||
args.state === 'running' ? (args.activeRunId ?? null) : null;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turn_attempts (
|
||||
attempt_id,
|
||||
parent_attempt_id,
|
||||
parent_handoff_id,
|
||||
continuation_handoff_id,
|
||||
turn_id,
|
||||
attempt_no,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
state,
|
||||
executor_service_id,
|
||||
executor_agent_type,
|
||||
active_run_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
last_error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id, attempt_no) DO UPDATE SET
|
||||
attempt_id = excluded.attempt_id,
|
||||
parent_attempt_id = excluded.parent_attempt_id,
|
||||
parent_handoff_id = CASE
|
||||
WHEN excluded.parent_handoff_id IS NOT NULL
|
||||
THEN excluded.parent_handoff_id
|
||||
ELSE paired_turn_attempts.parent_handoff_id
|
||||
END,
|
||||
continuation_handoff_id = CASE
|
||||
WHEN excluded.continuation_handoff_id IS NOT NULL
|
||||
THEN excluded.continuation_handoff_id
|
||||
ELSE paired_turn_attempts.continuation_handoff_id
|
||||
END,
|
||||
task_id = excluded.task_id,
|
||||
task_updated_at = excluded.task_updated_at,
|
||||
role = excluded.role,
|
||||
intent_kind = excluded.intent_kind,
|
||||
state = excluded.state,
|
||||
executor_service_id = COALESCE(
|
||||
excluded.executor_service_id,
|
||||
paired_turn_attempts.executor_service_id
|
||||
),
|
||||
executor_agent_type = COALESCE(
|
||||
excluded.executor_agent_type,
|
||||
paired_turn_attempts.executor_agent_type
|
||||
),
|
||||
active_run_id = CASE
|
||||
WHEN excluded.state = 'running'
|
||||
THEN excluded.active_run_id
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = excluded.updated_at,
|
||||
completed_at = excluded.completed_at,
|
||||
last_error = excluded.last_error
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
buildPairedTurnAttemptId(args.turnIdentity.turnId, args.attemptNo),
|
||||
getPairedTurnAttemptParentIdFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
args.attemptNo,
|
||||
),
|
||||
args.parentHandoffId ?? null,
|
||||
args.continuationHandoffId ?? null,
|
||||
args.turnIdentity.turnId,
|
||||
args.attemptNo,
|
||||
args.turnIdentity.taskId,
|
||||
args.turnIdentity.taskUpdatedAt,
|
||||
args.turnIdentity.role,
|
||||
args.turnIdentity.intentKind,
|
||||
args.state,
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
activeRunId,
|
||||
now,
|
||||
now,
|
||||
terminal ? now : null,
|
||||
args.error ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
export function backfillPairedTurnAttemptsFromTurns(database: Database): void {
|
||||
if (
|
||||
!tableHasColumn(database, 'paired_turns', 'state') ||
|
||||
!tableHasColumn(database, 'paired_turns', 'attempt_no')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_turns
|
||||
WHERE attempt_no >= 1
|
||||
AND state IN ('running', 'delegated', 'completed', 'failed', 'cancelled')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paired_turn_attempts existing_attempt
|
||||
WHERE existing_attempt.turn_id = paired_turns.turn_id
|
||||
AND existing_attempt.attempt_no = paired_turns.attempt_no
|
||||
)
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
attempt_id: string | null;
|
||||
parent_attempt_id: string | null;
|
||||
turn_id: string;
|
||||
task_id: string;
|
||||
task_updated_at: string;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
state: PairedTurnAttemptState;
|
||||
executor_service_id: string | null;
|
||||
executor_agent_type: AgentType | null;
|
||||
active_run_id: string | null;
|
||||
attempt_no: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
last_error: string | null;
|
||||
}>;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insert = database.prepare(
|
||||
`
|
||||
INSERT INTO paired_turn_attempts (
|
||||
attempt_id,
|
||||
parent_attempt_id,
|
||||
parent_handoff_id,
|
||||
continuation_handoff_id,
|
||||
turn_id,
|
||||
attempt_no,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
state,
|
||||
executor_service_id,
|
||||
executor_agent_type,
|
||||
active_run_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
last_error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id, attempt_no) DO NOTHING
|
||||
`,
|
||||
);
|
||||
|
||||
const tx = database.transaction(
|
||||
(
|
||||
attemptRows: Array<{
|
||||
attempt_id?: string | null;
|
||||
parent_attempt_id?: string | null;
|
||||
continuation_handoff_id?: number | null;
|
||||
turn_id: string;
|
||||
task_id: string;
|
||||
task_updated_at: string;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
state: PairedTurnAttemptState;
|
||||
executor_service_id: string | null;
|
||||
executor_agent_type: AgentType | null;
|
||||
active_run_id: string | null;
|
||||
attempt_no: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
last_error: string | null;
|
||||
}>,
|
||||
) => {
|
||||
for (const row of attemptRows) {
|
||||
const { executorServiceId, executorAgentType } =
|
||||
resolveExecutorMetadata({
|
||||
executorServiceId: row.executor_service_id,
|
||||
executorAgentType: row.executor_agent_type,
|
||||
});
|
||||
insert.run(
|
||||
row.attempt_id ??
|
||||
buildPairedTurnAttemptId(row.turn_id, row.attempt_no),
|
||||
row.parent_attempt_id ??
|
||||
getPairedTurnAttemptParentIdFromDatabase(
|
||||
database,
|
||||
row.turn_id,
|
||||
row.attempt_no,
|
||||
),
|
||||
null,
|
||||
row.continuation_handoff_id ?? null,
|
||||
row.turn_id,
|
||||
row.attempt_no,
|
||||
row.task_id,
|
||||
row.task_updated_at,
|
||||
row.role,
|
||||
row.intent_kind,
|
||||
row.state,
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
row.state === 'running' ? (row.active_run_id ?? null) : null,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.completed_at,
|
||||
row.last_error,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function getPairedTurnAttemptsForTurnFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
): PairedTurnAttemptRecord[] {
|
||||
return database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_turn_attempts
|
||||
WHERE turn_id = ?
|
||||
ORDER BY attempt_no ASC
|
||||
`,
|
||||
)
|
||||
.all(turnId) as PairedTurnAttemptRecord[];
|
||||
}
|
||||
|
||||
export function getCurrentPairedTurnAttemptForTurnFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
): PairedTurnAttemptRecord | undefined {
|
||||
return database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_turn_attempts
|
||||
WHERE turn_id = ?
|
||||
ORDER BY attempt_no DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(turnId) as PairedTurnAttemptRecord | undefined;
|
||||
}
|
||||
|
||||
export function getPairedTurnAttemptByNumberFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
attemptNo: number,
|
||||
): PairedTurnAttemptRecord | undefined {
|
||||
return database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_turn_attempts
|
||||
WHERE turn_id = ?
|
||||
AND attempt_no = ?
|
||||
`,
|
||||
)
|
||||
.get(turnId, attemptNo) as PairedTurnAttemptRecord | undefined;
|
||||
}
|
||||
|
||||
export function getPairedTurnAttemptIdFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
attemptNo: number,
|
||||
): string | null {
|
||||
const row = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT COALESCE(attempt_id, ? || ':attempt:' || CAST(? AS TEXT)) AS attempt_id
|
||||
FROM paired_turn_attempts
|
||||
WHERE turn_id = ?
|
||||
AND attempt_no = ?
|
||||
`,
|
||||
)
|
||||
.get(turnId, attemptNo, turnId, attemptNo) as
|
||||
| { attempt_id: string }
|
||||
| undefined;
|
||||
return row?.attempt_id ?? null;
|
||||
}
|
||||
|
||||
export function setPairedTurnAttemptContinuationHandoffIdInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnId: string;
|
||||
attemptNo: number;
|
||||
handoffId: number | null;
|
||||
},
|
||||
): void {
|
||||
if (args.attemptNo < 1) {
|
||||
return;
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_turn_attempts
|
||||
SET continuation_handoff_id = ?
|
||||
WHERE turn_id = ?
|
||||
AND attempt_no = ?
|
||||
`,
|
||||
)
|
||||
.run(args.handoffId, args.turnId, args.attemptNo);
|
||||
}
|
||||
|
||||
export function clearPairedTurnAttemptsInDatabase(database: Database): void {
|
||||
database.prepare('DELETE FROM paired_turn_attempts').run();
|
||||
}
|
||||
515
src/db/paired-turns.ts
Normal file
515
src/db/paired-turns.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { normalizeServiceId } from '../config.js';
|
||||
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
|
||||
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
import {
|
||||
getPairedTurnAttemptByNumberFromDatabase,
|
||||
getCurrentPairedTurnAttemptForTurnFromDatabase,
|
||||
type PairedTurnAttemptRecord,
|
||||
syncPairedTurnAttemptInDatabase,
|
||||
} from './paired-turn-attempts.js';
|
||||
|
||||
export type PairedTurnState =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'delegated'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface PairedTurnRecord {
|
||||
turn_id: string;
|
||||
task_id: string;
|
||||
task_updated_at: string;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
state: PairedTurnState;
|
||||
executor_service_id: string | null;
|
||||
executor_agent_type: AgentType | null;
|
||||
attempt_no: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
interface StoredPairedTurnRow {
|
||||
turn_id: string;
|
||||
task_id: string;
|
||||
task_updated_at: string;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function hydratePairedTurnRecord(
|
||||
row: StoredPairedTurnRow,
|
||||
currentAttemptRow?: PairedTurnAttemptRecord,
|
||||
): PairedTurnRecord {
|
||||
if (!currentAttemptRow) {
|
||||
return {
|
||||
...row,
|
||||
state: 'queued',
|
||||
executor_service_id: null,
|
||||
executor_agent_type: null,
|
||||
attempt_no: 0,
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
task_id: currentAttemptRow.task_id,
|
||||
task_updated_at: currentAttemptRow.task_updated_at,
|
||||
role: currentAttemptRow.role,
|
||||
intent_kind: currentAttemptRow.intent_kind,
|
||||
state: currentAttemptRow.state,
|
||||
executor_service_id: currentAttemptRow.executor_service_id,
|
||||
executor_agent_type: currentAttemptRow.executor_agent_type,
|
||||
attempt_no: currentAttemptRow.attempt_no,
|
||||
updated_at: currentAttemptRow.updated_at,
|
||||
completed_at: currentAttemptRow.completed_at,
|
||||
last_error: currentAttemptRow.last_error,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExecutorMetadata(args: {
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
}): {
|
||||
executorServiceId: string | null;
|
||||
executorAgentType: AgentType | null;
|
||||
} {
|
||||
const executorServiceId = args.executorServiceId
|
||||
? normalizeServiceId(args.executorServiceId)
|
||||
: null;
|
||||
const executorAgentType =
|
||||
args.executorAgentType ??
|
||||
inferAgentTypeFromServiceShadow(executorServiceId) ??
|
||||
null;
|
||||
return {
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
};
|
||||
}
|
||||
|
||||
function getRequiredPairedTurnAttemptByNumber(
|
||||
database: Database,
|
||||
turnIdentity: PairedTurnIdentity,
|
||||
attemptNo: number,
|
||||
): PairedTurnAttemptRecord {
|
||||
const currentAttemptRow = getPairedTurnAttemptByNumberFromDatabase(
|
||||
database,
|
||||
turnIdentity.turnId,
|
||||
attemptNo,
|
||||
);
|
||||
if (!currentAttemptRow) {
|
||||
throw new Error(
|
||||
`paired_turns(${turnIdentity.turnId}) did not materialize attempt ${attemptNo}`,
|
||||
);
|
||||
}
|
||||
return currentAttemptRow;
|
||||
}
|
||||
|
||||
export function ensurePairedTurnQueuedInDatabase(
|
||||
database: Database,
|
||||
turnIdentity: PairedTurnIdentity,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id) DO UPDATE SET
|
||||
task_id = excluded.task_id,
|
||||
task_updated_at = excluded.task_updated_at,
|
||||
role = excluded.role,
|
||||
intent_kind = excluded.intent_kind,
|
||||
updated_at = excluded.updated_at,
|
||||
created_at = paired_turns.created_at
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
turnIdentity.turnId,
|
||||
turnIdentity.taskId,
|
||||
turnIdentity.taskUpdatedAt,
|
||||
turnIdentity.role,
|
||||
turnIdentity.intentKind,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function markPairedTurnRunningInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnIdentity: PairedTurnIdentity;
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
runId?: string | null;
|
||||
},
|
||||
): PairedTurnAttemptRecord | undefined {
|
||||
const now = new Date().toISOString();
|
||||
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
|
||||
executorServiceId: args.executorServiceId,
|
||||
executorAgentType: args.executorAgentType,
|
||||
});
|
||||
const runId = args.runId ?? null;
|
||||
return database.transaction(() => {
|
||||
const previousTurnRow = getPairedTurnByIdFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
);
|
||||
const previousAttemptRow = previousTurnRow
|
||||
? getCurrentPairedTurnAttemptForTurnFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
)
|
||||
: undefined;
|
||||
const previousAttemptNo = previousAttemptRow?.attempt_no ?? 0;
|
||||
const previousAttemptActiveRunId =
|
||||
previousAttemptRow?.active_run_id ?? null;
|
||||
const isSameRunContinuation =
|
||||
previousAttemptRow?.state === 'running' &&
|
||||
previousAttemptActiveRunId !== null &&
|
||||
runId !== null &&
|
||||
previousAttemptActiveRunId === runId;
|
||||
const isDelegatedContinuation =
|
||||
previousAttemptRow?.state === 'delegated' &&
|
||||
(previousAttemptRow.executor_service_id ?? '') ===
|
||||
(executorServiceId ?? '') &&
|
||||
(previousAttemptRow.executor_agent_type ?? '') ===
|
||||
(executorAgentType ?? '');
|
||||
const nextAttemptNo = previousAttemptRow
|
||||
? isSameRunContinuation || isDelegatedContinuation
|
||||
? Math.max(previousAttemptNo, 1)
|
||||
: Math.max(previousAttemptNo, 0) + 1
|
||||
: 1;
|
||||
const nextAttemptParentHandoffId =
|
||||
previousAttemptRow && !isSameRunContinuation && !isDelegatedContinuation
|
||||
? previousAttemptRow.continuation_handoff_id
|
||||
: null;
|
||||
|
||||
if (
|
||||
previousTurnRow &&
|
||||
previousTurnRow.attempt_no >= 1 &&
|
||||
!previousAttemptRow
|
||||
) {
|
||||
throw new Error(
|
||||
`paired_turns(${args.turnIdentity.turnId}) cannot derive retry lineage because attempt ${previousTurnRow.attempt_no} is missing`,
|
||||
);
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id) DO UPDATE SET
|
||||
task_id = excluded.task_id,
|
||||
task_updated_at = excluded.task_updated_at,
|
||||
role = excluded.role,
|
||||
intent_kind = excluded.intent_kind,
|
||||
updated_at = excluded.updated_at,
|
||||
created_at = paired_turns.created_at
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.turnIdentity.turnId,
|
||||
args.turnIdentity.taskId,
|
||||
args.turnIdentity.taskUpdatedAt,
|
||||
args.turnIdentity.role,
|
||||
args.turnIdentity.intentKind,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
if (
|
||||
previousAttemptRow?.state === 'running' &&
|
||||
previousAttemptNo >= 1 &&
|
||||
previousAttemptActiveRunId &&
|
||||
runId &&
|
||||
previousAttemptActiveRunId !== runId
|
||||
) {
|
||||
syncPairedTurnAttemptInDatabase(database, {
|
||||
turnIdentity: args.turnIdentity,
|
||||
attemptNo: previousAttemptNo,
|
||||
state: 'cancelled',
|
||||
executorServiceId: previousAttemptRow.executor_service_id,
|
||||
executorAgentType: previousAttemptRow.executor_agent_type,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
syncPairedTurnAttemptInDatabase(database, {
|
||||
turnIdentity: args.turnIdentity,
|
||||
attemptNo: nextAttemptNo,
|
||||
state: 'running',
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
activeRunId: runId,
|
||||
parentHandoffId: nextAttemptParentHandoffId,
|
||||
});
|
||||
|
||||
return getRequiredPairedTurnAttemptByNumber(
|
||||
database,
|
||||
args.turnIdentity,
|
||||
nextAttemptNo,
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
export function markPairedTurnDelegatedInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnIdentity: PairedTurnIdentity;
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
},
|
||||
): PairedTurnAttemptRecord | undefined {
|
||||
const now = new Date().toISOString();
|
||||
const { executorServiceId, executorAgentType } = resolveExecutorMetadata({
|
||||
executorServiceId: args.executorServiceId,
|
||||
executorAgentType: args.executorAgentType,
|
||||
});
|
||||
return database.transaction(() => {
|
||||
const previousTurnRow = getPairedTurnByIdFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
);
|
||||
const currentAttemptRow = previousTurnRow
|
||||
? getCurrentPairedTurnAttemptForTurnFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
previousTurnRow &&
|
||||
previousTurnRow.attempt_no >= 1 &&
|
||||
!currentAttemptRow
|
||||
) {
|
||||
throw new Error(
|
||||
`paired_turns(${args.turnIdentity.turnId}) cannot mark delegated because attempt ${previousTurnRow.attempt_no} is missing`,
|
||||
);
|
||||
}
|
||||
const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1);
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id) DO UPDATE SET
|
||||
task_id = excluded.task_id,
|
||||
task_updated_at = excluded.task_updated_at,
|
||||
role = excluded.role,
|
||||
intent_kind = excluded.intent_kind,
|
||||
updated_at = excluded.updated_at,
|
||||
created_at = paired_turns.created_at
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.turnIdentity.turnId,
|
||||
args.turnIdentity.taskId,
|
||||
args.turnIdentity.taskUpdatedAt,
|
||||
args.turnIdentity.role,
|
||||
args.turnIdentity.intentKind,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
syncPairedTurnAttemptInDatabase(database, {
|
||||
turnIdentity: args.turnIdentity,
|
||||
attemptNo: currentAttemptNo,
|
||||
state: 'delegated',
|
||||
executorServiceId,
|
||||
executorAgentType,
|
||||
now,
|
||||
});
|
||||
|
||||
return getRequiredPairedTurnAttemptByNumber(
|
||||
database,
|
||||
args.turnIdentity,
|
||||
currentAttemptNo,
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
function markPairedTurnTerminalStateInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnIdentity: PairedTurnIdentity;
|
||||
state: 'completed' | 'failed' | 'cancelled';
|
||||
error?: string | null;
|
||||
},
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database.transaction(() => {
|
||||
const existingTurnRow = getPairedTurnByIdFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
);
|
||||
const currentAttemptRow = existingTurnRow
|
||||
? getCurrentPairedTurnAttemptForTurnFromDatabase(
|
||||
database,
|
||||
args.turnIdentity.turnId,
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
existingTurnRow &&
|
||||
existingTurnRow.attempt_no >= 1 &&
|
||||
!currentAttemptRow
|
||||
) {
|
||||
throw new Error(
|
||||
`paired_turns(${args.turnIdentity.turnId}) cannot mark ${args.state} because attempt ${existingTurnRow.attempt_no} is missing`,
|
||||
);
|
||||
}
|
||||
const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1);
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id,
|
||||
task_id,
|
||||
task_updated_at,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id) DO UPDATE SET
|
||||
task_id = excluded.task_id,
|
||||
task_updated_at = excluded.task_updated_at,
|
||||
role = excluded.role,
|
||||
intent_kind = excluded.intent_kind,
|
||||
updated_at = excluded.updated_at,
|
||||
created_at = paired_turns.created_at
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.turnIdentity.turnId,
|
||||
args.turnIdentity.taskId,
|
||||
args.turnIdentity.taskUpdatedAt,
|
||||
args.turnIdentity.role,
|
||||
args.turnIdentity.intentKind,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
syncPairedTurnAttemptInDatabase(database, {
|
||||
turnIdentity: args.turnIdentity,
|
||||
attemptNo: currentAttemptNo,
|
||||
state: args.state,
|
||||
executorServiceId: currentAttemptRow?.executor_service_id,
|
||||
executorAgentType: currentAttemptRow?.executor_agent_type,
|
||||
now,
|
||||
error: args.error,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
export function completePairedTurnInDatabase(
|
||||
database: Database,
|
||||
turnIdentity: PairedTurnIdentity,
|
||||
): void {
|
||||
markPairedTurnTerminalStateInDatabase(database, {
|
||||
turnIdentity,
|
||||
state: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
export function failPairedTurnInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
turnIdentity: PairedTurnIdentity;
|
||||
error?: string | null;
|
||||
},
|
||||
): void {
|
||||
markPairedTurnTerminalStateInDatabase(database, {
|
||||
turnIdentity: args.turnIdentity,
|
||||
state: 'failed',
|
||||
error: args.error,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPairedTurnByIdFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
): PairedTurnRecord | undefined {
|
||||
const row = database
|
||||
.prepare('SELECT * FROM paired_turns WHERE turn_id = ?')
|
||||
.get(turnId) as StoredPairedTurnRow | undefined;
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
return hydratePairedTurnRecord(
|
||||
row,
|
||||
getCurrentPairedTurnAttemptForTurnFromDatabase(database, turnId),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPairedTurnsForTaskFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
): PairedTurnRecord[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_turns
|
||||
WHERE COALESCE(
|
||||
(
|
||||
SELECT paired_turn_attempts.task_id
|
||||
FROM paired_turn_attempts
|
||||
WHERE paired_turn_attempts.turn_id = paired_turns.turn_id
|
||||
ORDER BY paired_turn_attempts.attempt_no DESC
|
||||
LIMIT 1
|
||||
),
|
||||
paired_turns.task_id
|
||||
) = ?
|
||||
ORDER BY created_at ASC, turn_id ASC
|
||||
`,
|
||||
)
|
||||
.all(taskId) as StoredPairedTurnRow[];
|
||||
return rows.map((row) =>
|
||||
hydratePairedTurnRecord(
|
||||
row,
|
||||
getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearPairedTurnsInDatabase(database: Database): void {
|
||||
database.prepare('DELETE FROM paired_turns').run();
|
||||
}
|
||||
@@ -47,6 +47,31 @@ export interface RegisteredGroupDatabaseRow {
|
||||
work_dir: string | null;
|
||||
}
|
||||
|
||||
export interface RoomRoleOverrideSnapshot {
|
||||
role: 'owner' | 'reviewer' | 'arbiter';
|
||||
agentType: AgentType;
|
||||
agentConfig?: RegisteredGroup['agentConfig'];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface StoredRoomRoleOverrideRow {
|
||||
role: 'owner' | 'reviewer' | 'arbiter';
|
||||
agentType: AgentType;
|
||||
agentConfig?: RegisteredGroup['agentConfig'];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LegacyRoomMigrationPlan {
|
||||
chatJid: string;
|
||||
roomMode: RoomMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
snapshot: RoomRegistrationSnapshot;
|
||||
roleOverrides: RoomRoleOverrideSnapshot[];
|
||||
}
|
||||
|
||||
export function normalizeRoomModeSource(
|
||||
source: string | null | undefined,
|
||||
): RoomModeSource | undefined {
|
||||
@@ -196,7 +221,132 @@ export function getLegacyRegisteredGroup(
|
||||
return parseRegisteredGroupRow(row);
|
||||
}
|
||||
|
||||
export function getRegisteredGroupCapabilityMetadata(
|
||||
export function getPendingLegacyRegisteredGroupJids(
|
||||
database: Database,
|
||||
): string[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT DISTINCT jid
|
||||
FROM registered_groups
|
||||
ORDER BY jid`,
|
||||
)
|
||||
.all() as Array<{ jid: string }>;
|
||||
|
||||
return rows
|
||||
.map((row) => row.jid)
|
||||
.filter((jid) => jidRequiresLegacyRoomMigration(database, jid));
|
||||
}
|
||||
|
||||
export function countPendingLegacyRegisteredGroupRows(
|
||||
database: Database,
|
||||
): number {
|
||||
let count = 0;
|
||||
const countRows = database.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM registered_groups
|
||||
WHERE jid = ?`,
|
||||
);
|
||||
|
||||
for (const jid of getPendingLegacyRegisteredGroupJids(database)) {
|
||||
const row = countRows.get(jid) as { count: number };
|
||||
count += row.count;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function deleteLegacyRegisteredGroupRowsForJid(
|
||||
database: Database,
|
||||
jid: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM registered_groups
|
||||
WHERE jid = ?`,
|
||||
)
|
||||
.run(jid);
|
||||
}
|
||||
|
||||
function getStoredRoomRoleOverrideRows(
|
||||
database: Database,
|
||||
jid: string,
|
||||
): Map<'owner' | 'reviewer' | 'arbiter', StoredRoomRoleOverrideRow> {
|
||||
let rows: Array<{
|
||||
role: 'owner' | 'reviewer' | 'arbiter';
|
||||
agent_type: string | null;
|
||||
agent_config_json: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}> = [];
|
||||
try {
|
||||
rows = database
|
||||
.prepare(
|
||||
`SELECT role, agent_type, agent_config_json, created_at, updated_at
|
||||
FROM room_role_overrides
|
||||
WHERE chat_jid = ?`,
|
||||
)
|
||||
.all(jid) as Array<{
|
||||
role: 'owner' | 'reviewer' | 'arbiter';
|
||||
agent_type: string | null;
|
||||
agent_config_json: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = new Map<
|
||||
'owner' | 'reviewer' | 'arbiter',
|
||||
StoredRoomRoleOverrideRow
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (!agentType) continue;
|
||||
result.set(row.role, {
|
||||
role: row.role,
|
||||
agentType,
|
||||
agentConfig: row.agent_config_json
|
||||
? JSON.parse(row.agent_config_json)
|
||||
: undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAgentConfigValue(
|
||||
agentConfig: RegisteredGroup['agentConfig'] | undefined,
|
||||
): string {
|
||||
return JSON.stringify(agentConfig ?? null);
|
||||
}
|
||||
|
||||
function roomRoleOverrideMatches(
|
||||
actual: StoredRoomRoleOverrideRow | undefined,
|
||||
expected: RoomRoleOverrideSnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
actual?.agentType === expected.agentType &&
|
||||
normalizeAgentConfigValue(actual.agentConfig) ===
|
||||
normalizeAgentConfigValue(expected.agentConfig)
|
||||
);
|
||||
}
|
||||
|
||||
function storedRoomSettingsMatchesLegacySnapshot(
|
||||
stored: StoredRoomSettings,
|
||||
snapshot: RoomRegistrationSnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
stored.name === snapshot.name &&
|
||||
stored.folder === snapshot.folder &&
|
||||
(stored.requiresTrigger ?? true) === snapshot.requiresTrigger &&
|
||||
(stored.isMain ?? false) === snapshot.isMain &&
|
||||
(stored.workDir ?? null) === (snapshot.workDir ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
function getRegisteredGroupCapabilityMetadata(
|
||||
database: Database,
|
||||
jid: string,
|
||||
preferredAgentType?: AgentType,
|
||||
@@ -229,6 +379,130 @@ export function getRegisteredGroupCapabilityMetadata(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReviewerAgentTypeFromRegisteredAgentTypes(
|
||||
agentTypes: readonly AgentType[],
|
||||
ownerAgentType: AgentType,
|
||||
): AgentType | undefined {
|
||||
return agentTypes.find((agentType) => agentType !== ownerAgentType);
|
||||
}
|
||||
|
||||
export function buildLegacyRoomMigrationPlan(
|
||||
database: Database,
|
||||
jid: string,
|
||||
): LegacyRoomMigrationPlan | undefined {
|
||||
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
|
||||
const snapshot = collectRoomRegistrationSnapshot(
|
||||
database,
|
||||
jid,
|
||||
existingStored,
|
||||
);
|
||||
if (!snapshot) return undefined;
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT added_at
|
||||
FROM registered_groups
|
||||
WHERE jid = ?
|
||||
ORDER BY added_at, agent_type`,
|
||||
)
|
||||
.all(jid) as Array<{ added_at: string }>;
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
const agentTypes = collectRegisteredAgentTypes(database, jid);
|
||||
const roomMode =
|
||||
existingStored?.roomMode ??
|
||||
inferRoomModeFromRegisteredAgentTypes(agentTypes);
|
||||
const createdAt = rows[0].added_at;
|
||||
const updatedAt = rows[rows.length - 1].added_at;
|
||||
const roleOverrides: RoomRoleOverrideSnapshot[] = [];
|
||||
|
||||
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
database,
|
||||
jid,
|
||||
snapshot.ownerAgentType,
|
||||
);
|
||||
roleOverrides.push({
|
||||
role: 'owner',
|
||||
agentType: snapshot.ownerAgentType,
|
||||
agentConfig: ownerMetadata?.agentConfig,
|
||||
createdAt: ownerMetadata?.added_at ?? createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
if (roomMode === 'tribunal') {
|
||||
const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes(
|
||||
agentTypes,
|
||||
snapshot.ownerAgentType,
|
||||
);
|
||||
if (!reviewerAgentType) {
|
||||
throw new Error(
|
||||
`Missing reviewer agent type for tribunal legacy room ${jid}`,
|
||||
);
|
||||
}
|
||||
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
database,
|
||||
jid,
|
||||
reviewerAgentType,
|
||||
);
|
||||
roleOverrides.push({
|
||||
role: 'reviewer',
|
||||
agentType: reviewerAgentType,
|
||||
agentConfig: reviewerMetadata?.agentConfig,
|
||||
createdAt: reviewerMetadata?.added_at ?? createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
chatJid: jid,
|
||||
roomMode,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
snapshot,
|
||||
roleOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jidRequiresLegacyRoomMigration(
|
||||
database: Database,
|
||||
jid: string,
|
||||
): boolean {
|
||||
let stored: StoredRoomSettings | undefined;
|
||||
try {
|
||||
stored = getStoredRoomSettingsRowFromDatabase(database, jid);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (!stored) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let plan: LegacyRoomMigrationPlan | undefined;
|
||||
try {
|
||||
plan = buildLegacyRoomMigrationPlan(database, jid);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (!plan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingOverrides = getStoredRoomRoleOverrideRows(database, jid);
|
||||
for (const override of plan.roleOverrides) {
|
||||
if (
|
||||
!roomRoleOverrideMatches(existingOverrides.get(override.role), override)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getStoredRoomSettingsRowFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
@@ -300,14 +574,16 @@ export function buildRegisteredGroupFromStoredSettings(
|
||||
requestedAgentType?: AgentType,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
|
||||
const ownerAgentType =
|
||||
stored.ownerAgentType ||
|
||||
(capabilityTypes.length > 0
|
||||
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
||||
: undefined);
|
||||
const resolvedAgentType = requestedAgentType
|
||||
? capabilityTypes.includes(requestedAgentType)
|
||||
? requestedAgentType
|
||||
: undefined
|
||||
: stored.ownerAgentType ||
|
||||
(capabilityTypes.length > 0
|
||||
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
||||
: undefined);
|
||||
: ownerAgentType;
|
||||
|
||||
if (!resolvedAgentType) return undefined;
|
||||
if (!stored.folder || !isValidGroupFolder(stored.folder)) {
|
||||
@@ -318,18 +594,19 @@ export function buildRegisteredGroupFromStoredSettings(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const capabilityMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
const role: 'owner' | 'reviewer' =
|
||||
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer';
|
||||
const capabilityMetadata = getStoredRoomRoleOverrideRows(
|
||||
database,
|
||||
stored.chatJid,
|
||||
resolvedAgentType,
|
||||
);
|
||||
).get(role);
|
||||
|
||||
return {
|
||||
jid: stored.chatJid,
|
||||
name: stored.name || stored.chatJid,
|
||||
folder: stored.folder,
|
||||
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
|
||||
added_at: capabilityMetadata?.added_at || new Date(0).toISOString(),
|
||||
added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(),
|
||||
agentConfig: capabilityMetadata?.agentConfig,
|
||||
requiresTrigger: stored.requiresTrigger,
|
||||
isMain: stored.isMain ? true : undefined,
|
||||
@@ -338,23 +615,43 @@ export function buildRegisteredGroupFromStoredSettings(
|
||||
};
|
||||
}
|
||||
|
||||
export function inferStoredRoomCapabilityTypes(
|
||||
database: Database,
|
||||
stored: StoredRoomSettings,
|
||||
): AgentType[] {
|
||||
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
||||
const inferredTypes = new Set<AgentType>();
|
||||
for (const override of overrides.values()) {
|
||||
inferredTypes.add(override.agentType);
|
||||
}
|
||||
if (inferredTypes.size === 0 && stored.ownerAgentType) {
|
||||
inferredTypes.add(stored.ownerAgentType);
|
||||
}
|
||||
return [...inferredTypes];
|
||||
}
|
||||
|
||||
export function resolveStoredRoomCapabilityTypes(
|
||||
database: Database,
|
||||
stored: StoredRoomSettings,
|
||||
): AgentType[] {
|
||||
const projectedTypes = collectRegisteredAgentTypes(database, stored.chatJid);
|
||||
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
||||
const ownerAgentType =
|
||||
stored.ownerAgentType ??
|
||||
overrides.get('owner')?.agentType ??
|
||||
OWNER_AGENT_TYPE;
|
||||
|
||||
if (stored.modeSource !== 'explicit') {
|
||||
return projectedTypes;
|
||||
return inferStoredRoomCapabilityTypes(database, stored);
|
||||
}
|
||||
|
||||
const ownerAgentType =
|
||||
stored.ownerAgentType ||
|
||||
(projectedTypes.length > 0
|
||||
? inferOwnerAgentTypeFromRegisteredAgentTypes(projectedTypes)
|
||||
: OWNER_AGENT_TYPE);
|
||||
const types = new Set<AgentType>([ownerAgentType]);
|
||||
if (stored.roomMode === 'tribunal') {
|
||||
types.add(REVIEWER_AGENT_TYPE);
|
||||
const reviewerAgentType =
|
||||
overrides.get('reviewer')?.agentType ??
|
||||
(ownerAgentType === REVIEWER_AGENT_TYPE
|
||||
? OWNER_AGENT_TYPE
|
||||
: REVIEWER_AGENT_TYPE);
|
||||
types.add(reviewerAgentType);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
@@ -659,6 +956,7 @@ export function insertStoredRoomSettings(
|
||||
source: RoomModeSource,
|
||||
snapshot: RoomRegistrationSnapshot,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
@@ -672,8 +970,9 @@ export function insertStoredRoomSettings(
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
chatJid,
|
||||
@@ -686,10 +985,150 @@ export function insertStoredRoomSettings(
|
||||
snapshot.isMain ? 1 : 0,
|
||||
snapshot.ownerAgentType,
|
||||
snapshot.workDir,
|
||||
new Date().toISOString(),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function insertStoredRoomSettingsFromMigration(
|
||||
database: Database,
|
||||
plan: LegacyRoomMigrationPlan,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_jid) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
plan.chatJid,
|
||||
plan.roomMode,
|
||||
'inferred',
|
||||
plan.snapshot.name,
|
||||
plan.snapshot.folder,
|
||||
plan.snapshot.triggerPattern,
|
||||
plan.snapshot.requiresTrigger ? 1 : 0,
|
||||
plan.snapshot.isMain ? 1 : 0,
|
||||
plan.snapshot.ownerAgentType,
|
||||
plan.snapshot.workDir,
|
||||
plan.createdAt,
|
||||
plan.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertRoomRoleOverride(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
override: RoomRoleOverrideSnapshot,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO room_role_overrides (
|
||||
chat_jid,
|
||||
role,
|
||||
agent_type,
|
||||
agent_config_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_jid, role) DO UPDATE SET
|
||||
agent_type = excluded.agent_type,
|
||||
agent_config_json = excluded.agent_config_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
chatJid,
|
||||
override.role,
|
||||
override.agentType,
|
||||
override.agentConfig ? JSON.stringify(override.agentConfig) : null,
|
||||
override.createdAt,
|
||||
override.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function syncRoomRoleOverridesForRoom(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
roomMode: RoomMode,
|
||||
ownerAgentType: AgentType,
|
||||
ownerAgentConfig?: RegisteredGroup['agentConfig'],
|
||||
addedAt?: string,
|
||||
): void {
|
||||
const now = addedAt ?? new Date().toISOString();
|
||||
const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid);
|
||||
const existingOwnerOverride = existingOverrides.get('owner');
|
||||
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
database,
|
||||
chatJid,
|
||||
ownerAgentType,
|
||||
);
|
||||
upsertRoomRoleOverride(database, chatJid, {
|
||||
role: 'owner',
|
||||
agentType: ownerAgentType,
|
||||
agentConfig:
|
||||
ownerAgentConfig ??
|
||||
(existingOwnerOverride?.agentType === ownerAgentType
|
||||
? existingOwnerOverride.agentConfig
|
||||
: undefined) ??
|
||||
ownerMetadata?.agentConfig,
|
||||
createdAt:
|
||||
(existingOwnerOverride?.agentType === ownerAgentType
|
||||
? existingOwnerOverride.createdAt
|
||||
: undefined) ??
|
||||
ownerMetadata?.added_at ??
|
||||
now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (roomMode === 'tribunal') {
|
||||
const reviewerAgentType =
|
||||
ownerAgentType === REVIEWER_AGENT_TYPE
|
||||
? OWNER_AGENT_TYPE
|
||||
: REVIEWER_AGENT_TYPE;
|
||||
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
database,
|
||||
chatJid,
|
||||
reviewerAgentType,
|
||||
);
|
||||
const existingReviewerOverride = existingOverrides.get('reviewer');
|
||||
upsertRoomRoleOverride(database, chatJid, {
|
||||
role: 'reviewer',
|
||||
agentType: reviewerAgentType,
|
||||
agentConfig:
|
||||
(existingReviewerOverride?.agentType === reviewerAgentType
|
||||
? existingReviewerOverride.agentConfig
|
||||
: undefined) ?? reviewerMetadata?.agentConfig,
|
||||
createdAt:
|
||||
(existingReviewerOverride?.agentType === reviewerAgentType
|
||||
? existingReviewerOverride.createdAt
|
||||
: undefined) ??
|
||||
reviewerMetadata?.added_at ??
|
||||
now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM room_role_overrides
|
||||
WHERE chat_jid = ?
|
||||
AND role = 'reviewer'`,
|
||||
)
|
||||
.run(chatJid);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateStoredRoomMetadata(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
@@ -65,6 +65,23 @@ export function setRouterStateForServiceInDatabase(
|
||||
.run(prefixedKey, value);
|
||||
}
|
||||
|
||||
export function getLegacyRouterStateKeysFromDatabase(
|
||||
database: Database,
|
||||
): string[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT key
|
||||
FROM router_state
|
||||
WHERE key IN ('last_timestamp', 'last_agent_timestamp')
|
||||
OR key LIKE '%:last_timestamp'
|
||||
OR key LIKE '%:last_agent_timestamp'
|
||||
ORDER BY key`,
|
||||
)
|
||||
.all() as Array<{ key: string }>;
|
||||
|
||||
return rows.map((row) => row.key);
|
||||
}
|
||||
|
||||
export function getLastRespondingAgentTypeFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
2504
src/db/schema.ts
2504
src/db/schema.ts
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,21 @@ import {
|
||||
SERVICE_ID,
|
||||
SERVICE_SESSION_SCOPE,
|
||||
} from '../config.js';
|
||||
import { resolveRoleServiceShadow } from '../role-service-shadow.js';
|
||||
import {
|
||||
buildPairedTurnIdentity,
|
||||
type PairedTurnIdentity,
|
||||
} from '../paired-turn-identity.js';
|
||||
import { AgentType, PairedRoomRole } from '../types.js';
|
||||
import { setPairedTurnAttemptContinuationHandoffIdInDatabase } from './paired-turn-attempts.js';
|
||||
import {
|
||||
failPairedTurnInDatabase,
|
||||
markPairedTurnDelegatedInDatabase,
|
||||
} from './paired-turns.js';
|
||||
import {
|
||||
getLatestMessageSeqAtOrBeforeFromDatabase,
|
||||
normalizeSeqCursor,
|
||||
} from './messages.js';
|
||||
import { resolveStableRoomRoleAgentType } from './legacy-rebuilds.js';
|
||||
import { normalizeStoredAgentType } from './room-registration.js';
|
||||
import { canonicalizeServiceHandoffMetadata } from './canonical-role-metadata.js';
|
||||
import {
|
||||
getRouterStateFromDatabase,
|
||||
setRouterStateInDatabase,
|
||||
@@ -22,6 +29,13 @@ export interface ServiceHandoff {
|
||||
id: number;
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
paired_task_id?: string | null;
|
||||
paired_task_updated_at?: string | null;
|
||||
turn_id?: string | null;
|
||||
turn_attempt_id?: string | null;
|
||||
turn_attempt_no?: number | null;
|
||||
turn_intent_kind?: PairedTurnIdentity['intentKind'] | null;
|
||||
turn_role?: PairedRoomRole | null;
|
||||
source_service_id: string;
|
||||
target_service_id: string;
|
||||
source_role: PairedRoomRole | null;
|
||||
@@ -43,6 +57,12 @@ export interface ServiceHandoff {
|
||||
export interface CreateServiceHandoffInput {
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
paired_task_id?: string | null;
|
||||
paired_task_updated_at?: string | null;
|
||||
turn_id?: string | null;
|
||||
turn_attempt_id?: string | null;
|
||||
turn_intent_kind?: PairedTurnIdentity['intentKind'] | null;
|
||||
turn_role?: PairedRoomRole | null;
|
||||
source_service_id?: string;
|
||||
target_service_id?: string;
|
||||
source_role?: PairedRoomRole | null;
|
||||
@@ -76,46 +96,72 @@ interface StoredServiceHandoffRow extends Omit<
|
||||
target_agent_type: string;
|
||||
}
|
||||
|
||||
function hydrateStoredTurnIdentity(
|
||||
row: Pick<
|
||||
StoredServiceHandoffRow,
|
||||
| 'paired_task_id'
|
||||
| 'paired_task_updated_at'
|
||||
| 'turn_id'
|
||||
| 'turn_intent_kind'
|
||||
| 'turn_role'
|
||||
>,
|
||||
): PairedTurnIdentity | null {
|
||||
if (
|
||||
!row.paired_task_id ||
|
||||
!row.paired_task_updated_at ||
|
||||
!row.turn_intent_kind
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildPairedTurnIdentity({
|
||||
taskId: row.paired_task_id,
|
||||
taskUpdatedAt: row.paired_task_updated_at,
|
||||
intentKind: row.turn_intent_kind,
|
||||
role: row.turn_role ?? undefined,
|
||||
turnId: row.turn_id ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateServiceHandoffRow(
|
||||
database: Database,
|
||||
row: StoredServiceHandoffRow,
|
||||
): ServiceHandoff {
|
||||
const sourceAgentType =
|
||||
normalizeStoredAgentType(row.source_agent_type) ??
|
||||
(row.source_role
|
||||
? resolveStableRoomRoleAgentType(database, {
|
||||
chatJid: row.chat_jid,
|
||||
groupFolder: row.group_folder,
|
||||
role: row.source_role,
|
||||
})
|
||||
: null);
|
||||
const targetAgentType =
|
||||
normalizeStoredAgentType(row.target_agent_type) ??
|
||||
(row.target_role
|
||||
? resolveStableRoomRoleAgentType(database, {
|
||||
chatJid: row.chat_jid,
|
||||
groupFolder: row.group_folder,
|
||||
role: row.target_role,
|
||||
})
|
||||
: null) ??
|
||||
'claude-code';
|
||||
const {
|
||||
sourceRole,
|
||||
targetRole,
|
||||
sourceAgentType,
|
||||
targetAgentType,
|
||||
sourceServiceId,
|
||||
targetServiceId,
|
||||
} = canonicalizeServiceHandoffMetadata({
|
||||
id: row.id,
|
||||
chat_jid: row.chat_jid,
|
||||
source_service_id: row.source_service_id,
|
||||
target_service_id: row.target_service_id,
|
||||
source_role: row.source_role,
|
||||
target_role: row.target_role,
|
||||
intended_role: row.intended_role,
|
||||
source_agent_type: row.source_agent_type,
|
||||
target_agent_type: row.target_agent_type,
|
||||
});
|
||||
const turnIdentity = hydrateStoredTurnIdentity(row);
|
||||
|
||||
return {
|
||||
...row,
|
||||
paired_task_id: turnIdentity?.taskId ?? row.paired_task_id ?? null,
|
||||
paired_task_updated_at:
|
||||
turnIdentity?.taskUpdatedAt ?? row.paired_task_updated_at ?? null,
|
||||
turn_id: turnIdentity?.turnId ?? row.turn_id ?? null,
|
||||
turn_attempt_id: row.turn_attempt_id ?? null,
|
||||
turn_attempt_no: row.turn_attempt_no ?? null,
|
||||
turn_intent_kind: turnIdentity?.intentKind ?? row.turn_intent_kind ?? null,
|
||||
turn_role: turnIdentity?.role ?? row.turn_role ?? null,
|
||||
source_role: sourceRole,
|
||||
target_role: targetRole,
|
||||
source_agent_type: sourceAgentType ?? null,
|
||||
target_agent_type: targetAgentType,
|
||||
source_service_id:
|
||||
row.source_service_id ??
|
||||
(row.source_role != null
|
||||
? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ??
|
||||
SERVICE_SESSION_SCOPE)
|
||||
: SERVICE_SESSION_SCOPE),
|
||||
target_service_id:
|
||||
row.target_service_id ??
|
||||
(row.target_role != null
|
||||
? (resolveRoleServiceShadow(row.target_role, targetAgentType) ??
|
||||
SERVICE_SESSION_SCOPE)
|
||||
: SERVICE_SESSION_SCOPE),
|
||||
source_service_id: sourceServiceId,
|
||||
target_service_id: targetServiceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,35 +230,66 @@ export function createServiceHandoffInDatabase(
|
||||
database: Database,
|
||||
input: CreateServiceHandoffInput,
|
||||
): ServiceHandoff {
|
||||
const sourceRole = input.source_role ?? input.intended_role ?? null;
|
||||
const targetRole = input.target_role ?? input.intended_role ?? null;
|
||||
const sourceAgentType =
|
||||
normalizeStoredAgentType(input.source_agent_type) ??
|
||||
(sourceRole
|
||||
? resolveStableRoomRoleAgentType(database, {
|
||||
chatJid: input.chat_jid,
|
||||
groupFolder: input.group_folder,
|
||||
role: sourceRole,
|
||||
const turnIdentity =
|
||||
input.paired_task_id &&
|
||||
input.paired_task_updated_at &&
|
||||
input.turn_intent_kind
|
||||
? buildPairedTurnIdentity({
|
||||
taskId: input.paired_task_id,
|
||||
taskUpdatedAt: input.paired_task_updated_at,
|
||||
intentKind: input.turn_intent_kind,
|
||||
role: input.turn_role ?? undefined,
|
||||
turnId: input.turn_id ?? undefined,
|
||||
})
|
||||
: null);
|
||||
const sourceServiceId =
|
||||
input.source_service_id ??
|
||||
(sourceRole != null
|
||||
? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ?? null)
|
||||
: null) ??
|
||||
SERVICE_SESSION_SCOPE;
|
||||
const targetServiceId =
|
||||
input.target_service_id ??
|
||||
(targetRole != null
|
||||
? (resolveRoleServiceShadow(targetRole, input.target_agent_type) ?? null)
|
||||
: null) ??
|
||||
SERVICE_SESSION_SCOPE;
|
||||
: null;
|
||||
const {
|
||||
sourceRole,
|
||||
targetRole,
|
||||
sourceAgentType,
|
||||
targetAgentType,
|
||||
sourceServiceId,
|
||||
targetServiceId,
|
||||
} = canonicalizeServiceHandoffMetadata({
|
||||
id: 'new',
|
||||
chat_jid: input.chat_jid,
|
||||
source_service_id: input.source_service_id,
|
||||
target_service_id: input.target_service_id,
|
||||
source_role: input.source_role,
|
||||
target_role: input.target_role,
|
||||
intended_role: input.intended_role,
|
||||
source_agent_type: input.source_agent_type,
|
||||
target_agent_type: input.target_agent_type,
|
||||
});
|
||||
|
||||
const currentAttempt = turnIdentity
|
||||
? markPairedTurnDelegatedInDatabase(database, {
|
||||
turnIdentity,
|
||||
executorServiceId: targetServiceId,
|
||||
executorAgentType: targetAgentType,
|
||||
})
|
||||
: undefined;
|
||||
if (turnIdentity) {
|
||||
if (!currentAttempt) {
|
||||
throw new Error(
|
||||
`paired_turns(${turnIdentity.turnId}) did not materialize a delegated attempt row`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const turnAttemptNo = currentAttempt?.attempt_no ?? null;
|
||||
const turnAttemptId = currentAttempt?.attempt_id ?? null;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO service_handoffs (
|
||||
chat_jid,
|
||||
group_folder,
|
||||
paired_task_id,
|
||||
paired_task_updated_at,
|
||||
turn_id,
|
||||
turn_attempt_id,
|
||||
turn_attempt_no,
|
||||
turn_intent_kind,
|
||||
turn_role,
|
||||
source_service_id,
|
||||
target_service_id,
|
||||
source_role,
|
||||
@@ -224,17 +301,24 @@ export function createServiceHandoffInDatabase(
|
||||
end_seq,
|
||||
reason,
|
||||
intended_role
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.chat_jid,
|
||||
input.group_folder,
|
||||
turnIdentity?.taskId ?? null,
|
||||
turnIdentity?.taskUpdatedAt ?? null,
|
||||
turnIdentity?.turnId ?? null,
|
||||
turnAttemptId,
|
||||
turnAttemptNo,
|
||||
turnIdentity?.intentKind ?? null,
|
||||
turnIdentity?.role ?? null,
|
||||
sourceServiceId,
|
||||
targetServiceId,
|
||||
sourceRole,
|
||||
sourceAgentType ?? null,
|
||||
targetRole,
|
||||
input.target_agent_type,
|
||||
targetAgentType,
|
||||
input.prompt,
|
||||
input.start_seq ?? null,
|
||||
input.end_seq ?? null,
|
||||
@@ -245,8 +329,14 @@ export function createServiceHandoffInDatabase(
|
||||
const lastId = (
|
||||
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
|
||||
).id;
|
||||
if (turnIdentity && currentAttempt) {
|
||||
setPairedTurnAttemptContinuationHandoffIdInDatabase(database, {
|
||||
turnId: turnIdentity.turnId,
|
||||
attemptNo: currentAttempt.attempt_no,
|
||||
handoffId: lastId,
|
||||
});
|
||||
}
|
||||
return hydrateServiceHandoffRow(
|
||||
database,
|
||||
database
|
||||
.prepare('SELECT * FROM service_handoffs WHERE id = ?')
|
||||
.get(lastId) as StoredServiceHandoffRow,
|
||||
@@ -258,7 +348,7 @@ export function getPendingServiceHandoffsFromDatabase(
|
||||
targetServiceId: string = SERVICE_SESSION_SCOPE,
|
||||
): ServiceHandoff[] {
|
||||
return getPendingServiceHandoffRows(database)
|
||||
.map((row) => hydrateServiceHandoffRow(database, row))
|
||||
.map((row) => hydrateServiceHandoffRow(row))
|
||||
.filter(
|
||||
(handoff) =>
|
||||
normalizeServiceId(handoff.target_service_id) ===
|
||||
@@ -270,7 +360,7 @@ export function getAllPendingServiceHandoffsFromDatabase(
|
||||
database: Database,
|
||||
): ServiceHandoff[] {
|
||||
return getPendingServiceHandoffRows(database).map((row) =>
|
||||
hydrateServiceHandoffRow(database, row),
|
||||
hydrateServiceHandoffRow(row),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,15 +402,36 @@ export function failServiceHandoffInDatabase(
|
||||
id: number,
|
||||
error: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE service_handoffs
|
||||
SET status = 'failed',
|
||||
completed_at = datetime('now'),
|
||||
last_error = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(error, id);
|
||||
database.transaction(() => {
|
||||
const row = database
|
||||
.prepare('SELECT * FROM service_handoffs WHERE id = ?')
|
||||
.get(id) as StoredServiceHandoffRow | undefined;
|
||||
let turnIdentity: PairedTurnIdentity | null = null;
|
||||
if (row) {
|
||||
try {
|
||||
turnIdentity = hydrateStoredTurnIdentity(row);
|
||||
} catch {
|
||||
turnIdentity = null;
|
||||
}
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE service_handoffs
|
||||
SET status = 'failed',
|
||||
completed_at = datetime('now'),
|
||||
last_error = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(error, id);
|
||||
|
||||
if (turnIdentity) {
|
||||
failPairedTurnInDatabase(database, {
|
||||
turnIdentity,
|
||||
error,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function completeServiceHandoffAndAdvanceTargetCursorInDatabase(
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
283
src/message-runtime-handoffs.test.ts
Normal file
283
src/message-runtime-handoffs.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
103
src/paired-turn-identity.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user