Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user