diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index e49a061..21e21d7 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { resolveExecutionTarget, resolveNextTurnAction, + resolveQueuedTurnRole, resolveSessionFolder, } from './message-runtime-rules.js'; import { @@ -85,6 +86,27 @@ describe('message-runtime-rules', () => { ).toEqual({ kind: 'none' }); }); + it('routes fresh human input to owner even while review is pending', () => { + expect( + resolveQueuedTurnRole({ + taskStatus: 'review_ready', + hasHumanMessage: true, + }), + ).toBe('owner'); + expect( + resolveQueuedTurnRole({ + taskStatus: 'in_review', + hasHumanMessage: true, + }), + ).toBe('reviewer'); + expect( + resolveQueuedTurnRole({ + taskStatus: 'review_ready', + hasHumanMessage: false, + }), + ).toBe('reviewer'); + }); + it('resolves reviewer execution target from review_ready task status', () => { const resolution = resolveExecutionTarget({ lease: baseLease, diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 70d2e8f..b1653c9 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -61,6 +61,17 @@ export function resolveActiveRole( } } +export function resolveQueuedTurnRole(args: { + taskStatus?: PairedTaskStatus | null; + hasHumanMessage: boolean; +}): 'owner' | 'reviewer' | 'arbiter' { + if (args.hasHumanMessage && args.taskStatus === 'review_ready') { + return 'owner'; + } + + return resolveActiveRole(args.taskStatus); +} + export type NextTurnAction = | { kind: 'none' } | { kind: 'reviewer-turn' } @@ -104,7 +115,15 @@ export function resolveCursorKey( ): string { if (!hasReviewerLease(chatJid)) return chatJid; const role = resolveActiveRole(taskStatus); - return role === 'owner' ? chatJid : `${chatJid}:${role}`; + return resolveCursorKeyForRole(chatJid, role); +} + +export function resolveCursorKeyForRole( + chatJid: string, + role: 'owner' | 'reviewer' | 'arbiter', +): string { + if (!hasReviewerLease(chatJid) || role === 'owner') return chatJid; + return `${chatJid}:${role}`; } /** Resolve the effective agent type for a role, considering per-role overrides. */ diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index a3aa5f9..30a8d47 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1066,6 +1066,7 @@ describe('createMessageRuntime', () => { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), + enqueueMessageCheck: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), @@ -1803,6 +1804,95 @@ describe('createMessageRuntime', () => { ); }); + it('routes fresh human input to owner even when the latest task is review_ready', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const reviewerChannel = makeChannel(chatJid, 'discord-review', false); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-human-owner-override', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + } as any); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'human-override-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '새로 다시 진행해줘', + timestamp: '2026-03-30T00:00:01.000Z', + seq: 1, + is_bot_message: false, + } as any, + ]); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'DONE_WITH_CONCERNS\nowner handled fresh input', + newSessionId: 'session-human-owner-override', + }); + return { + status: 'success', + result: 'DONE_WITH_CONCERNS\nowner handled fresh input', + newSessionId: 'session-human-owner-override', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel, reviewerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-human-owner-override', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(ownerChannel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'DONE_WITH_CONCERNS\nowner handled fresh input', + ); + expect(reviewerChannel.sendMessage).not.toHaveBeenCalled(); + }); + it('consumes stale bot-only owner messages once the finalize turn output is already persisted', () => { const chatJid = 'group@test'; const group = makeGroup('codex'); @@ -2492,7 +2582,7 @@ describe('createMessageRuntime', () => { ); }); - it('fails closed for pending reviewer turns when the reviewer channel is missing', async () => { + it('fails closed for in-review turns when the reviewer channel is missing', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); const ownerChannel = makeChannel(chatJid); @@ -2511,7 +2601,7 @@ describe('createMessageRuntime', () => { plan_notes: null, review_requested_at: '2026-03-30T00:00:00.000Z', round_trip_count: 0, - status: 'review_ready', + status: 'in_review', arbiter_verdict: null, arbiter_requested_at: null, completion_reason: null, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 52eba53..0ae37a9 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -35,7 +35,9 @@ import { createImplicitContinuationTracker, resolveNextTurnAction, resolveActiveRole, + resolveCursorKeyForRole, resolveCursorKey, + resolveQueuedTurnRole, filterLoopingPairedBotMessages, getProcessableMessages, hasAllowedTrigger, @@ -484,7 +486,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { await turnController.finish(outputStatus); if (deliverySucceeded && pairedRoom && resolvedDeliveryRole === 'owner') { - const pendingTaskAfterDelivery = getLatestOpenPairedTaskForChat(chatJid); + const pendingTaskAfterDelivery = + getLatestOpenPairedTaskForChat(chatJid); const nextTurnAction = resolveNextTurnAction({ taskStatus: pendingTaskAfterDelivery?.status, lastTurnOutputRole: 'owner', @@ -697,10 +700,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { reviewer: foundReviewerChannel || null, arbiter: foundArbiterChannel || null, }; - const resolveChannel = (taskStatus?: string | null): Channel | null => { - const role = resolveActiveRole(taskStatus); + const resolveChannelForRole = ( + role: 'owner' | 'reviewer' | 'arbiter', + ): Channel | null => { return role === 'owner' ? channel : roleToChannel[role]; }; + const resolveChannel = (taskStatus?: string | null): Channel | null => + resolveChannelForRole(resolveActiveRole(taskStatus)); const enqueueGroupMessageCheck = (): void => { enqueueScopedGroupMessageCheck(chatJid, group.folder); @@ -987,11 +993,22 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ? getLatestOpenPairedTaskForChat(chatJid) : null; const taskStatus = pendingTaskForChannel?.status; - const turnChannel = resolveChannel(taskStatus); - const cursorKey = resolveCursorKey(chatJid, taskStatus); + const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages); + const turnRole = pendingTaskForChannel + ? resolveQueuedTurnRole({ + taskStatus, + hasHumanMessage: hasHumanMsg, + }) + : 'owner'; + const turnChannel = resolveChannelForRole(turnRole); + const cursorKey = resolveCursorKeyForRole(chatJid, turnRole); + const forcedRole = + pendingTaskForChannel && + turnRole !== resolveActiveRole(taskStatus) + ? turnRole + : undefined; // Arbiter turns use a dedicated context prompt; regular turns use formatted messages. - const turnRole = resolveActiveRole(taskStatus); let prompt: string; if (turnRole === 'arbiter' && pendingTaskForChannel) { const recentMessages = getRecentChatMessages(chatJid, 20); @@ -1052,7 +1069,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); } - const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages); const { deliverySucceeded, visiblePhase } = await executeTurn({ group, prompt, @@ -1063,6 +1079,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq, endSeq, hasHumanMessage: hasHumanMsg, + forcedRole, }); if (!deliverySucceeded) {