451 lines
12 KiB
TypeScript
451 lines
12 KiB
TypeScript
import { getPairedTurnOutputs, getRecentChatMessages } from './db.js';
|
|
import { logger } from './logger.js';
|
|
import {
|
|
buildArbiterPromptForTask,
|
|
buildPairedTurnPrompt,
|
|
} from './message-runtime-prompts.js';
|
|
import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js';
|
|
import {
|
|
buildPendingPairedTurn,
|
|
executePendingPairedTurn,
|
|
isBotOnlyPairedRoomTurn,
|
|
} from './message-runtime-flow.js';
|
|
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
|
import { resolveStoredVisibleVerdict } from './paired-verdict.js';
|
|
import {
|
|
advanceLastAgentCursor,
|
|
finalizeQueuedRunCursor,
|
|
resolveActiveRole,
|
|
resolveCursorKeyForRole,
|
|
resolveQueuedPairedTurnRole,
|
|
resolveQueuedTurnRole,
|
|
} from './message-runtime-rules.js';
|
|
import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js';
|
|
import type {
|
|
ExecuteTurnFn,
|
|
RoleToChannelMap,
|
|
} from './message-runtime-types.js';
|
|
import type {
|
|
Channel,
|
|
NewMessage,
|
|
PairedTask,
|
|
PairedTaskStatus,
|
|
PairedTurnReservationIntentKind,
|
|
RegisteredGroup,
|
|
} from './types.js';
|
|
|
|
function resolveQueuedTurnReservationIntent(args: {
|
|
task: PairedTask;
|
|
turnRole: 'owner' | 'reviewer' | 'arbiter';
|
|
hasHumanMessage: boolean;
|
|
}): PairedTurnReservationIntentKind {
|
|
if (args.turnRole === 'reviewer') {
|
|
return 'reviewer-turn';
|
|
}
|
|
if (args.turnRole === 'arbiter') {
|
|
return 'arbiter-turn';
|
|
}
|
|
if (args.hasHumanMessage) {
|
|
return 'owner-turn';
|
|
}
|
|
if (args.task.status === 'merge_ready') {
|
|
return 'finalize-owner-turn';
|
|
}
|
|
return 'owner-follow-up';
|
|
}
|
|
|
|
function buildQueuedGroupTurnPrompt(args: {
|
|
turnRole: 'owner' | 'reviewer' | 'arbiter';
|
|
currentTask: PairedTask | null | undefined;
|
|
chatJid: string;
|
|
timezone: string;
|
|
missedMessages: NewMessage[];
|
|
fallbackMessages: NewMessage[];
|
|
turnOutputs: ReturnType<typeof getPairedTurnOutputs>;
|
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
|
}): string {
|
|
if (args.turnRole === 'arbiter' && args.currentTask) {
|
|
const recentMessages = getRecentChatMessages(args.chatJid, 20);
|
|
return buildArbiterPromptForTask({
|
|
task: args.currentTask,
|
|
chatJid: args.chatJid,
|
|
timezone: args.timezone,
|
|
turnOutputs: args.turnOutputs,
|
|
recentMessages,
|
|
labeledRecentMessages: args.labelPairedSenders(
|
|
args.chatJid,
|
|
recentMessages,
|
|
),
|
|
});
|
|
}
|
|
|
|
if (args.currentTask) {
|
|
return buildPairedTurnPrompt({
|
|
taskId: args.currentTask.id,
|
|
chatJid: args.chatJid,
|
|
timezone: args.timezone,
|
|
missedMessages: args.missedMessages,
|
|
labeledFallbackMessages: args.labelPairedSenders(
|
|
args.chatJid,
|
|
args.fallbackMessages,
|
|
),
|
|
turnOutputs: args.turnOutputs,
|
|
});
|
|
}
|
|
|
|
return args.formatMessages(
|
|
args.labelPairedSenders(args.chatJid, args.missedMessages),
|
|
args.timezone,
|
|
);
|
|
}
|
|
|
|
function claimQueuedPairedTurn(args: {
|
|
chatJid: string;
|
|
runId: string;
|
|
task: PairedTask | null | undefined;
|
|
taskStatus: PairedTaskStatus | null | undefined;
|
|
intentKind: PairedTurnReservationIntentKind | null;
|
|
turnRole: 'owner' | 'reviewer' | 'arbiter';
|
|
log: typeof logger;
|
|
}): boolean {
|
|
if (!args.task) {
|
|
return true;
|
|
}
|
|
const claimed = claimPairedTurnExecution({
|
|
chatJid: args.chatJid,
|
|
runId: args.runId,
|
|
task: args.task,
|
|
intentKind: args.intentKind!,
|
|
});
|
|
if (claimed) {
|
|
return true;
|
|
}
|
|
args.log.info(
|
|
{
|
|
taskId: args.task.id,
|
|
taskStatus: args.taskStatus,
|
|
taskUpdatedAt: args.task.updated_at,
|
|
intentKind: args.intentKind,
|
|
turnRole: args.turnRole,
|
|
},
|
|
'Skipped queued paired turn because the task revision was already claimed elsewhere',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
export async function runPendingPairedTurnIfNeeded(args: {
|
|
chatJid: string;
|
|
group: RegisteredGroup;
|
|
runId: string;
|
|
log: typeof logger;
|
|
timezone: string;
|
|
task: PairedTask | null | undefined;
|
|
rawMissedMessages: NewMessage[];
|
|
saveState: () => void;
|
|
lastAgentTimestamps: Record<string, string>;
|
|
executeTurn: ExecuteTurnFn;
|
|
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
|
|
roleToChannel: RoleToChannelMap;
|
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
|
mode: 'idle' | 'bot-only';
|
|
missedMessages?: NewMessage[];
|
|
}): Promise<boolean | null> {
|
|
const { chatJid, task, roleToChannel } = args;
|
|
if (!task) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
args.mode === 'bot-only' &&
|
|
(!args.missedMessages ||
|
|
!isBotOnlyPairedRoomTurn(chatJid, args.missedMessages))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const recentMessages = getRecentChatMessages(chatJid, 20);
|
|
const recentHumanMessages = recentMessages.filter(
|
|
(message) => !message.is_bot_message,
|
|
);
|
|
const labeledRecentMessages = args.labelPairedSenders(
|
|
chatJid,
|
|
recentMessages,
|
|
);
|
|
const pendingTurn = buildPendingPairedTurn({
|
|
chatJid,
|
|
timezone: args.timezone,
|
|
task,
|
|
rawMissedMessages: args.rawMissedMessages,
|
|
recentHumanMessages,
|
|
labeledRecentMessages,
|
|
resolveChannel: (taskStatus) =>
|
|
roleToChannel[resolveActiveRole(taskStatus)] ?? null,
|
|
});
|
|
|
|
if (!pendingTurn) {
|
|
return null;
|
|
}
|
|
|
|
if (pendingTurn.channel) {
|
|
const claimed = claimPairedTurnExecution({
|
|
chatJid,
|
|
runId: args.runId,
|
|
task,
|
|
intentKind: pendingTurn.intentKind,
|
|
});
|
|
if (!claimed) {
|
|
args.log.info(
|
|
{
|
|
chatJid,
|
|
taskId: task.id,
|
|
taskStatus: task.status,
|
|
taskUpdatedAt: task.updated_at,
|
|
intentKind: pendingTurn.intentKind,
|
|
},
|
|
'Skipped paired pending turn because the task revision was already claimed elsewhere',
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return executePendingPairedTurn({
|
|
pendingTurn,
|
|
chatJid,
|
|
group: args.group,
|
|
runId: args.runId,
|
|
log: args.log,
|
|
saveState: args.saveState,
|
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
|
executeTurn: args.executeTurn,
|
|
getFixedRoleChannelName: args.getFixedRoleChannelName,
|
|
});
|
|
}
|
|
|
|
export async function runQueuedGroupTurn(args: {
|
|
chatJid: string;
|
|
group: RegisteredGroup;
|
|
runId: string;
|
|
log: typeof logger;
|
|
timezone: string;
|
|
missedMessages: NewMessage[];
|
|
task: PairedTask | null | undefined;
|
|
roleToChannel: RoleToChannelMap;
|
|
ownerChannel: Channel;
|
|
lastAgentTimestamps: Record<string, string>;
|
|
saveState: () => void;
|
|
executeTurn: ExecuteTurnFn;
|
|
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
|
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
|
}): Promise<boolean> {
|
|
const { chatJid, group, runId, log, missedMessages, task, roleToChannel } =
|
|
args;
|
|
let currentTask = task;
|
|
const hasHumanMsg = task
|
|
? !missedMessages.every(
|
|
(message) => message.is_from_me === true || !!message.is_bot_message,
|
|
)
|
|
: !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
|
|
let fallbackMessages = missedMessages;
|
|
if (currentTask !== undefined && hasHumanMsg) {
|
|
const resolvedTask = resolveOwnerTaskForHumanMessage({
|
|
group,
|
|
chatJid,
|
|
existingTask: currentTask ?? null,
|
|
});
|
|
currentTask = resolvedTask.task;
|
|
if (resolvedTask.supersededTask) {
|
|
fallbackMessages = getRecentChatMessages(chatJid, 20).filter(
|
|
(message) => !message.is_bot_message,
|
|
);
|
|
}
|
|
}
|
|
const taskStatus = currentTask?.status;
|
|
const turnOutputs = currentTask ? getPairedTurnOutputs(currentTask.id) : [];
|
|
const lastTurnOutputRole = currentTask
|
|
? (turnOutputs.at(-1)?.role ?? null)
|
|
: null;
|
|
const lastTurnOutputVerdict = currentTask
|
|
? resolveStoredVisibleVerdict({
|
|
verdict: turnOutputs.at(-1)?.verdict ?? null,
|
|
outputText: turnOutputs.at(-1)?.output_text ?? null,
|
|
})
|
|
: null;
|
|
const turnRole = currentTask
|
|
? hasHumanMsg
|
|
? resolveQueuedTurnRole({
|
|
taskStatus,
|
|
hasHumanMessage: true,
|
|
})
|
|
: resolveQueuedPairedTurnRole({
|
|
taskStatus,
|
|
hasHumanMessage: false,
|
|
lastTurnOutputRole,
|
|
lastTurnOutputVerdict,
|
|
})
|
|
: 'owner';
|
|
if (!turnRole) {
|
|
const endSeq = missedMessages[missedMessages.length - 1]?.seq ?? null;
|
|
if (endSeq !== null) {
|
|
advanceLastAgentCursor(
|
|
args.lastAgentTimestamps,
|
|
args.saveState,
|
|
chatJid,
|
|
endSeq,
|
|
resolveCursorKeyForRole(chatJid, resolveActiveRole(taskStatus)),
|
|
);
|
|
}
|
|
log.info(
|
|
{
|
|
chatJid,
|
|
taskId: task?.id ?? null,
|
|
taskStatus,
|
|
lastTurnOutputRole,
|
|
},
|
|
'Skipped queued paired turn because the latest persisted turn already closed the pending handoff',
|
|
);
|
|
return true;
|
|
}
|
|
const turnChannel =
|
|
turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole];
|
|
const cursorKey = resolveCursorKeyForRole(chatJid, turnRole);
|
|
const forcedRole = currentTask ? turnRole : undefined;
|
|
const queuedIntentKind = currentTask
|
|
? resolveQueuedTurnReservationIntent({
|
|
task: currentTask,
|
|
turnRole,
|
|
hasHumanMessage: hasHumanMsg,
|
|
})
|
|
: null;
|
|
const pairedTurnIdentity =
|
|
currentTask && queuedIntentKind
|
|
? buildPairedTurnIdentity({
|
|
taskId: currentTask.id,
|
|
taskUpdatedAt: currentTask.updated_at,
|
|
intentKind: queuedIntentKind,
|
|
role: turnRole,
|
|
})
|
|
: undefined;
|
|
|
|
const prompt = buildQueuedGroupTurnPrompt({
|
|
turnRole,
|
|
currentTask,
|
|
chatJid,
|
|
timezone: args.timezone,
|
|
missedMessages,
|
|
fallbackMessages,
|
|
turnOutputs,
|
|
labelPairedSenders: args.labelPairedSenders,
|
|
formatMessages: args.formatMessages,
|
|
});
|
|
|
|
const startSeq = missedMessages[0].seq ?? null;
|
|
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
|
|
log.info(
|
|
{
|
|
messageCount: missedMessages.length,
|
|
messageSeqStart: startSeq,
|
|
messageSeqEnd: endSeq,
|
|
},
|
|
'Dispatching queued messages to agent',
|
|
);
|
|
|
|
if (!turnChannel) {
|
|
const missingRole = turnRole === 'arbiter' ? 'arbiter' : 'reviewer';
|
|
log.error(
|
|
{
|
|
taskStatus,
|
|
role: turnRole,
|
|
requiredChannel: args.getFixedRoleChannelName(missingRole),
|
|
},
|
|
'Skipping paired-room run because the dedicated Discord role channel is not configured',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
!claimQueuedPairedTurn({
|
|
chatJid,
|
|
runId,
|
|
task: currentTask,
|
|
taskStatus,
|
|
intentKind: queuedIntentKind!,
|
|
turnRole,
|
|
log,
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const previousCursor = args.lastAgentTimestamps[cursorKey];
|
|
const cursorAdvanced = endSeq !== null;
|
|
if (cursorAdvanced) {
|
|
advanceLastAgentCursor(
|
|
args.lastAgentTimestamps,
|
|
args.saveState,
|
|
chatJid,
|
|
endSeq,
|
|
cursorKey,
|
|
);
|
|
}
|
|
|
|
const { outputStatus, deliverySucceeded, visiblePhase } =
|
|
await args.executeTurn({
|
|
group,
|
|
prompt,
|
|
chatJid,
|
|
runId,
|
|
channel: turnChannel,
|
|
deliveryRole: currentTask ? turnRole : undefined,
|
|
startSeq,
|
|
endSeq,
|
|
hasHumanMessage: hasHumanMsg,
|
|
forcedRole,
|
|
pairedTurnIdentity,
|
|
});
|
|
|
|
if (
|
|
!finalizeQueuedRunCursor({
|
|
outputStatus,
|
|
visiblePhase,
|
|
startSeq,
|
|
endSeq,
|
|
rollbackOnSilentError:
|
|
hasHumanMsg &&
|
|
turnRole === 'owner' &&
|
|
currentTask?.owner_agent_type === 'codex',
|
|
cursorAdvanced,
|
|
previousCursor,
|
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
|
saveState: args.saveState,
|
|
cursorKey,
|
|
log,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (!deliverySucceeded) {
|
|
log.warn(
|
|
{
|
|
messageSeqStart: startSeq,
|
|
messageSeqEnd: endSeq,
|
|
},
|
|
'Persisted produced output for delivery retry without rerunning agent',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
log.info(
|
|
{
|
|
visiblePhase,
|
|
messageSeqStart: startSeq,
|
|
messageSeqEnd: endSeq,
|
|
},
|
|
'Queued run completed successfully',
|
|
);
|
|
|
|
return true;
|
|
}
|