Files
EJClaw/src/db/paired-turns.ts
2026-04-29 20:04:47 +09:00

595 lines
16 KiB
TypeScript

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;
progress_text?: string | null;
progress_updated_at?: 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;
progress_text?: string | null;
progress_updated_at?: string | null;
}
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 cancelPairedTurnInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
error?: string | null;
},
): void {
markPairedTurnTerminalStateInDatabase(database, {
turnIdentity: args.turnIdentity,
state: 'cancelled',
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),
);
}
const updateProgressTextStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function updatePairedTurnProgressTextFromDatabase(
database: Database,
turnId: string,
progressText: string | null,
): void {
const now = new Date().toISOString();
let stmt = updateProgressTextStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
UPDATE paired_turns
SET progress_text = ?,
progress_updated_at = ?,
updated_at = ?
WHERE turn_id = ?
`);
updateProgressTextStmtCache.set(database, stmt);
}
stmt.run(progressText, now, now, turnId);
}
const latestPairedTurnStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function getLatestPairedTurnForTaskFromDatabase(
database: Database,
taskId: string,
): PairedTurnRecord | null {
let stmt = latestPairedTurnStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
SELECT *
FROM paired_turns
WHERE task_id = ?
ORDER BY CASE
WHEN progress_text IS NOT NULL
AND trim(progress_text) <> ''
AND progress_updated_at IS NOT NULL
AND progress_updated_at > updated_at
THEN progress_updated_at
ELSE updated_at
END DESC,
turn_id DESC
LIMIT 1
`);
latestPairedTurnStmtCache.set(database, stmt);
}
const row = stmt.get(taskId) as StoredPairedTurnRow | undefined;
if (!row) return null;
return hydratePairedTurnRecord(
row,
getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
);
}
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();
}