refactor: decompose runtime coordination and runner internals (PR6)
This commit is contained in:
267
src/message-runtime-turns.ts
Normal file
267
src/message-runtime-turns.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { type AgentOutput } from './agent-runner.js';
|
||||
import { getLastBotFinalMessage } from './db.js';
|
||||
import { runAgentForGroup } from './message-agent-executor.js';
|
||||
import { MessageTurnController } from './message-turn-controller.js';
|
||||
import {
|
||||
getEffectiveChannelLease,
|
||||
hasReviewerLease,
|
||||
resolveLeaseServiceId,
|
||||
} from './service-routing.js';
|
||||
import { normalizeMessageForDedupe } from './router.js';
|
||||
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||
import type {
|
||||
Channel,
|
||||
NewMessage,
|
||||
AgentType,
|
||||
PairedRoomRole,
|
||||
RegisteredGroup,
|
||||
} from './types.js';
|
||||
import type { GroupQueue } from './group-queue.js';
|
||||
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export function isDuplicateOfLastBotFinal(
|
||||
chatJid: string,
|
||||
text: string,
|
||||
): boolean {
|
||||
if (!hasReviewerLease(chatJid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMessages = getLastBotFinalMessage(chatJid, 'claude-code', 1);
|
||||
if (lastMessages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMessage = lastMessages[0];
|
||||
const normalizedLast = normalizeMessageForDedupe(lastMessage.content);
|
||||
const normalizedCurrent = normalizeMessageForDedupe(text);
|
||||
|
||||
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
|
||||
}
|
||||
|
||||
export function labelPairedSenders(
|
||||
channels: Channel[],
|
||||
chatJid: string,
|
||||
messages: NewMessage[],
|
||||
): NewMessage[] {
|
||||
if (!hasReviewerLease(chatJid)) return messages;
|
||||
|
||||
const botIdToChannelName = new Map<string, string>();
|
||||
for (const ch of channels) {
|
||||
if (!ch.isConnected()) continue;
|
||||
for (const msg of messages) {
|
||||
if (msg.is_bot_message && ch.isOwnMessage?.(msg)) {
|
||||
botIdToChannelName.set(msg.sender, ch.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channelToRole: Record<string, PairedRoomRole> = {
|
||||
discord: 'owner',
|
||||
'discord-review': 'reviewer',
|
||||
'discord-arbiter': 'arbiter',
|
||||
};
|
||||
|
||||
return messages.map((msg) => {
|
||||
if (!msg.is_bot_message) return msg;
|
||||
const channelName = botIdToChannelName.get(msg.sender);
|
||||
if (!channelName) return msg;
|
||||
const role = channelToRole[channelName];
|
||||
return role ? { ...msg, sender_name: role } : msg;
|
||||
});
|
||||
}
|
||||
|
||||
interface CreateExecuteTurnDeps {
|
||||
runAgent: (
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string,
|
||||
runId: string,
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
options?: {
|
||||
startSeq?: number | null;
|
||||
endSeq?: number | null;
|
||||
hasHumanMessage?: boolean;
|
||||
forcedRole?: PairedRoomRole;
|
||||
forcedAgentType?: AgentType;
|
||||
pairedTurnIdentity?: PairedTurnIdentity;
|
||||
},
|
||||
) => Promise<'success' | 'error'>;
|
||||
assistantName: string;
|
||||
idleTimeout: number;
|
||||
failureFinalText: string;
|
||||
channels: Channel[];
|
||||
queue: GroupQueue;
|
||||
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
||||
deliverFinalText: (args: {
|
||||
text: string;
|
||||
chatJid: string;
|
||||
runId: string;
|
||||
channel: Channel;
|
||||
group: RegisteredGroup;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
forcedAgentType?: AgentType;
|
||||
deliveryRole: PairedRoomRole | null;
|
||||
deliveryServiceId: string | null;
|
||||
}) => Promise<boolean>;
|
||||
afterDeliverySuccess?: (args: {
|
||||
chatJid: string;
|
||||
runId: string;
|
||||
deliveryRole: PairedRoomRole | null;
|
||||
pairedRoom: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createRunAgent(deps: {
|
||||
assistantName: string;
|
||||
queue: GroupQueue;
|
||||
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
||||
}) {
|
||||
return async (
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string,
|
||||
runId: string,
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
options?: {
|
||||
startSeq?: number | null;
|
||||
endSeq?: number | null;
|
||||
hasHumanMessage?: boolean;
|
||||
forcedRole?: PairedRoomRole;
|
||||
forcedAgentType?: AgentType;
|
||||
pairedTurnIdentity?: PairedTurnIdentity;
|
||||
},
|
||||
): Promise<'success' | 'error'> =>
|
||||
runAgentForGroup(deps, {
|
||||
group,
|
||||
prompt,
|
||||
chatJid,
|
||||
runId,
|
||||
startSeq: options?.startSeq,
|
||||
endSeq: options?.endSeq,
|
||||
hasHumanMessage: options?.hasHumanMessage,
|
||||
forcedRole: options?.forcedRole,
|
||||
forcedAgentType: options?.forcedAgentType,
|
||||
pairedTurnIdentity: options?.pairedTurnIdentity,
|
||||
onOutput,
|
||||
});
|
||||
}
|
||||
|
||||
export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
||||
return async (args) => {
|
||||
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
|
||||
const isClaudeCodeAgent =
|
||||
(args.forcedAgentType ?? group.agentType ?? 'claude-code') ===
|
||||
'claude-code';
|
||||
const pairedRoom = hasReviewerLease(chatJid);
|
||||
const resolvedDeliveryRole =
|
||||
args.deliveryRole ?? args.forcedRole ?? (pairedRoom ? 'owner' : null);
|
||||
const resolvedDeliveryServiceId = resolveLeaseServiceId(
|
||||
getEffectiveChannelLease(chatJid),
|
||||
resolvedDeliveryRole ?? 'owner',
|
||||
);
|
||||
const allowProgressReplayWithoutFinal =
|
||||
args.pairedTurnIdentity?.role !== 'reviewer' &&
|
||||
args.pairedTurnIdentity?.role !== 'arbiter';
|
||||
const turnController = new MessageTurnController({
|
||||
chatJid,
|
||||
group,
|
||||
runId,
|
||||
channel,
|
||||
idleTimeout: deps.idleTimeout,
|
||||
failureFinalText: deps.failureFinalText,
|
||||
isClaudeCodeAgent,
|
||||
clearSession: () => deps.clearSession(group.folder),
|
||||
requestClose: (reason) =>
|
||||
deps.queue.closeStdin(chatJid, { runId, reason }),
|
||||
allowProgressReplayWithoutFinal,
|
||||
deliveryRole: resolvedDeliveryRole,
|
||||
deliveryServiceId: resolvedDeliveryServiceId,
|
||||
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
|
||||
deliverFinalText: async (text) => {
|
||||
try {
|
||||
return await deps.deliverFinalText({
|
||||
text,
|
||||
chatJid,
|
||||
runId,
|
||||
channel,
|
||||
group,
|
||||
startSeq,
|
||||
endSeq,
|
||||
forcedAgentType: args.forcedAgentType,
|
||||
deliveryRole: resolvedDeliveryRole,
|
||||
deliveryServiceId: resolvedDeliveryServiceId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, chatJid, runId, err },
|
||||
'Failed to persist produced output for delivery',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await turnController.start();
|
||||
|
||||
try {
|
||||
const outputStatus = await deps.runAgent(
|
||||
group,
|
||||
prompt,
|
||||
chatJid,
|
||||
runId,
|
||||
async (result) => {
|
||||
await turnController.handleOutput(result);
|
||||
},
|
||||
{
|
||||
startSeq,
|
||||
endSeq,
|
||||
hasHumanMessage: args.hasHumanMessage,
|
||||
forcedRole: args.forcedRole,
|
||||
forcedAgentType: args.forcedAgentType,
|
||||
pairedTurnIdentity: args.pairedTurnIdentity,
|
||||
},
|
||||
);
|
||||
|
||||
const { deliverySucceeded, visiblePhase } =
|
||||
await turnController.finish(outputStatus);
|
||||
|
||||
if (deliverySucceeded) {
|
||||
await deps.afterDeliverySuccess?.({
|
||||
chatJid,
|
||||
runId,
|
||||
deliveryRole: resolvedDeliveryRole,
|
||||
pairedRoom,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outputStatus,
|
||||
deliverySucceeded,
|
||||
visiblePhase,
|
||||
};
|
||||
} finally {
|
||||
turnController.cancelPendingTypingDelay();
|
||||
logger.debug(
|
||||
{
|
||||
transition: 'typing:off',
|
||||
source: 'message-runtime:safety-net',
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
},
|
||||
'Typing indicator transition',
|
||||
);
|
||||
await channel.setTyping?.(chatJid, false);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user