Refactor local memory and role-fixed runtime routing

This commit is contained in:
Eyejoker
2026-04-04 03:50:28 +09:00
parent ca58e8c8eb
commit 3dd772c229
47 changed files with 6158 additions and 669 deletions

View File

@@ -5,8 +5,8 @@ import {
completeServiceHandoffAndAdvanceTargetCursor,
createProducedWorkItem,
failServiceHandoff,
getAllPendingServiceHandoffs,
getOpenWorkItem,
getPendingServiceHandoffs,
getMessagesSinceSeq,
getNewMessagesBySeq,
markWorkItemDelivered,
@@ -21,12 +21,7 @@ import {
type WorkItem,
} from './db.js';
import {
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
isSessionCommandSenderAllowed,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
} from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js';
import {
@@ -67,7 +62,6 @@ import {
import { createScopedLogger, logger } from './logger.js';
import { resolveGroupIpcPath } from './group-folder.js';
import {
getEffectiveChannelLease,
hasReviewerLease,
} from './service-routing.js';
@@ -97,8 +91,11 @@ export function isDuplicateOfLastBotFinal(
}
export function resolveHandoffRoleOverride(
handoff: Pick<ServiceHandoff, 'intended_role' | 'reason'>,
handoff: Pick<ServiceHandoff, 'target_role' | 'intended_role' | 'reason'>,
): PairedRoomRole | undefined {
if (handoff.target_role) {
return handoff.target_role;
}
if (handoff.intended_role) {
return handoff.intended_role;
}
@@ -111,6 +108,16 @@ export function resolveHandoffRoleOverride(
return undefined;
}
export function resolveHandoffCursorKey(
chatJid: string,
role?: PairedRoomRole,
): string {
if (!role || role === 'owner') {
return chatJid;
}
return `${chatJid}:${role}`;
}
export interface MessageRuntimeDeps {
assistantName: string;
idleTimeout: number;
@@ -129,6 +136,14 @@ export interface MessageRuntimeDeps {
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
}
function getFixedRoleChannelName(role: 'reviewer' | 'arbiter'): string {
return role === 'reviewer' ? 'discord-review' : 'discord-arbiter';
}
function getMissingRoleChannelMessage(role: 'reviewer' | 'arbiter'): string {
return `Missing configured ${role} Discord bot channel (${getFixedRoleChannelName(role)}) for role-fixed delivery`;
}
export function createMessageRuntime(deps: MessageRuntimeDeps): {
processGroupMessages: (
chatJid: string,
@@ -149,10 +164,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
messages: NewMessage[],
): NewMessage[] => {
if (!hasReviewerLease(chatJid)) return messages;
const lease = getEffectiveChannelLease(chatJid);
const sharedOwnerReviewerService =
lease.reviewer_service_id !== null &&
lease.owner_service_id === lease.reviewer_service_id;
// Build bot-user-id → channel-name mapping from connected channels
const botIdToChannelName = new Map<string, string>();
for (const ch of deps.channels) {
@@ -164,36 +175,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
}
}
// Map channel name → service ID
const channelToService: Record<string, string> = {
discord: 'claude',
'discord-codex': 'codex-main',
'discord-review': 'codex-review',
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 serviceId = channelToService[channelName];
if (!serviceId) return msg;
// Raw channel history cannot tell owner/reviewer apart when both roles
// are delivered by the same service, so avoid fabricating a role label.
if (
sharedOwnerReviewerService &&
serviceId === lease.owner_service_id &&
serviceId === lease.reviewer_service_id
) {
return msg;
}
const role =
serviceId === lease.owner_service_id
? 'owner'
: serviceId === lease.reviewer_service_id
? 'reviewer'
: serviceId === lease.arbiter_service_id
? 'arbiter'
: msg.sender_name;
return { ...msg, sender_name: role };
const role = channelToRole[channelName];
return role ? { ...msg, sender_name: role } : msg;
});
};
@@ -467,6 +459,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: Channel;
startSeq: number | null;
endSeq: number | null;
deliveryRole?: PairedRoomRole;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
@@ -504,6 +497,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chat_jid: chatJid,
agent_type:
args.forcedAgentType ?? group.agentType ?? 'claude-code',
delivery_role: args.deliveryRole ?? args.forcedRole ?? null,
start_seq: startSeq,
end_seq: endSeq,
result_payload: text,
@@ -563,17 +557,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
};
const enqueuePendingHandoffs = (): void => {
// Unified service handles all three bots — collect handoffs for all service IDs.
const allServiceIds = new Set([
SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
]);
const allHandoffs = [...allServiceIds].flatMap((id) =>
getPendingServiceHandoffs(id),
);
for (const handoff of allHandoffs) {
// Unified runtime claims pending handoffs once and resolves delivery from role context.
for (const handoff of getAllPendingServiceHandoffs()) {
if (!claimServiceHandoff(handoff.id)) {
continue;
}
@@ -608,14 +593,29 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const handoffRole = resolveHandoffRoleOverride(handoff);
let handoffChannel = channel;
if (handoffRole === 'reviewer') {
const revChName =
handoff.target_agent_type === 'claude-code'
? 'discord'
: 'discord-review';
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
// Role-fixed delivery intentionally does not follow fallback agent type.
const reviewerChannel = findChannelByName(
deps.channels,
getFixedRoleChannelName('reviewer'),
);
if (!reviewerChannel) {
failServiceHandoff(
handoff.id,
getMissingRoleChannelMessage('reviewer'),
);
return;
}
handoffChannel = reviewerChannel;
} else if (handoffRole === 'arbiter') {
handoffChannel =
findChannelByName(deps.channels, 'discord-review') || channel;
const arbiterChannel = findChannelByName(
deps.channels,
getFixedRoleChannelName('arbiter'),
);
if (!arbiterChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter'));
return;
}
handoffChannel = arbiterChannel;
}
const runId = `handoff-${handoff.id}`;
@@ -626,6 +626,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
handoffId: handoff.id,
runId,
handoffRole,
targetRole: handoff.target_role ?? null,
targetServiceId: handoff.target_service_id,
targetAgentType: handoff.target_agent_type,
reason: handoff.reason,
@@ -651,14 +652,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return;
}
const cursorKey = resolveHandoffCursorKey(handoff.chat_jid, handoffRole);
const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({
id: handoff.id,
target_service_id: handoff.target_service_id,
chat_jid: handoff.chat_jid,
cursor_key: cursorKey,
end_seq: handoff.end_seq,
});
if (appliedCursor) {
deps.getLastAgentTimestamps()[handoff.chat_jid] = appliedCursor;
deps.getLastAgentTimestamps()[cursorKey] = appliedCursor;
deps.saveState();
}
logger.info(
{
@@ -668,6 +671,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
outputStatus: result.outputStatus,
visiblePhase: result.visiblePhase,
appliedCursor,
cursorKey:
appliedCursor != null
? resolveHandoffCursorKey(handoff.chat_jid, handoffRole)
: null,
},
'Completed claimed service handoff',
);
@@ -701,44 +708,39 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return true;
}
// For paired rooms, determine the reviewer channel for correct bot routing.
// This is used whenever the reviewer needs to send output.
const reviewerChannelName =
hasReviewerLease(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code'
? 'discord'
: 'discord-review';
// For paired rooms, reviewer/arbiter outputs use their fixed role bots.
const reviewerChannelName = 'discord-review';
const foundReviewerChannel = findChannelByName(
deps.channels,
reviewerChannelName,
);
const reviewerChannel = foundReviewerChannel || channel;
// Arbiter always uses discord-review bot regardless of model —
// the arbiter role is tied to the 3rd bot, not the model behind it.
const arbiterChannelName = 'discord-review';
const arbiterChannelName = 'discord-arbiter';
const foundArbiterChannel = findChannelByName(
deps.channels,
arbiterChannelName,
);
const arbiterChannel = foundArbiterChannel || channel;
// Resolve the correct Discord channel for a given task status.
const roleToChannel: Record<string, Channel> = {
const roleToChannel: Record<string, Channel | null> = {
owner: channel,
reviewer: reviewerChannel,
arbiter: arbiterChannel,
reviewer: foundReviewerChannel || null,
arbiter: foundArbiterChannel || null,
};
const resolveChannel = (taskStatus?: string | null): Channel | null => {
const role = resolveActiveRole(taskStatus);
return role === 'owner' ? channel : roleToChannel[role];
};
const resolveChannel = (taskStatus?: string | null): Channel =>
roleToChannel[resolveActiveRole(taskStatus)] ?? channel;
const buildPendingPairedTurn = (
task: PairedTask,
rawMissedMessages: NewMessage[],
): {
prompt: string;
channel: Channel;
channel: Channel | null;
cursor: string | number | null;
cursorKey?: string;
role?: 'reviewer' | 'arbiter';
} | null => {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
@@ -751,6 +753,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: resolveChannel(taskStatus),
cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'reviewer',
};
}
@@ -760,6 +763,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: resolveChannel(taskStatus),
cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'arbiter',
};
}
@@ -776,10 +780,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const executePendingPairedTurn = async (args: {
prompt: string;
channel: Channel;
channel: Channel | null;
cursor: string | number | null;
cursorKey?: string;
role?: 'reviewer' | 'arbiter';
}): Promise<boolean> => {
if (!args.channel) {
const missingRole = args.role ?? 'reviewer';
log.error(
{
role: missingRole,
requiredChannel: getFixedRoleChannelName(missingRole),
},
'Skipping paired turn because the dedicated Discord role channel is not configured',
);
return false;
}
if (args.cursor != null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
@@ -796,6 +813,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
channel: args.channel,
deliveryRole: args.role,
startSeq: null,
endSeq: null,
});
@@ -808,10 +826,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
{
reviewerChannelName,
foundChannel: foundReviewerChannel?.name ?? null,
usingChannel: reviewerChannel.name,
arbiterChannelName,
foundArbiterChannel: foundArbiterChannel?.name ?? null,
usingArbiterChannel: arbiterChannel.name,
availableChannels: deps.channels.map((c) => c.name),
},
'Paired room reviewer/arbiter channel resolution',
@@ -828,7 +844,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const pendingTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const deliveryChannel = resolveChannel(pendingTask?.status);
const deliveryRole =
openWorkItem.delivery_role ??
(pendingTask ? resolveActiveRole(pendingTask.status) : 'owner');
const deliveryChannel =
deliveryRole === 'owner' ? channel : roleToChannel[deliveryRole];
if (!deliveryChannel) {
const missingRole = deliveryRole === 'arbiter' ? 'arbiter' : 'reviewer';
const errorMessage = getMissingRoleChannelMessage(missingRole);
markWorkItemDeliveryRetry(openWorkItem.id, errorMessage);
log.error(
{
workItemId: openWorkItem.id,
role: deliveryRole,
requiredChannel: getFixedRoleChannelName(missingRole),
},
'Unable to deliver paired-room work item because the dedicated Discord role channel is not configured',
);
return false;
}
const delivered = await deliverOpenWorkItem(
deliveryChannel,
openWorkItem,
@@ -999,6 +1033,28 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
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: getFixedRoleChannelName(missingRole),
},
'Skipping paired-room run because the dedicated Discord role channel is not configured',
);
return false;
}
if (endSeq !== null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
@@ -1009,15 +1065,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
);
}
log.info(
{
messageCount: missedMessages.length,
messageSeqStart: startSeq,
messageSeqEnd: endSeq,
},
'Dispatching queued messages to agent',
);
const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
const { deliverySucceeded, visiblePhase } = await executeTurn({
group,
@@ -1025,6 +1072,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
channel: turnChannel,
deliveryRole: pendingTaskForChannel ? turnRole : undefined,
startSeq,
endSeq,
hasHumanMessage: hasHumanMsg,