589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
import {
|
|
createProducedWorkItem,
|
|
getOpenWorkItemForChat,
|
|
getMessagesSinceSeq,
|
|
getLatestOpenPairedTaskForChat,
|
|
getPairedTaskById,
|
|
} from './db.js';
|
|
import {
|
|
isSessionCommandSenderAllowed,
|
|
SERVICE_SESSION_SCOPE,
|
|
} from './config.js';
|
|
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
|
import { findChannel, findChannelByName, formatMessages } from './router.js';
|
|
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
|
import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowUp } from './message-runtime-dispatch.js';
|
|
import {
|
|
deliverOpenWorkItem,
|
|
processOpenWorkItemDelivery,
|
|
} from './message-runtime-delivery.js';
|
|
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
|
import {
|
|
enqueuePendingHandoffs as enqueueClaimedServiceHandoffs,
|
|
processClaimedHandoff as processClaimedServiceHandoff,
|
|
} from './message-runtime-handoffs.js';
|
|
import {
|
|
buildScopedMessageCheckEnqueuer,
|
|
processMessageLoopTick,
|
|
recoverPendingMessages as recoverRuntimePendingMessages,
|
|
} from './message-runtime-loop.js';
|
|
import {
|
|
runPendingPairedTurnIfNeeded,
|
|
runQueuedGroupTurn,
|
|
} from './message-runtime-queue.js';
|
|
import {
|
|
advanceLastAgentCursor,
|
|
createImplicitContinuationTracker,
|
|
filterLoopingPairedBotMessages,
|
|
getProcessableMessages,
|
|
shouldSkipBotOnlyCollaboration,
|
|
} from './message-runtime-rules.js';
|
|
import {
|
|
enqueuePairedFollowUpAfterEvent,
|
|
schedulePairedFollowUpWithMessageCheck,
|
|
} from './message-runtime-follow-up.js';
|
|
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
|
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
|
|
import {
|
|
Channel,
|
|
NewMessage,
|
|
type PairedRoomRole,
|
|
RegisteredGroup,
|
|
type PairedTask,
|
|
} from './types.js';
|
|
import { createScopedLogger, logger } from './logger.js';
|
|
import { hasReviewerLease } from './service-routing.js';
|
|
export {
|
|
resolveHandoffCursorKey,
|
|
resolveHandoffRoleOverride,
|
|
} from './message-runtime-shared.js';
|
|
import {
|
|
getFixedRoleChannelName,
|
|
getMissingRoleChannelMessage,
|
|
} from './message-runtime-shared.js';
|
|
import {
|
|
createRunAgent,
|
|
createExecuteTurn,
|
|
isDuplicateOfLastBotFinal,
|
|
labelPairedSenders,
|
|
} from './message-runtime-turns.js';
|
|
|
|
export { isDuplicateOfLastBotFinal };
|
|
|
|
export interface MessageRuntimeDeps {
|
|
assistantName: string;
|
|
idleTimeout: number;
|
|
pollInterval: number;
|
|
timezone: string;
|
|
triggerPattern: RegExp;
|
|
channels: Channel[];
|
|
queue: GroupQueue;
|
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
|
getSessions: () => Record<string, string>;
|
|
getLastTimestamp: () => string;
|
|
setLastTimestamp: (timestamp: string) => void;
|
|
getLastAgentTimestamps: () => Record<string, string>;
|
|
saveState: () => void;
|
|
persistSession: (groupFolder: string, sessionId: string) => void;
|
|
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
|
}
|
|
|
|
export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|
processGroupMessages: (
|
|
chatJid: string,
|
|
context: GroupRunContext,
|
|
) => Promise<boolean>;
|
|
recoverPendingMessages: () => void;
|
|
startMessageLoop: () => Promise<void>;
|
|
} {
|
|
let messageLoopRunning = false;
|
|
const FAILURE_FINAL_TEXT = '요청을 완료하지 못했습니다. 다시 시도해 주세요.';
|
|
const continuationTracker = createImplicitContinuationTracker(
|
|
deps.idleTimeout,
|
|
);
|
|
const labelPairedRuntimeSenders = (
|
|
chatJid: string,
|
|
messages: NewMessage[],
|
|
): NewMessage[] => labelPairedSenders(deps.channels, chatJid, messages);
|
|
|
|
const enqueueScopedGroupMessageCheck = buildScopedMessageCheckEnqueuer(
|
|
deps.queue,
|
|
);
|
|
|
|
const scheduleQueuedPairedFollowUp = (args: {
|
|
chatJid: string;
|
|
runId: string;
|
|
task: PairedTask | null | undefined;
|
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
|
enqueue: () => void;
|
|
fallbackLastTurnOutputRole?: PairedRoomRole | null;
|
|
}): boolean =>
|
|
schedulePairedFollowUpWithMessageCheck({
|
|
chatJid: args.chatJid,
|
|
runId: args.runId,
|
|
task: args.task,
|
|
intentKind: args.intentKind,
|
|
enqueueMessageCheck: args.enqueue,
|
|
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
|
|
});
|
|
|
|
/**
|
|
* Check if a message is a duplicate of the last bot final message in a paired room.
|
|
* Returns true if duplicate (should be suppressed).
|
|
*/
|
|
const checkDuplicateOfLastBotFinal = (
|
|
chatJid: string,
|
|
text: string,
|
|
): boolean => {
|
|
return isDuplicateOfLastBotFinal(chatJid, text);
|
|
};
|
|
const runAgent = createRunAgent({
|
|
assistantName: deps.assistantName,
|
|
queue: deps.queue,
|
|
getRoomBindings: deps.getRoomBindings,
|
|
getSessions: deps.getSessions,
|
|
persistSession: deps.persistSession,
|
|
clearSession: deps.clearSession,
|
|
});
|
|
const executeTurn = createExecuteTurn({
|
|
runAgent,
|
|
assistantName: deps.assistantName,
|
|
idleTimeout: deps.idleTimeout,
|
|
failureFinalText: FAILURE_FINAL_TEXT,
|
|
channels: deps.channels,
|
|
queue: deps.queue,
|
|
getRoomBindings: deps.getRoomBindings,
|
|
getSessions: deps.getSessions,
|
|
persistSession: deps.persistSession,
|
|
clearSession: deps.clearSession,
|
|
deliverFinalText: async ({
|
|
text,
|
|
chatJid,
|
|
runId,
|
|
channel,
|
|
group,
|
|
startSeq,
|
|
endSeq,
|
|
forcedAgentType,
|
|
deliveryRole,
|
|
deliveryServiceId,
|
|
}) => {
|
|
if (
|
|
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
|
|
deps.queue.hasDirectTerminalDeliveryForRun?.(
|
|
chatJid,
|
|
runId,
|
|
deliveryRole,
|
|
)
|
|
) {
|
|
logger.info(
|
|
{
|
|
chatJid,
|
|
runId,
|
|
deliveryRole,
|
|
},
|
|
'Skipping final work item delivery because this run already sent a direct terminal IPC message',
|
|
);
|
|
return true;
|
|
}
|
|
const workItem = createProducedWorkItem({
|
|
group_folder: group.folder,
|
|
chat_jid: chatJid,
|
|
agent_type: forcedAgentType ?? group.agentType ?? 'claude-code',
|
|
service_id: deliveryServiceId ?? undefined,
|
|
delivery_role: deliveryRole,
|
|
start_seq: startSeq,
|
|
end_seq: endSeq,
|
|
result_payload: text,
|
|
});
|
|
return deliverOpenWorkItem({
|
|
channel,
|
|
item: workItem,
|
|
log: logger,
|
|
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
|
openContinuation: (targetChatJid) =>
|
|
continuationTracker.open(targetChatJid),
|
|
});
|
|
},
|
|
afterDeliverySuccess: async ({
|
|
chatJid,
|
|
runId,
|
|
deliveryRole,
|
|
pairedRoom,
|
|
}) => {
|
|
if (!deliveryRole || !pairedRoom) {
|
|
return;
|
|
}
|
|
const pendingTaskAfterDelivery = getLatestOpenPairedTaskForChat(chatJid);
|
|
const followUpResult = enqueuePairedFollowUpAfterEvent({
|
|
chatJid,
|
|
runId,
|
|
task: pendingTaskAfterDelivery,
|
|
source: 'delivery-success',
|
|
completedRole: deliveryRole,
|
|
fallbackLastTurnOutputRole: deliveryRole,
|
|
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
|
|
});
|
|
if (followUpResult.kind === 'paired-follow-up') {
|
|
logger.info(
|
|
{
|
|
chatJid,
|
|
runId,
|
|
completedRole: deliveryRole,
|
|
taskId: followUpResult.taskId,
|
|
taskStatus: followUpResult.taskStatus,
|
|
intentKind: followUpResult.intentKind,
|
|
scheduled: followUpResult.scheduled,
|
|
},
|
|
followUpResult.scheduled
|
|
? deliveryRole === 'owner'
|
|
? 'Queued paired follow-up after successful owner delivery'
|
|
: 'Queued paired follow-up after successful reviewer/arbiter delivery'
|
|
: deliveryRole === 'owner'
|
|
? 'Skipped duplicate paired follow-up after successful owner delivery while task state was unchanged'
|
|
: 'Skipped duplicate paired follow-up after successful reviewer/arbiter delivery while task state was unchanged',
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
const enqueuePendingHandoffs = (): void => {
|
|
enqueueClaimedServiceHandoffs({
|
|
enqueueTask: (chatJid, taskId, task) => {
|
|
deps.queue.enqueueTask?.(chatJid, taskId, task);
|
|
},
|
|
processClaimedHandoff: async (handoff) => {
|
|
await processClaimedServiceHandoff({
|
|
handoff,
|
|
getRoomBindings: deps.getRoomBindings,
|
|
channels: deps.channels,
|
|
executeTurn,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
saveState: deps.saveState,
|
|
getPairedTaskById,
|
|
enqueueMessageCheck: (chatJid) =>
|
|
deps.queue.enqueueMessageCheck(chatJid),
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
const processGroupMessages = async (
|
|
chatJid: string,
|
|
context: GroupRunContext,
|
|
): Promise<boolean> => {
|
|
const { runId } = context;
|
|
const group = deps.getRoomBindings()[chatJid];
|
|
if (!group) return true;
|
|
const log = createScopedLogger({
|
|
chatJid,
|
|
groupName: group.name,
|
|
groupFolder: group.folder,
|
|
runId,
|
|
});
|
|
|
|
const channel = findChannel(deps.channels, chatJid);
|
|
if (!channel) {
|
|
log.warn('No channel owns JID, skipping messages');
|
|
return true;
|
|
}
|
|
|
|
// For paired rooms, reviewer/arbiter outputs use their fixed role bots.
|
|
const reviewerChannelName = 'discord-review';
|
|
const foundReviewerChannel = findChannelByName(
|
|
deps.channels,
|
|
reviewerChannelName,
|
|
);
|
|
|
|
const arbiterChannelName = 'discord-arbiter';
|
|
const foundArbiterChannel = findChannelByName(
|
|
deps.channels,
|
|
arbiterChannelName,
|
|
);
|
|
|
|
// Resolve the correct Discord channel for a given task status.
|
|
const roleToChannel: Record<
|
|
'owner' | 'reviewer' | 'arbiter',
|
|
Channel | null
|
|
> = {
|
|
owner: channel,
|
|
reviewer: foundReviewerChannel || null,
|
|
arbiter: foundArbiterChannel || null,
|
|
};
|
|
if (hasReviewerLease(chatJid)) {
|
|
log.info(
|
|
{
|
|
reviewerChannelName,
|
|
foundChannel: foundReviewerChannel?.name ?? null,
|
|
arbiterChannelName,
|
|
foundArbiterChannel: foundArbiterChannel?.name ?? null,
|
|
availableChannels: deps.channels.map((c) => c.name),
|
|
},
|
|
'Paired room reviewer/arbiter channel resolution',
|
|
);
|
|
}
|
|
|
|
// Delivery retries can come from forced fallback runs whose agent_type
|
|
// differs from the room owner's registered agent type.
|
|
const openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
|
|
const openWorkItemOutcome = await processOpenWorkItemDelivery({
|
|
chatJid,
|
|
runId,
|
|
openWorkItem,
|
|
pendingTask: hasReviewerLease(chatJid)
|
|
? getLatestOpenPairedTaskForChat(chatJid)
|
|
: null,
|
|
channel,
|
|
roleToChannel,
|
|
log,
|
|
isPairedRoom: hasReviewerLease(chatJid),
|
|
getMissingRoleChannelMessage,
|
|
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
|
openContinuation: (targetChatJid) =>
|
|
continuationTracker.open(targetChatJid),
|
|
enqueueFollowUpAfterDeliveryRetry: ({
|
|
deliveryRole,
|
|
pendingTask,
|
|
workItemId,
|
|
}) =>
|
|
enqueueDeliveryRetryFollowUp({
|
|
chatJid,
|
|
runId,
|
|
deliveryRole,
|
|
pendingTask,
|
|
workItemId,
|
|
log,
|
|
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
|
|
}),
|
|
});
|
|
if (openWorkItemOutcome === 'failed') {
|
|
return false;
|
|
}
|
|
if (openWorkItemOutcome === 'delivered') {
|
|
return true;
|
|
}
|
|
|
|
const isMainGroup = group.isMain === true;
|
|
while (true) {
|
|
const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
|
|
const rawMissedMessages = getMessagesSinceSeq(
|
|
chatJid,
|
|
sinceSeqCursor,
|
|
deps.assistantName,
|
|
);
|
|
const missedMessages = filterLoopingPairedBotMessages(
|
|
chatJid,
|
|
getProcessableMessages(chatJid, rawMissedMessages, channel),
|
|
FAILURE_FINAL_TEXT,
|
|
);
|
|
|
|
if (missedMessages.length === 0) {
|
|
const pendingTurnOutcome = await runPendingPairedTurnIfNeeded({
|
|
chatJid,
|
|
group,
|
|
runId,
|
|
log,
|
|
timezone: deps.timezone,
|
|
task: hasReviewerLease(chatJid)
|
|
? getLatestOpenPairedTaskForChat(chatJid)
|
|
: null,
|
|
rawMissedMessages,
|
|
saveState: deps.saveState,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
executeTurn,
|
|
getFixedRoleChannelName,
|
|
roleToChannel,
|
|
labelPairedSenders: labelPairedRuntimeSenders,
|
|
mode: 'idle',
|
|
});
|
|
if (pendingTurnOutcome !== null) {
|
|
return pendingTurnOutcome;
|
|
}
|
|
|
|
const lastIgnored = rawMissedMessages[rawMissedMessages.length - 1];
|
|
if (lastIgnored) {
|
|
advanceLastAgentCursor(
|
|
deps.getLastAgentTimestamps(),
|
|
deps.saveState,
|
|
chatJid,
|
|
lastIgnored.timestamp,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const botOnlyPendingTurnOutcome = await runPendingPairedTurnIfNeeded({
|
|
chatJid,
|
|
group,
|
|
runId,
|
|
log,
|
|
timezone: deps.timezone,
|
|
task: hasReviewerLease(chatJid)
|
|
? getLatestOpenPairedTaskForChat(chatJid)
|
|
: null,
|
|
rawMissedMessages,
|
|
saveState: deps.saveState,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
executeTurn,
|
|
getFixedRoleChannelName,
|
|
roleToChannel,
|
|
labelPairedSenders: labelPairedRuntimeSenders,
|
|
mode: 'bot-only',
|
|
missedMessages,
|
|
});
|
|
if (botOnlyPendingTurnOutcome !== null) {
|
|
return botOnlyPendingTurnOutcome;
|
|
}
|
|
|
|
if (shouldSkipBotOnlyCollaboration(chatJid, missedMessages)) {
|
|
const lastMessage = missedMessages[missedMessages.length - 1];
|
|
if (lastMessage?.seq != null) {
|
|
advanceLastAgentCursor(
|
|
deps.getLastAgentTimestamps(),
|
|
deps.saveState,
|
|
chatJid,
|
|
lastMessage.seq,
|
|
);
|
|
}
|
|
log.info(
|
|
'Skipping bot-only collaboration because no recent human message exists',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const gateResult = await handleQueuedRunGates({
|
|
chatJid,
|
|
group,
|
|
runId,
|
|
missedMessages,
|
|
triggerPattern: deps.triggerPattern,
|
|
timezone: deps.timezone,
|
|
hasImplicitContinuationWindow: continuationTracker.has,
|
|
sessionCommandDeps: {
|
|
sendMessage: (text) => channel.sendMessage(chatJid, text),
|
|
setTyping: (typing) =>
|
|
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
|
|
runAgent: (prompt, onOutput) =>
|
|
runAgent(group, prompt, chatJid, runId, onOutput),
|
|
closeStdin: () =>
|
|
deps.queue.closeStdin(chatJid, {
|
|
reason: 'session-command',
|
|
}),
|
|
clearSession: (opts) => deps.clearSession(group.folder, opts),
|
|
advanceCursor: (cursorOrTimestamp) => {
|
|
advanceLastAgentCursor(
|
|
deps.getLastAgentTimestamps(),
|
|
deps.saveState,
|
|
chatJid,
|
|
cursorOrTimestamp,
|
|
);
|
|
},
|
|
formatMessages,
|
|
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
|
|
canSenderInteract: () => true,
|
|
resetPairedTask: () => {
|
|
if (hasReviewerLease(chatJid)) {
|
|
const task = getLatestOpenPairedTaskForChat(chatJid);
|
|
if (task) {
|
|
const now = new Date().toISOString();
|
|
transitionPairedTaskStatus({
|
|
taskId: task.id,
|
|
currentStatus: task.status,
|
|
nextStatus: 'completed',
|
|
expectedUpdatedAt: task.updated_at,
|
|
updatedAt: now,
|
|
patch: {
|
|
completion_reason: 'stopped',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
},
|
|
killProcess: () => deps.queue.killProcess(chatJid),
|
|
},
|
|
});
|
|
if (gateResult.handled) {
|
|
return gateResult.success;
|
|
}
|
|
|
|
return runQueuedGroupTurn({
|
|
chatJid,
|
|
group,
|
|
runId,
|
|
log,
|
|
timezone: deps.timezone,
|
|
missedMessages,
|
|
task: hasReviewerLease(chatJid)
|
|
? getLatestOpenPairedTaskForChat(chatJid)
|
|
: null,
|
|
roleToChannel,
|
|
ownerChannel: channel,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
saveState: deps.saveState,
|
|
executeTurn,
|
|
getFixedRoleChannelName,
|
|
labelPairedSenders: labelPairedRuntimeSenders,
|
|
formatMessages,
|
|
});
|
|
}
|
|
};
|
|
|
|
const startMessageLoop = async (): Promise<void> => {
|
|
if (messageLoopRunning) {
|
|
logger.debug('Message loop already running, skipping duplicate start');
|
|
return;
|
|
}
|
|
messageLoopRunning = true;
|
|
|
|
logger.info('EJClaw running');
|
|
|
|
while (true) {
|
|
try {
|
|
await processMessageLoopTick({
|
|
assistantName: deps.assistantName,
|
|
failureFinalText: FAILURE_FINAL_TEXT,
|
|
triggerPattern: deps.triggerPattern,
|
|
timezone: deps.timezone,
|
|
channels: deps.channels,
|
|
getRoomBindings: deps.getRoomBindings,
|
|
getLastTimestamp: deps.getLastTimestamp,
|
|
setLastTimestamp: deps.setLastTimestamp,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
saveState: deps.saveState,
|
|
hasImplicitContinuationWindow: continuationTracker.has,
|
|
executeTurn,
|
|
enqueuePendingHandoffs,
|
|
scheduleQueuedPairedFollowUp,
|
|
enqueueScopedGroupMessageCheck,
|
|
sendQueuedMessage: deps.queue.sendMessage.bind(deps.queue),
|
|
closeStdin: (chatJid, reason) =>
|
|
deps.queue.closeStdin(chatJid, {
|
|
reason,
|
|
}),
|
|
labelPairedSenders: labelPairedRuntimeSenders,
|
|
});
|
|
} catch (err) {
|
|
logger.error({ err }, 'Error in message loop');
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, deps.pollInterval));
|
|
}
|
|
};
|
|
|
|
const recoverPendingMessages = (): void => {
|
|
recoverRuntimePendingMessages({
|
|
assistantName: deps.assistantName,
|
|
channels: deps.channels,
|
|
getRoomBindings: deps.getRoomBindings,
|
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
|
saveState: deps.saveState,
|
|
enqueueScopedGroupMessageCheck,
|
|
});
|
|
};
|
|
|
|
return {
|
|
processGroupMessages,
|
|
recoverPendingMessages,
|
|
startMessageLoop,
|
|
};
|
|
}
|