From 0a6782f5c2ac2ebce7e0c48184075eba8fcfea44 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 01:21:00 +0900 Subject: [PATCH 01/12] Fix merge-ready task supersession handling --- src/db.ts | 1 + src/db/paired-turns.ts | 14 +++ src/db/runtime-paired.ts | 8 ++ src/message-runtime-queue.test.ts | 89 +++++++++++++ src/message-runtime-queue.ts | 64 ++++++---- src/message-runtime.test.ts | 130 +++++++++++++++++++ src/message-runtime.ts | 51 +++++++- src/paired-execution-context.test.ts | 2 + src/paired-execution-context.ts | 179 ++++++++++++++++++++++----- 9 files changed, 478 insertions(+), 60 deletions(-) diff --git a/src/db.ts b/src/db.ts index d479d52..aef3bbb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -74,6 +74,7 @@ export { } from './db/runtime-tasks-rooms.js'; export { _clearPairedTurnReservationsForTests, + cancelPairedTurn, claimPairedTurnReservation, claimServiceHandoff, clearChannelOwnerLease, diff --git a/src/db/paired-turns.ts b/src/db/paired-turns.ts index 8636c74..299fbe7 100644 --- a/src/db/paired-turns.ts +++ b/src/db/paired-turns.ts @@ -463,6 +463,20 @@ export function failPairedTurnInDatabase( }); } +export function cancelPairedTurnInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + error?: string | null; + }, +): void { + markPairedTurnTerminalStateInDatabase(database, { + turnIdentity: args.turnIdentity, + state: 'cancelled', + error: args.error, + }); +} + export function getPairedTurnByIdFromDatabase( database: Database, turnId: string, diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts index 3aee67c..fb875a4 100644 --- a/src/db/runtime-paired.ts +++ b/src/db/runtime-paired.ts @@ -50,6 +50,7 @@ import { insertPairedTurnOutputInDatabase, } from './paired-turn-outputs.js'; import { + cancelPairedTurnInDatabase, clearPairedTurnsInDatabase, completePairedTurnInDatabase, failPairedTurnInDatabase, @@ -176,6 +177,13 @@ export function failPairedTurn(args: { failPairedTurnInDatabase(requireDatabase(), args); } +export function cancelPairedTurn(args: { + turnIdentity: PairedTurnIdentity; + error?: string | null; +}): void { + cancelPairedTurnInDatabase(requireDatabase(), args); +} + export function getPairedTurnById( turnId: string, ): PairedTurnRecord | undefined { diff --git a/src/message-runtime-queue.test.ts b/src/message-runtime-queue.test.ts index e4d1889..b38ab88 100644 --- a/src/message-runtime-queue.test.ts +++ b/src/message-runtime-queue.test.ts @@ -3,8 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createPairedTask, + getLatestOpenPairedTaskForChat, getPairedTaskById, + getPairedTurnById, insertPairedTurnOutput, + markPairedTurnRunning, updatePairedTaskIfUnchanged, } from './db.js'; import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js'; @@ -13,6 +16,7 @@ import { runQueuedGroupTurn, } from './message-runtime-queue.js'; import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import type { Channel, PairedTask, RegisteredGroup } from './types.js'; function makeGroup(): RegisteredGroup { @@ -23,6 +27,7 @@ function makeGroup(): RegisteredGroup { added_at: '2026-03-30T00:00:00.000Z', requiresTrigger: false, agentType: 'codex', + workDir: '/repo', }; } @@ -343,4 +348,88 @@ describe('message-runtime-queue', () => { }), ); }); + + it('splits merge_ready owner turns into a fresh task and cancels the stale finalize turn', async () => { + const task = makeTask({ + id: 'task-merge-ready-owner', + status: 'merge_ready', + updated_at: '2026-03-30T00:00:00.000Z', + }); + createPairedTask(task); + insertPairedTurnOutput(task.id, 1, 'reviewer', 'DONE\nreview finished'); + const staleFinalizeTurn = buildPairedTurnIdentity({ + taskId: task.id, + taskUpdatedAt: task.updated_at, + intentKind: 'finalize-owner-turn', + role: 'owner', + }); + markPairedTurnRunning({ + turnIdentity: staleFinalizeTurn, + executorServiceId: 'claude', + executorAgentType: 'claude-code', + runId: 'run-stale-finalize', + }); + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + const outcome = await runQueuedGroupTurn({ + chatJid: task.chat_jid, + group: makeGroup(), + runId: 'run-owner-after-merge-ready', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + timezone: 'UTC', + missedMessages: [ + { + id: 'human-merge-ready-1', + chat_jid: task.chat_jid, + sender: 'user@test', + sender_name: 'User', + content: '새 질문입니다', + timestamp: '2026-03-30T00:00:02.000Z', + seq: 47, + is_bot_message: false, + }, + ], + task, + roleToChannel: { + owner: null, + reviewer: makeChannel(), + arbiter: null, + }, + ownerChannel: makeChannel(), + lastAgentTimestamps: {}, + saveState: vi.fn(), + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + labelPairedSenders: (_chatJid, messages) => messages, + formatMessages: () => 'formatted prompt', + }); + + expect(outcome).toBe(true); + const freshTask = getLatestOpenPairedTaskForChat(task.chat_jid); + expect(freshTask).toBeDefined(); + expect(freshTask?.id).not.toBe(task.id); + expect(getPairedTaskById(task.id)).toMatchObject({ + status: 'completed', + completion_reason: 'superseded', + }); + expect(getPairedTurnById(staleFinalizeTurn.turnId)).toMatchObject({ + state: 'cancelled', + }); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.not.stringContaining('DONE\nreview finished'), + deliveryRole: 'owner', + forcedRole: 'owner', + pairedTurnIdentity: expect.objectContaining({ + taskId: freshTask?.id, + intentKind: 'owner-turn', + role: 'owner', + }), + }), + ); + }); }); diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 485dde0..698b444 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -4,6 +4,7 @@ import { buildArbiterPromptForTask, buildPairedTurnPrompt, } from './message-runtime-prompts.js'; +import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js'; import { buildPendingPairedTurn, executePendingPairedTurn, @@ -157,16 +158,32 @@ export async function runQueuedGroupTurn(args: { }): Promise { const { chatJid, group, runId, log, missedMessages, task, roleToChannel } = args; - const taskStatus = task?.status; + let currentTask = task; const hasHumanMsg = task ? !missedMessages.every( (message) => message.is_from_me === true || !!message.is_bot_message, ) : !isBotOnlyPairedRoomTurn(chatJid, missedMessages); - const lastTurnOutputRole = task - ? (getPairedTurnOutputs(task.id).at(-1)?.role ?? null) + let fallbackMessages = missedMessages; + if (currentTask && hasHumanMsg) { + const resolvedTask = resolveOwnerTaskForHumanMessage({ + group, + chatJid, + existingTask: currentTask, + }); + 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 turnRole = task + const turnRole = currentTask ? hasHumanMsg ? resolveQueuedTurnRole({ taskStatus, @@ -203,43 +220,46 @@ export async function runQueuedGroupTurn(args: { const turnChannel = turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole]; const cursorKey = resolveCursorKeyForRole(chatJid, turnRole); - const forcedRole = task ? turnRole : undefined; - const queuedIntentKind = task + const forcedRole = currentTask ? turnRole : undefined; + const queuedIntentKind = currentTask ? resolveQueuedTurnReservationIntent({ - task, + task: currentTask, turnRole, hasHumanMessage: hasHumanMsg, }) : null; const pairedTurnIdentity = - task && queuedIntentKind + currentTask && queuedIntentKind ? buildPairedTurnIdentity({ - taskId: task.id, - taskUpdatedAt: task.updated_at, + taskId: currentTask.id, + taskUpdatedAt: currentTask.updated_at, intentKind: queuedIntentKind, role: turnRole, }) : undefined; let prompt: string; - if (turnRole === 'arbiter' && task) { + if (turnRole === 'arbiter' && currentTask) { const recentMessages = getRecentChatMessages(chatJid, 20); prompt = buildArbiterPromptForTask({ - task, + task: currentTask, chatJid, timezone: args.timezone, - turnOutputs: getPairedTurnOutputs(task.id), + turnOutputs, recentMessages, labeledRecentMessages: args.labelPairedSenders(chatJid, recentMessages), }); - } else if (task) { + } else if (currentTask) { prompt = buildPairedTurnPrompt({ - taskId: task.id, + taskId: currentTask.id, chatJid, timezone: args.timezone, missedMessages, - labeledFallbackMessages: args.labelPairedSenders(chatJid, missedMessages), - turnOutputs: getPairedTurnOutputs(task.id), + labeledFallbackMessages: args.labelPairedSenders( + chatJid, + fallbackMessages, + ), + turnOutputs, }); } else { prompt = args.formatMessages( @@ -272,19 +292,19 @@ export async function runQueuedGroupTurn(args: { return false; } - if (task) { + if (currentTask) { const claimed = claimPairedTurnExecution({ chatJid, runId, - task, + task: currentTask, intentKind: queuedIntentKind!, }); if (!claimed) { log.info( { - taskId: task.id, + taskId: currentTask.id, taskStatus, - taskUpdatedAt: task.updated_at, + taskUpdatedAt: currentTask.updated_at, intentKind: queuedIntentKind, turnRole, }, @@ -310,7 +330,7 @@ export async function runQueuedGroupTurn(args: { chatJid, runId, channel: turnChannel, - deliveryRole: task ? turnRole : undefined, + deliveryRole: currentTask ? turnRole : undefined, startSeq, endSeq, hasHumanMessage: hasHumanMsg, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 9fc06d2..d227839 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; +import * as pairedExecutionContext from './paired-execution-context.js'; /** Prefix helper for progress message assertions */ const P = (text: string) => `${TASK_STATUS_MESSAGE_PREFIX}${text}`; @@ -34,6 +35,10 @@ vi.mock('./config.js', () => ({ vi.mock('./paired-execution-context.js', () => ({ preparePairedExecutionContext: vi.fn(() => undefined), completePairedExecutionContext: vi.fn(), + resolveOwnerTaskForHumanMessage: vi.fn((args?: { existingTask?: unknown }) => ({ + task: args?.existingTask ?? null, + supersededTask: null, + })), })); vi.mock('./db.js', () => { @@ -693,6 +698,131 @@ describe('createMessageRuntime', () => { expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); }); + it('suppresses a stale owner work item when a new human message supersedes a merge_ready task', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const enqueueMessageCheck = vi.fn(); + const oldTask = { + id: 'task-merge-ready-old', + 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: 'merge_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; + const newTask = { + ...oldTask, + id: 'task-fresh-owner-turn', + status: 'active', + completion_reason: null, + created_at: '2026-03-30T00:00:05.000Z', + updated_at: '2026-03-30T00:00:05.000Z', + } as any; + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getOpenWorkItem).mockReturnValue({ + id: 99, + group_folder: group.folder, + chat_jid: chatJid, + agent_type: 'codex', + service_id: 'claude', + delivery_role: 'owner', + status: 'delivery_retry', + start_seq: 1, + end_seq: 1, + result_payload: '이전 final입니다.', + delivery_attempts: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + delivered_at: null, + delivery_message_id: null, + last_error: 'discord send failed', + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '새 작업입니다.', + timestamp: '2026-03-30T00:00:10.000Z', + seq: 10, + }, + ]); + vi.mocked(db.getLatestOpenPairedTaskForChat) + .mockReturnValueOnce(oldTask) + .mockReturnValue(newTask); + vi.mocked( + pairedExecutionContext.resolveOwnerTaskForHumanMessage, + ).mockReturnValue({ + task: newTask, + supersededTask: oldTask, + }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, input, _onProcess, onOutput) => { + expect(input.prompt).toContain('새 작업입니다.'); + expect(input.prompt).not.toContain('이전 final입니다.'); + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'DONE\n새 작업 처리', + output: { visibility: 'public', text: 'DONE\n새 작업 처리' }, + } as any); + return { + status: 'success', + result: 'DONE\n새 작업 처리', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRoomBindings: () => ({ [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-supersede-owner-work-item', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(db.markWorkItemDelivered).toHaveBeenCalledWith(99); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).not.toHaveBeenCalledWith( + chatJid, + '이전 final입니다.', + ); + }); + it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index eba3a1c..de06cbe 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -4,6 +4,7 @@ import { getOpenWorkItemForChat, getMessagesSinceSeq, getLatestOpenPairedTaskForChat, + markWorkItemDelivered, getPairedTaskById, } from './db.js'; import { @@ -39,6 +40,7 @@ import { getProcessableMessages, shouldSkipBotOnlyCollaboration, } from './message-runtime-rules.js'; +import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js'; import { enqueuePairedFollowUpAfterEvent, schedulePairedFollowUpWithMessageCheck, @@ -345,14 +347,55 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // 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); + let pendingTask = hasReviewerLease(chatJid) + ? getLatestOpenPairedTaskForChat(chatJid) + : null; + let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE); + if ( + pendingTask?.status === 'merge_ready' && + openWorkItem?.delivery_role === 'owner' + ) { + const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0'; + const preflightRawMessages = getMessagesSinceSeq( + chatJid, + sinceSeqCursor, + deps.assistantName, + ); + const preflightMessages = filterLoopingPairedBotMessages( + chatJid, + getProcessableMessages(chatJid, preflightRawMessages, channel), + FAILURE_FINAL_TEXT, + ); + const hasFreshHumanMessage = preflightMessages.some( + (message) => message.is_from_me !== true && !message.is_bot_message, + ); + if (hasFreshHumanMessage) { + const resolvedTask = resolveOwnerTaskForHumanMessage({ + group, + chatJid, + existingTask: pendingTask, + }); + pendingTask = resolvedTask.task; + if (resolvedTask.supersededTask) { + markWorkItemDelivered(openWorkItem.id); + log.info( + { + chatJid, + workItemId: openWorkItem.id, + supersededTaskId: resolvedTask.supersededTask.id, + replacementTaskId: resolvedTask.task?.id ?? null, + }, + 'Suppressed stale owner delivery retry because a new human message superseded the merge_ready task', + ); + openWorkItem = undefined; + } + } + } const openWorkItemOutcome = await processOpenWorkItemDelivery({ chatJid, runId, openWorkItem, - pendingTask: hasReviewerLease(chatJid) - ? getLatestOpenPairedTaskForChat(chatJid) - : null, + pendingTask, channel, roleToChannel, log, diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 6caef09..f737260 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -12,10 +12,12 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./db.js', () => { const updatePairedTask = vi.fn(); return { + cancelPairedTurn: vi.fn(), createPairedTask: vi.fn(), getLatestPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), getPairedTaskById: vi.fn(), + getPairedTurnById: vi.fn(), getPairedWorkspace: vi.fn(), updatePairedTask, updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => { diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 9461095..799a663 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -18,10 +18,12 @@ import { REVIEWER_AGENT_TYPE, } from './config.js'; import { + cancelPairedTurn, createPairedTask, getLatestPairedTaskForChat, getLatestOpenPairedTaskForChat, getPairedTaskById, + getPairedTurnById, getPairedWorkspace, hasActiveCiWatcherForChat, releasePairedTaskExecutionLease, @@ -52,7 +54,10 @@ import { prepareReviewerWorkspaceForExecution, provisionOwnerWorkspaceForPairedTask, } from './paired-workspace-manager.js'; -import type { PairedTurnIdentity } from './paired-turn-identity.js'; +import { + buildPairedTurnIdentity, + type PairedTurnIdentity, +} from './paired-turn-identity.js'; import type { AgentType, PairedRoomRole, @@ -97,32 +102,12 @@ function resolvePairedTaskServiceShadow( return role === 'owner' ? CODEX_MAIN_SERVICE_ID : CODEX_REVIEW_SERVICE_ID; } -// --------------------------------------------------------------------------- -// ensureActiveTask -// --------------------------------------------------------------------------- - -function ensureActiveTask( - group: RegisteredGroup, - chatJid: string, - roomRoleContext: RoomRoleContext, - hasHumanMessage?: boolean, -): PairedTask | null { - const canonicalWorkDir = ensurePairedProject(group, chatJid); - if (!canonicalWorkDir) { - return null; - } - - const existing = getLatestOpenPairedTaskForChat(chatJid); - if (existing) { - return existing; - } - - // Don't create a new task for bot-only messages — prevents - // ESCALATE → completed → bot message triggers new task → loop. - if (!hasHumanMessage) { - return null; - } - +function createActiveTaskForRoom(args: { + group: RegisteredGroup; + chatJid: string; + canonicalWorkDir: string; +}): PairedTask { + const { group, chatJid, canonicalWorkDir } = args; const now = new Date().toISOString(); const rolePlan = resolveRoleAgentPlan({ paired: true, @@ -172,6 +157,137 @@ function ensureActiveTask( return task; } +function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void { + const turnIdentity = buildPairedTurnIdentity({ + taskId: task.id, + taskUpdatedAt: task.updated_at, + intentKind: 'finalize-owner-turn', + role: 'owner', + }); + const existingTurn = getPairedTurnById(turnIdentity.turnId); + if ( + !existingTurn || + (existingTurn.state !== 'running' && existingTurn.state !== 'delegated') + ) { + return; + } + cancelPairedTurn({ + turnIdentity, + error: 'Superseded by a newer human message before owner finalize delivery.', + }); + logger.info( + { + taskId: task.id, + turnId: turnIdentity.turnId, + turnState: existingTurn.state, + }, + 'Cancelled stale finalize-owner turn after a new human message superseded the merge_ready task', + ); +} + +export interface ResolvedOwnerHumanTask { + task: PairedTask | null; + supersededTask: PairedTask | null; +} + +export function resolveOwnerTaskForHumanMessage(args: { + group: RegisteredGroup; + chatJid: string; + existingTask?: PairedTask | null; +}): ResolvedOwnerHumanTask { + const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid); + const existing = + args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null; + + if (!existing) { + return { + task: canonicalWorkDir + ? createActiveTaskForRoom({ + group: args.group, + chatJid: args.chatJid, + canonicalWorkDir, + }) + : null, + supersededTask: null, + }; + } + + if (existing.status !== 'merge_ready' || !canonicalWorkDir) { + return { + task: existing, + supersededTask: null, + }; + } + + const now = new Date().toISOString(); + const superseded = transitionPairedTaskStatus({ + taskId: existing.id, + currentStatus: existing.status, + nextStatus: 'completed', + expectedUpdatedAt: existing.updated_at, + updatedAt: now, + patch: { + completion_reason: 'superseded', + }, + }); + + if (!superseded) { + return { + task: getLatestOpenPairedTaskForChat(args.chatJid) ?? existing, + supersededTask: null, + }; + } + + cancelOutstandingFinalizeOwnerTurn(existing); + + return { + task: createActiveTaskForRoom({ + group: args.group, + chatJid: args.chatJid, + canonicalWorkDir, + }), + supersededTask: existing, + }; +} + +// --------------------------------------------------------------------------- +// ensureActiveTask +// --------------------------------------------------------------------------- + +function ensureActiveTask( + group: RegisteredGroup, + chatJid: string, + roomRoleContext: RoomRoleContext, + hasHumanMessage?: boolean, +): PairedTask | null { + const existing = getLatestOpenPairedTaskForChat(chatJid); + if (roomRoleContext.role === 'owner' && hasHumanMessage) { + return resolveOwnerTaskForHumanMessage({ + group, + chatJid, + existingTask: existing ?? null, + }).task; + } + if (existing) { + return existing; + } + + // Don't create a new task for bot-only messages — prevents + // ESCALATE → completed → bot message triggers new task → loop. + if (!hasHumanMessage) { + return null; + } + const canonicalWorkDir = ensurePairedProject(group, chatJid); + if (!canonicalWorkDir) { + return null; + } + return createActiveTaskForRoom({ + group, + chatJid, + canonicalWorkDir, + }); +} + // --------------------------------------------------------------------------- // preparePairedExecutionContext // --------------------------------------------------------------------------- @@ -236,17 +352,12 @@ export function preparePairedExecutionContext(args: { const now = new Date().toISOString(); if (roomRoleContext.role === 'owner') { - // New human message → new ping-pong cycle. Reset round trip counter - // AND status so the owner turn is not treated as a finalize turn. - // Reset status on new human message so the owner gets a fresh working - // turn. merge_ready is only reset when a human message is present — - // without it, this is a finalize turn after reviewer approval and - // resetting would prevent task completion. + // New human message keeps the same task only for active review loops. + // merge_ready is split into a fresh task before this function runs. // Only reset round_trip_count when a human message is present — // bot-only ping-pong must accumulate the counter for loop detection. const hasHuman = args.hasHumanMessage === true; const needsStatusReset = - (latestTask.status === 'merge_ready' && hasHuman) || latestTask.status === 'review_ready' || latestTask.status === 'in_review'; if (hasHuman || needsStatusReset) { From 533d0fbd18439b8e5d741a7ba0b92acb6e69156a Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 01:22:20 +0900 Subject: [PATCH 02/12] Format supersession follow-up changes --- src/message-runtime.test.ts | 10 ++++++---- src/paired-execution-context.ts | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index d227839..e03d8a7 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -35,10 +35,12 @@ vi.mock('./config.js', () => ({ vi.mock('./paired-execution-context.js', () => ({ preparePairedExecutionContext: vi.fn(() => undefined), completePairedExecutionContext: vi.fn(), - resolveOwnerTaskForHumanMessage: vi.fn((args?: { existingTask?: unknown }) => ({ - task: args?.existingTask ?? null, - supersededTask: null, - })), + resolveOwnerTaskForHumanMessage: vi.fn( + (args?: { existingTask?: unknown }) => ({ + task: args?.existingTask ?? null, + supersededTask: null, + }), + ), })); vi.mock('./db.js', () => { diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 799a663..c464d22 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -173,7 +173,8 @@ function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void { } cancelPairedTurn({ turnIdentity, - error: 'Superseded by a newer human message before owner finalize delivery.', + error: + 'Superseded by a newer human message before owner finalize delivery.', }); logger.info( { @@ -358,8 +359,7 @@ export function preparePairedExecutionContext(args: { // bot-only ping-pong must accumulate the counter for loop detection. const hasHuman = args.hasHumanMessage === true; const needsStatusReset = - latestTask.status === 'review_ready' || - latestTask.status === 'in_review'; + latestTask.status === 'review_ready' || latestTask.status === 'in_review'; if (hasHuman || needsStatusReset) { if (needsStatusReset) { transitionPairedTaskStatus({ From c76902a84b561b3d6baffe507592d2ab525199e4 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 01:46:48 +0900 Subject: [PATCH 03/12] Release v0.2.2 --- CHANGELOG.md | 7 +++++++ README.md | 2 +- package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae531fe..bd2c69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 이 문서는 EJClaw의 주요 변경 사항을 기록합니다. +## [0.2.2] - 2026-04-15 + +### Fixed + +- CI watcher 완료 뒤 reviewer가 끝난 상태에서 owner 후속 흐름이 이어지지 않던 문제 수정 +- `merge_ready` 상태에서 새 human message가 오면 이전 task를 supersede 처리해, 직전 owner final이나 turn output이 다음 질문으로 누출되며 한 턴 늦게 보이던 문제 수정 + ## [0.2.1] - 2026-04-13 ### Changed diff --git a/README.md b/README.md index 63f4d48..d39268d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EJClaw -![Version](https://img.shields.io/badge/version-0.2.1-blue) +![Version](https://img.shields.io/badge/version-0.2.2-blue) ![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.101-blueviolet) ![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.120.0-green) ![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black) diff --git a/package.json b/package.json index 961813f..9fca48b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ejclaw", - "version": "0.2.1", + "version": "0.2.2", "description": "Discord tribunal multi-agent assistant with owner, reviewer, and arbiter roles.", "packageManager": "bun@1.3.11", "type": "module", From 2ab3bddc0e4578d05fdc9002aa56ac709b636a65 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 03:11:14 +0900 Subject: [PATCH 04/12] Recover Codex compaction failures in paired owner flow --- src/db/base-schema.ts | 1 + src/db/bootstrap.test.ts | 1 + src/db/migrations/011_owner-failure-count.ts | 23 ++++++ src/db/migrations/index.ts | 2 + src/db/paired-state.ts | 13 +++- src/message-agent-executor-attempt-runner.ts | 16 +++- src/message-agent-executor-lifecycle.ts | 77 +++++++++++++++++++- src/message-agent-executor-rules.test.ts | 11 +++ src/message-agent-executor.test.ts | 66 +++++++++++++++++ src/message-runtime-rules.test.ts | 26 ++++++- src/message-runtime-rules.ts | 4 +- src/paired-execution-context-owner.ts | 69 ++++++++++++++++-- src/paired-execution-context-shared.ts | 15 ++++ src/paired-execution-context.test.ts | 30 +++++++- src/paired-execution-context.ts | 7 +- src/session-recovery.test.ts | 35 +++++++++ src/session-recovery.ts | 23 ++++++ src/streamed-output-evaluator.test.ts | 29 +++++++- src/streamed-output-evaluator.ts | 18 ++++- src/types.ts | 1 + 20 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 src/db/migrations/011_owner-failure-count.ts diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 96c0b3d..1fd66ba 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -118,6 +118,7 @@ export function applyBaseSchema(database: Database): void { plan_notes TEXT, review_requested_at TEXT, round_trip_count INTEGER NOT NULL DEFAULT 0, + owner_failure_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', arbiter_verdict TEXT, arbiter_requested_at TEXT, diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index ee4eaf9..c375201 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -35,6 +35,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 8, name: 'paired_task_schema_cleanup' }, { version: 9, name: 'paired_workspace_project_schema_cleanup' }, { version: 10, name: 'paired_turn_provenance_upgrade' }, + { version: 11, name: 'owner_failure_count' }, ]; } diff --git a/src/db/migrations/011_owner-failure-count.ts b/src/db/migrations/011_owner-failure-count.ts new file mode 100644 index 0000000..f05efc4 --- /dev/null +++ b/src/db/migrations/011_owner-failure-count.ts @@ -0,0 +1,23 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const OWNER_FAILURE_COUNT_MIGRATION: SchemaMigrationDefinition = { + version: 11, + name: 'owner_failure_count', + apply(database: Database) { + if (!tableHasColumn(database, 'paired_tasks', 'owner_failure_count')) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN owner_failure_count INTEGER NOT NULL DEFAULT 0 + `); + } + + database.exec(` + UPDATE paired_tasks + SET owner_failure_count = 0 + WHERE owner_failure_count IS NULL + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index df4133f..85eaf42 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,6 +10,7 @@ import { RUNTIME_SERVICE_METADATA_MIGRATION } from './007_runtime-service-metada import { PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION } from './008_paired-task-schema-cleanup.js'; import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js'; import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js'; +import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -28,6 +29,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION, PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION, PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION, + OWNER_FAILURE_COUNT_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 0a08504..44eaa90 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -50,6 +50,7 @@ export type PairedTaskUpdates = Partial< | 'plan_notes' | 'review_requested_at' | 'round_trip_count' + | 'owner_failure_count' | 'status' | 'arbiter_verdict' | 'arbiter_requested_at' @@ -171,6 +172,7 @@ export function createPairedTaskInDatabase( plan_notes, review_requested_at, round_trip_count, + owner_failure_count, status, arbiter_verdict, arbiter_requested_at, @@ -178,7 +180,7 @@ export function createPairedTaskInDatabase( created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -195,6 +197,7 @@ export function createPairedTaskInDatabase( task.plan_notes, task.review_requested_at, task.round_trip_count, + task.owner_failure_count ?? 0, task.status, task.arbiter_verdict, task.arbiter_requested_at, @@ -279,6 +282,10 @@ export function updatePairedTaskInDatabase( fields.push('round_trip_count = ?'); values.push(updates.round_trip_count); } + if (updates.owner_failure_count !== undefined) { + fields.push('owner_failure_count = ?'); + values.push(updates.owner_failure_count); + } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); @@ -338,6 +345,10 @@ export function updatePairedTaskIfUnchangedInDatabase( fields.push('round_trip_count = ?'); values.push(updates.round_trip_count); } + if (updates.owner_failure_count !== undefined) { + fields.push('owner_failure_count = ?'); + values.push(updates.owner_failure_count); + } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); diff --git a/src/message-agent-executor-attempt-runner.ts b/src/message-agent-executor-attempt-runner.ts index 7dd62a4..30d015d 100644 --- a/src/message-agent-executor-attempt-runner.ts +++ b/src/message-agent-executor-attempt-runner.ts @@ -5,7 +5,10 @@ import type { AttemptStreamedTrigger } from './agent-attempt-retry.js'; import { runAgentProcess, type AgentOutput } from './agent-runner.js'; import { getCodexAccountCount } from './codex-token-rotation.js'; import type { PreparedPairedExecutionContext } from './paired-execution-context.js'; -import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; +import { + shouldResetCodexSessionOnAgentFailure, + shouldResetSessionOnAgentFailure, +} from './session-recovery.js'; import type { AgentType, RegisteredGroup, RoomRoleContext } from './types.js'; export interface MessageAgentAttempt { @@ -148,6 +151,13 @@ export async function runMessageAgentAttempt(args: { ) { resetSessionRequested = true; } + if ( + !isClaudeCodeAgent && + provider === 'codex' && + shouldResetCodexSessionOnAgentFailure(output) + ) { + resetSessionRequested = true; + } if ( output.newSessionId && !resetSessionRequested && @@ -197,7 +207,9 @@ export async function runMessageAgentAttempt(args: { ? outputText.slice(0, 160) : output.error?.slice(0, 160), }, - 'Suppressed retryable Claude session failure from chat output', + provider === 'claude' + ? 'Suppressed retryable Claude session failure from chat output' + : 'Suppressed retryable Codex session failure from chat output', ); return; } diff --git a/src/message-agent-executor-lifecycle.ts b/src/message-agent-executor-lifecycle.ts index 0206e7f..95c63b5 100644 --- a/src/message-agent-executor-lifecycle.ts +++ b/src/message-agent-executor-lifecycle.ts @@ -12,7 +12,11 @@ import type { } from './agent-error-detection.js'; import type { PairedExecutionLifecycle } from './message-agent-executor-paired.js'; import type { MessageAgentAttempt } from './message-agent-executor-attempt-runner.js'; -import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; +import { + shouldResetCodexSessionOnAgentFailure, + shouldResetSessionOnAgentFailure, + shouldRetryFreshCodexSessionOnAgentFailure, +} from './session-recovery.js'; import { getErrorMessage } from './utils.js'; type AttemptResult = 'success' | 'error'; @@ -208,6 +212,57 @@ export async function executeMessageAgentAttemptLifecycle(args: { }; }; + const isRetryableCodexSessionFailure = ( + attempt: MessageAgentAttempt, + ): boolean => { + if (provider !== 'codex' || attempt.sawOutput) { + return false; + } + + if (attempt.retryableSessionFailureDetected === true) { + return true; + } + + if (attempt.error == null) { + return false; + } + + return shouldRetryFreshCodexSessionOnAgentFailure({ + result: null, + error: getErrorMessage(attempt.error), + }); + }; + + const recoverRetryableCodexSessionFailure = async ( + attempt: MessageAgentAttempt, + ): Promise<{ + attempt: MessageAgentAttempt; + resolved: AttemptResult | null; + }> => { + if (!isRetryableCodexSessionFailure(attempt)) { + return { attempt, resolved: null }; + } + + clearStoredSession(); + clearRoleSdkSessions(); + log.warn( + 'Cleared poisoned Codex session before visible output, retrying fresh session', + ); + + const freshAttempt = await runTrackedAttempt('codex'); + if (!isRetryableCodexSessionFailure(freshAttempt)) { + return { attempt: freshAttempt, resolved: null }; + } + + clearStoredSession(); + log.warn('Fresh Codex retry also hit a retryable session failure'); + log.error('Retryable Codex session failure persisted after fresh retry'); + return { + attempt: freshAttempt, + resolved: 'error', + }; + }; + const handlePrimaryAttemptFailure = async ( attempt: MessageAgentAttempt, rotationMessage: string, @@ -294,6 +349,19 @@ export async function executeMessageAgentAttemptLifecycle(args: { ); } + if ( + !isClaudeCodeAgent && + provider === 'codex' && + (resetSessionRequested || shouldResetCodexSessionOnAgentFailure(output)) + ) { + clearStoredSession(); + clearRoleSdkSessions(); + log.warn( + { sessionFolder }, + 'Cleared poisoned Codex session after unrecoverable error', + ); + } + if (output.status === 'error') { return handlePrimaryAttemptFailure( attempt, @@ -354,6 +422,13 @@ export async function executeMessageAgentAttemptLifecycle(args: { } primaryAttempt = recoveredSessionAttempt.attempt; + const recoveredCodexSessionAttempt = + await recoverRetryableCodexSessionFailure(primaryAttempt); + if (recoveredCodexSessionAttempt.resolved) { + return recoveredCodexSessionAttempt.resolved; + } + primaryAttempt = recoveredCodexSessionAttempt.attempt; + if (primaryAttempt.error) { return handlePrimaryAttemptFailure( primaryAttempt, diff --git a/src/message-agent-executor-rules.test.ts b/src/message-agent-executor-rules.test.ts index 0662955..74583ad 100644 --- a/src/message-agent-executor-rules.test.ts +++ b/src/message-agent-executor-rules.test.ts @@ -172,6 +172,17 @@ describe('message-agent-executor-rules', () => { ).toBe('pending'); }); + it('resolves pending arbiter follow-up requeue after repeated owner execution failures', () => { + expect( + resolvePairedFollowUpQueueAction({ + completedRole: 'owner', + executionStatus: 'failed', + sawOutput: false, + taskStatus: 'arbiter_requested', + }), + ).toBe('pending'); + }); + it('does not request an executor-side follow-up after successful owner output moved the task to review_ready', () => { expect( resolvePairedFollowUpQueueAction({ diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 824cef9..2ff6275 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -196,7 +196,9 @@ vi.mock('./agent-error-detection.js', async (importOriginal) => { }); vi.mock('./session-recovery.js', () => ({ + shouldResetCodexSessionOnAgentFailure: vi.fn(() => false), shouldResetSessionOnAgentFailure: vi.fn(() => false), + shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false), shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false), })); @@ -3330,6 +3332,70 @@ describe('runAgentForGroup Claude rotation', () => { expect(deps.clearSession).toHaveBeenCalledWith('test-claude'); }); + it('drops a poisoned Codex session id before retrying a fresh session after remote compaction failure', async () => { + const group = { + ...makeGroup(), + folder: 'test-codex', + agentType: 'codex' as const, + }; + const deps = { + ...makeDeps(), + getSessions: () => ({ 'test-codex': 'stale-codex-session-id' }), + }; + + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: null, + arbiter_agent_type: null, + owner_service_id: 'codex-main', + reviewer_service_id: null, + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(sessionRecovery.shouldRetryFreshCodexSessionOnAgentFailure) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + vi.mocked(sessionRecovery.shouldResetCodexSessionOnAgentFailure) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + vi.mocked(agentRunner.runAgentProcess) + .mockImplementationOnce(async (_group, input) => { + expect(input.sessionId).toBe('stale-codex-session-id'); + throw new Error( + "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + ); + }) + .mockImplementationOnce(async (_group, input, _onProcess, onOutput) => { + expect(input.sessionId).toBeUndefined(); + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'fresh Codex retry success', + }); + return { + status: 'success', + result: 'fresh Codex retry success', + newSessionId: 'fresh-codex-session-id', + }; + }); + + const result = await runAgentForGroup(deps, { + group, + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-stale-codex-session-id-retry', + onOutput: async () => {}, + }); + + expect(result).toBe('success'); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); + expect(deps.clearSession).toHaveBeenCalledWith('test-codex'); + }); + it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => { const outputs: string[] = []; diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index f31c406..b0692de 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -133,7 +133,7 @@ describe('message-runtime-rules', () => { ).toEqual({ kind: 'none' }); }); - it('dispatches owner delivery success through a paired follow-up enqueue only for reviewer turns', () => { + it('dispatches owner delivery success through a paired follow-up enqueue for reviewer or arbiter turns', () => { expect( resolveFollowUpDispatch({ source: 'owner-delivery-success', @@ -143,6 +143,15 @@ describe('message-runtime-rules', () => { kind: 'enqueue', queueKind: 'paired-follow-up', }); + expect( + resolveFollowUpDispatch({ + source: 'owner-delivery-success', + nextTurnAction: { kind: 'arbiter-turn' }, + }), + ).toEqual({ + kind: 'enqueue', + queueKind: 'paired-follow-up', + }); expect( resolveFollowUpDispatch({ source: 'owner-delivery-success', @@ -151,6 +160,21 @@ describe('message-runtime-rules', () => { ).toEqual({ kind: 'none' }); }); + it('dispatches failed owner recovery through a paired follow-up when execution escalated to arbiter', () => { + expect( + resolveFollowUpDispatch({ + source: 'executor-recovery', + nextTurnAction: { kind: 'arbiter-turn' }, + completedRole: 'owner', + executionStatus: 'failed', + sawOutput: false, + }), + ).toEqual({ + kind: 'enqueue', + queueKind: 'paired-follow-up', + }); + }); + it('dispatches reviewer and arbiter delivery success through paired follow-up enqueue when a handoff is pending', () => { expect( resolveFollowUpDispatch({ diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 3e152da..7a284f9 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -183,7 +183,8 @@ export function resolveFollowUpDispatch(args: { args.source === 'owner-delivery-success' || args.completedRole === 'owner' ) { - return args.nextTurnAction.kind === 'reviewer-turn' + return args.nextTurnAction.kind === 'reviewer-turn' || + args.nextTurnAction.kind === 'arbiter-turn' ? { kind: 'enqueue', queueKind: 'paired-follow-up' } : { kind: 'none' }; } @@ -211,6 +212,7 @@ export function resolveFollowUpDispatch(args: { return { kind: 'none' }; } if ( + args.completedRole !== 'owner' && args.completedRole !== 'reviewer' && args.completedRole !== 'arbiter' ) { diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index 1c2f9dd..9d2b7c4 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -21,26 +21,72 @@ import { import type { PairedTask } from './types.js'; type OwnerFinalizeOutcome = 'stop' | 're_review'; +const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; export function handleFailedOwnerExecution(args: { task: PairedTask; taskId: string; + summary?: string | null; }): void { - const { task, taskId } = args; + const { task, taskId, summary } = args; + const now = new Date().toISOString(); + const nextFailureCount = (task.owner_failure_count ?? 0) + 1; + + if (nextFailureCount >= OWNER_FAILURE_ESCALATION_THRESHOLD) { + requestArbiterOrEscalate({ + taskId, + currentStatus: task.status, + expectedUpdatedAt: task.updated_at, + now, + arbiterLogMessage: + 'Owner failed repeatedly without a visible verdict — requesting arbiter', + escalateLogMessage: + 'Owner failed repeatedly without a visible verdict — escalating to user', + logContext: { + taskId, + role: 'owner', + previousStatus: task.status, + ownerFailureCount: nextFailureCount, + summary: summary?.slice(0, 160), + }, + patch: { + owner_failure_count: nextFailureCount, + }, + }); + return; + } + if (task.status !== 'active') { - const now = new Date().toISOString(); transitionPairedTaskStatus({ taskId, currentStatus: task.status, nextStatus: 'active', expectedUpdatedAt: task.updated_at, updatedAt: now, + patch: { + owner_failure_count: nextFailureCount, + }, + }); + } else { + applyPairedTaskPatch({ + taskId, + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + owner_failure_count: nextFailureCount, + }, }); - logger.info( - { taskId, role: 'owner', previousStatus: task.status }, - 'Reset task to active after failed execution', - ); } + logger.info( + { + taskId, + role: 'owner', + previousStatus: task.status, + ownerFailureCount: nextFailureCount, + summary: summary?.slice(0, 160), + }, + 'Reset task to active after failed owner execution', + ); } function handleOwnerFinalizeCompletion(args: { @@ -90,6 +136,9 @@ function handleOwnerFinalizeCompletion(args: { hasNewChanges, summary: summary?.slice(0, 100), }, + patch: { + owner_failure_count: 0, + }, }); return 'stop'; } @@ -102,6 +151,9 @@ function handleOwnerFinalizeCompletion(args: { nextStatus: 'active', expectedUpdatedAt: task.updated_at, updatedAt: now, + patch: { + owner_failure_count: 0, + }, }); } logger.info( @@ -126,6 +178,7 @@ function handleOwnerFinalizeCompletion(args: { updatedAt: now, patch: { completion_reason: 'done', + owner_failure_count: 0, }, }); logger.info( @@ -166,6 +219,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: { updatedAt: now, patch: { round_trip_count: task.round_trip_count + 1, + owner_failure_count: 0, }, }); if (hasActiveCiWatcherForChat(task.chat_jid)) { @@ -229,6 +283,9 @@ export function handleOwnerCompletion(args: { ownerVerdict, summary: summary?.slice(0, 100), }, + patch: { + owner_failure_count: 0, + }, }); return; } diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 58d2f95..18d3faa 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -247,6 +247,7 @@ export function transitionPairedTaskStatus(args: { plan_notes?: string | null; review_requested_at?: string | null; round_trip_count?: number; + owner_failure_count?: number; arbiter_verdict?: string | null; arbiter_requested_at?: string | null; completion_reason?: string | null; @@ -290,6 +291,7 @@ export function applyPairedTaskPatch(args: { plan_notes?: string | null; review_requested_at?: string | null; round_trip_count?: number; + owner_failure_count?: number; status?: PairedTaskStatus; arbiter_verdict?: string | null; arbiter_requested_at?: string | null; @@ -324,6 +326,17 @@ export function requestArbiterOrEscalate(args: { arbiterLogMessage: string; escalateLogMessage: string; logContext?: Record; + patch?: { + title?: string | null; + source_ref?: string | null; + plan_notes?: string | null; + review_requested_at?: string | null; + round_trip_count?: number; + owner_failure_count?: number; + arbiter_verdict?: string | null; + arbiter_requested_at?: string | null; + completion_reason?: string | null; + }; }): boolean { const { taskId, @@ -342,6 +355,7 @@ export function requestArbiterOrEscalate(args: { expectedUpdatedAt, updatedAt: now, patch: { + ...args.patch, arbiter_requested_at: now, }, }); @@ -358,6 +372,7 @@ export function requestArbiterOrEscalate(args: { expectedUpdatedAt, updatedAt: now, patch: { + ...args.patch, completion_reason: 'escalated', }, }); diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index f737260..47ca04a 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -139,6 +139,7 @@ function buildPairedTask(overrides: Partial = {}): PairedTask { plan_notes: null, review_requested_at: null, round_trip_count: 0, + owner_failure_count: 0, status: 'active', arbiter_verdict: null, arbiter_requested_at: null, @@ -980,7 +981,7 @@ describe('paired execution context', () => { ); }); - it('still resets owner tasks to active after failed execution', () => { + it('increments owner failure count and resets owner tasks to active after failed execution', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ status: 'merge_ready', @@ -998,11 +999,38 @@ describe('paired execution context', () => { 'task-1', expect.objectContaining({ status: 'active', + owner_failure_count: 1, updated_at: expect.any(String), }), ); }); + it('requests arbiter after repeated owner execution failures without a visible verdict', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'active', + owner_failure_count: 1, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'failed', + summary: "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + owner_failure_count: 2, + arbiter_requested_at: expect.any(String), + }), + ); + }); + it('releases the execution lease even when a completion handler throws', () => { const transitionError = new Error('transition failed'); vi.mocked(db.getPairedTaskById).mockReturnValue( diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index c464d22..bb58c88 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -137,6 +137,7 @@ function createActiveTaskForRoom(args: { plan_notes: null, review_requested_at: null, round_trip_count: 0, + owner_failure_count: 0, status: 'active', arbiter_verdict: null, arbiter_requested_at: null, @@ -369,7 +370,7 @@ export function preparePairedExecutionContext(args: { expectedUpdatedAt: latestTask.updated_at, updatedAt: now, patch: { - ...(hasHuman ? { round_trip_count: 0 } : {}), + ...(hasHuman ? { round_trip_count: 0, owner_failure_count: 0 } : {}), }, }); } else { @@ -378,7 +379,7 @@ export function preparePairedExecutionContext(args: { expectedUpdatedAt: latestTask.updated_at, updatedAt: now, patch: { - ...(hasHuman ? { round_trip_count: 0 } : {}), + ...(hasHuman ? { round_trip_count: 0, owner_failure_count: 0 } : {}), }, }); } @@ -551,7 +552,7 @@ export function completePairedExecutionContext(args: { handleFailedArbiterExecution({ task, taskId }); return; } - handleFailedOwnerExecution({ task, taskId }); + handleFailedOwnerExecution({ task, taskId, summary: args.summary }); return; } diff --git a/src/session-recovery.test.ts b/src/session-recovery.test.ts index 349d1f9..c4ba31d 100644 --- a/src/session-recovery.test.ts +++ b/src/session-recovery.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + shouldResetCodexSessionOnAgentFailure, shouldResetSessionOnAgentFailure, + shouldRetryFreshCodexSessionOnAgentFailure, shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; @@ -99,3 +101,36 @@ describe('shouldRetryFreshSessionOnAgentFailure', () => { ).toBe(false); }); }); + +describe('shouldResetCodexSessionOnAgentFailure', () => { + it('matches remote compact task failures that mention prompt_cache_retention', () => { + expect( + shouldResetCodexSessionOnAgentFailure({ + result: null, + error: + "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + }), + ).toBe(true); + }); + + it('does not match unrelated Codex failures', () => { + expect( + shouldResetCodexSessionOnAgentFailure({ + result: null, + error: 'Codex process exited with code 1', + }), + ).toBe(false); + }); +}); + +describe('shouldRetryFreshCodexSessionOnAgentFailure', () => { + it('retries the same remote compact task failure with a fresh session', () => { + expect( + shouldRetryFreshCodexSessionOnAgentFailure({ + result: null, + error: + "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + }), + ).toBe(true); + }); +}); diff --git a/src/session-recovery.ts b/src/session-recovery.ts index a098f3a..6ce675a 100644 --- a/src/session-recovery.ts +++ b/src/session-recovery.ts @@ -17,6 +17,11 @@ const SESSION_RETRY_PATTERNS = [ /invalid[^\n]*signature[^\n]*thinking/i, ]; +const CODEX_SESSION_RESET_PATTERNS = [ + /Error running remote compact task/i, + /prompt_cache_retention/i, +]; + function toText(value: string | object | null | undefined): string[] { if (!value) return []; if (typeof value === 'string') return [value]; @@ -51,3 +56,21 @@ export function shouldRetryFreshSessionOnAgentFailure( SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)), ); } + +export function shouldResetCodexSessionOnAgentFailure( + output: Pick, +): boolean { + const texts = [ + ...toText(getAgentOutputText(output)), + ...toText(output.error), + ]; + return texts.some((text) => + CODEX_SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)), + ); +} + +export function shouldRetryFreshCodexSessionOnAgentFailure( + output: Pick, +): boolean { + return shouldResetCodexSessionOnAgentFailure(output); +} diff --git a/src/streamed-output-evaluator.test.ts b/src/streamed-output-evaluator.test.ts index faf065b..dad210e 100644 --- a/src/streamed-output-evaluator.test.ts +++ b/src/streamed-output-evaluator.test.ts @@ -21,6 +21,7 @@ vi.mock('./codex-token-rotation.js', () => ({ })); vi.mock('./session-recovery.js', () => ({ + shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false), shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false), })); @@ -34,7 +35,10 @@ import { isClaudeAuthError, } from './agent-error-detection.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js'; -import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js'; +import { + shouldRetryFreshCodexSessionOnAgentFailure, + shouldRetryFreshSessionOnAgentFailure, +} from './session-recovery.js'; import { evaluateStreamedOutput, @@ -91,6 +95,7 @@ beforeEach(() => { shouldRotate: false, reason: '', }); + vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue(false); vi.mocked(shouldRetryFreshSessionOnAgentFailure).mockReturnValue(false); }); @@ -252,6 +257,28 @@ describe('evaluateStreamedOutput', () => { }); }); + describe('Codex retryable session failure suppression', () => { + it('suppresses remote compact failures before any visible output', () => { + vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue( + true, + ); + + const result = evaluateStreamedOutput( + successOutput( + "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + 'intermediate', + ), + freshState(), + codexOpts, + ); + + expect(result.shouldForwardOutput).toBe(false); + expect(result.suppressedRetryableSessionFailure).toBe(true); + expect(result.state.retryableSessionFailureDetected).toBe(true); + expect(result.state.sawOutput).toBe(false); + }); + }); + describe('Claude auth-expired banner', () => { it('suppresses output and returns newTrigger', () => { vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(true); diff --git a/src/streamed-output-evaluator.ts b/src/streamed-output-evaluator.ts index b78fc1d..a853328 100644 --- a/src/streamed-output-evaluator.ts +++ b/src/streamed-output-evaluator.ts @@ -15,7 +15,10 @@ import { } from './agent-error-detection.js'; import type { AgentOutput } from './agent-runner.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js'; -import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js'; +import { + shouldRetryFreshCodexSessionOnAgentFailure, + shouldRetryFreshSessionOnAgentFailure, +} from './session-recovery.js'; export interface StreamedTriggerReason { reason: AgentTriggerReason; @@ -74,6 +77,19 @@ export function evaluateStreamedOutput( }; } + if ( + isPrimaryCodex && + !state.sawOutput && + shouldRetryFreshCodexSessionOnAgentFailure(output) + ) { + nextState.retryableSessionFailureDetected = true; + return { + state: nextState, + shouldForwardOutput: false, + suppressedRetryableSessionFailure: true, + }; + } + if ( isPrimaryClaude && output.status === 'success' && diff --git a/src/types.ts b/src/types.ts index ff64efa..b06efe6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,7 @@ export interface PairedTask { plan_notes: string | null; review_requested_at: string | null; round_trip_count: number; + owner_failure_count?: number | null; status: PairedTaskStatus; arbiter_verdict: string | null; arbiter_requested_at: string | null; From 1846d59d2ffa484b157791cb8aa4acffcfdf5249 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 03:11:47 +0900 Subject: [PATCH 05/12] Format owner failure recovery changes --- src/paired-execution-context.test.ts | 3 ++- src/paired-execution-context.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 47ca04a..47c1520 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -1018,7 +1018,8 @@ describe('paired execution context', () => { taskId: 'task-1', role: 'owner', status: 'failed', - summary: "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", + summary: + "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", }); expect(db.updatePairedTask).toHaveBeenCalledWith( diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index bb58c88..46b0fcb 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -370,7 +370,9 @@ export function preparePairedExecutionContext(args: { expectedUpdatedAt: latestTask.updated_at, updatedAt: now, patch: { - ...(hasHuman ? { round_trip_count: 0, owner_failure_count: 0 } : {}), + ...(hasHuman + ? { round_trip_count: 0, owner_failure_count: 0 } + : {}), }, }); } else { @@ -379,7 +381,9 @@ export function preparePairedExecutionContext(args: { expectedUpdatedAt: latestTask.updated_at, updatedAt: now, patch: { - ...(hasHuman ? { round_trip_count: 0, owner_failure_count: 0 } : {}), + ...(hasHuman + ? { round_trip_count: 0, owner_failure_count: 0 } + : {}), }, }); } From a59813dee6af911e0a3d00d64b9f9497191b5c1b Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 15 Apr 2026 23:47:46 +0900 Subject: [PATCH 06/12] feat: support room-level reviewer and arbiter agent selection --- runners/agent-runner/src/ipc-mcp-stdio.ts | 12 +++ src/db.test.ts | 23 +++++ src/db.ts | 1 + src/db/room-registration.ts | 115 ++++++++++++++++++---- src/db/rooms.ts | 13 +++ src/db/runtime-tasks-rooms.ts | 9 ++ src/ipc-auth.test.ts | 100 +++++++++++++++++++ src/ipc-task-processor.ts | 88 +++++++++++++++++ src/ipc-types.ts | 75 +++++++++++++- src/message-runtime-rules.test.ts | 50 +++++++++- src/message-runtime-rules.ts | 40 +++++--- src/paired-execution-context.test.ts | 69 +++++++++++++ src/paired-execution-context.ts | 23 +++-- src/service-routing.test.ts | 24 +++++ src/service-routing.ts | 28 +++--- 15 files changed, 618 insertions(+), 52 deletions(-) diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 2840066..b47c67f 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -798,6 +798,14 @@ If folder is omitted, the host generates one automatically.`, .enum(['claude-code', 'codex']) .optional() .describe('Preferred owner agent type for the room'), + reviewer_agent_type: z + .enum(['claude-code', 'codex']) + .optional() + .describe('Optional reviewer agent type override for tribunal rooms'), + arbiter_agent_type: z + .enum(['claude-code', 'codex']) + .optional() + .describe('Optional arbiter agent type override for tribunal rooms'), folder: z .string() .optional() @@ -826,6 +834,8 @@ If folder is omitted, the host generates one automatically.`, name: string; room_mode?: 'single' | 'tribunal'; owner_agent_type?: 'claude-code' | 'codex'; + reviewer_agent_type?: 'claude-code' | 'codex'; + arbiter_agent_type?: 'claude-code' | 'codex'; folder?: string; trigger?: string; requires_trigger?: boolean; @@ -850,6 +860,8 @@ If folder is omitted, the host generates one automatically.`, name: args.name, room_mode: args.room_mode || 'single', owner_agent_type: args.owner_agent_type, + reviewer_agent_type: args.reviewer_agent_type, + arbiter_agent_type: args.arbiter_agent_type, folder: args.folder, trigger: args.trigger, requiresTrigger: args.requires_trigger, diff --git a/src/db.test.ts b/src/db.test.ts index af0efa6..ceacaa9 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -2396,6 +2396,29 @@ describe('room assignment writes', () => { }); }); + it('includes arbiter-only room agent overrides in capability views', () => { + assignRoom('dc:arbiter-only-capability', { + name: 'Arbiter Only Capability', + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + reviewerAgentType: 'claude-code', + arbiterAgentType: 'codex', + folder: 'arbiter-only-capability', + }); + + expect(getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort()).toEqual([ + 'claude-code', + 'codex', + ]); + expect( + getAllRoomBindings('codex')['dc:arbiter-only-capability'], + ).toMatchObject({ + name: 'Arbiter Only Capability', + folder: 'arbiter-only-capability', + agentType: 'codex', + }); + }); + it('updates room_settings-backed metadata across tribunal capability views', () => { assignRoom('dc:projection-room', { name: 'Projection Room', diff --git a/src/db.ts b/src/db.ts index aef3bbb..28a3e81 100644 --- a/src/db.ts +++ b/src/db.ts @@ -57,6 +57,7 @@ export { getRouterStateForService, getSession, getSessionForAgentType, + getStoredRoomRoleAgentPlan, getStoredRoomSettings, getTaskById, getTasksForGroup, diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index 775bebb..42008a2 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -1,8 +1,13 @@ import { Database } from 'bun:sqlite'; -import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js'; +import { + ARBITER_AGENT_TYPE, + OWNER_AGENT_TYPE, + REVIEWER_AGENT_TYPE, +} from '../config.js'; import { isValidGroupFolder } from '../group-folder.js'; import { logger } from '../logger.js'; +import { type RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js'; import type { AgentType, RegisteredGroup, RoomMode } from '../types.js'; export type RoomModeSource = 'explicit' | 'inferred'; @@ -58,8 +63,12 @@ export interface LegacyRoomMigrationPlan { export interface SyncRoomRoleOverridesOptions { ownerAgentConfig?: RegisteredGroup['agentConfig']; ownerCreatedAt?: string; + reviewerAgentType?: AgentType; reviewerAgentConfig?: RegisteredGroup['agentConfig']; reviewerCreatedAt?: string; + arbiterAgentType?: AgentType | null; + arbiterAgentConfig?: RegisteredGroup['agentConfig']; + arbiterCreatedAt?: string; updatedAt?: string; } @@ -207,14 +216,32 @@ export function getStoredRoomRowsFromDatabase( .filter((row): row is StoredRoomSettings => Boolean(row)); } +export function resolveStoredRoomRoleAgentPlan( + database: Database, + stored: StoredRoomSettings, +): RoleAgentPlan { + const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); + const ownerAgentType = + stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE; + + return resolveRoleAgentPlan({ + paired: stored.roomMode === 'tribunal', + groupAgentType: ownerAgentType, + configuredReviewer: + overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE, + configuredArbiter: overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, + }); +} + export function buildRegisteredGroupFromStoredSettings( database: Database, stored: StoredRoomSettings, requestedAgentType?: AgentType, ): (RegisteredGroup & { jid: string }) | undefined { const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); + const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored); const ownerAgentType = - stored.ownerAgentType || + rolePlan.ownerAgentType || (capabilityTypes.length > 0 ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) : undefined); @@ -233,8 +260,12 @@ export function buildRegisteredGroupFromStoredSettings( return undefined; } - const role: 'owner' | 'reviewer' = - ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer'; + const role: 'owner' | 'reviewer' | 'arbiter' = + ownerAgentType === resolvedAgentType + ? 'owner' + : rolePlan.reviewerAgentType === resolvedAgentType + ? 'reviewer' + : 'arbiter'; const capabilityMetadata = getStoredRoomRoleOverrideRows( database, stored.chatJid, @@ -273,24 +304,17 @@ export function resolveStoredRoomCapabilityTypes( database: Database, stored: StoredRoomSettings, ): AgentType[] { - const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); - const ownerAgentType = - stored.ownerAgentType ?? - overrides.get('owner')?.agentType ?? - OWNER_AGENT_TYPE; - if (stored.modeSource !== 'explicit') { return inferStoredRoomCapabilityTypes(database, stored); } - const types = new Set([ownerAgentType]); - if (stored.roomMode === 'tribunal') { - const reviewerAgentType = - overrides.get('reviewer')?.agentType ?? - (ownerAgentType === REVIEWER_AGENT_TYPE - ? OWNER_AGENT_TYPE - : REVIEWER_AGENT_TYPE); - types.add(reviewerAgentType); + const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored); + const types = new Set([rolePlan.ownerAgentType]); + if (stored.roomMode === 'tribunal' && rolePlan.reviewerAgentType) { + types.add(rolePlan.reviewerAgentType); + } + if (stored.roomMode === 'tribunal' && rolePlan.arbiterAgentType) { + types.add(rolePlan.arbiterAgentType); } return [...types]; } @@ -526,11 +550,19 @@ export function syncRoomRoleOverridesForRoom( }); if (roomMode === 'tribunal') { - const reviewerAgentType = - ownerAgentType === REVIEWER_AGENT_TYPE + const existingReviewerOverride = existingOverrides.get('reviewer'); + const previousDefaultReviewerAgentType = + existingOwnerOverride?.agentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; - const existingReviewerOverride = existingOverrides.get('reviewer'); + const computedReviewerAgentType = + ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; + const reviewerAgentType = + options.reviewerAgentType ?? + (existingReviewerOverride && + existingReviewerOverride.agentType !== previousDefaultReviewerAgentType + ? existingReviewerOverride.agentType + : computedReviewerAgentType); upsertRoomRoleOverride(database, chatJid, { role: 'reviewer', agentType: reviewerAgentType, @@ -547,6 +579,40 @@ export function syncRoomRoleOverridesForRoom( now, updatedAt: now, }); + + const existingArbiterOverride = existingOverrides.get('arbiter'); + const arbiterAgentType = + options.arbiterAgentType ?? + existingArbiterOverride?.agentType ?? + ARBITER_AGENT_TYPE ?? + null; + + if (arbiterAgentType) { + upsertRoomRoleOverride(database, chatJid, { + role: 'arbiter', + agentType: arbiterAgentType, + agentConfig: + options.arbiterAgentConfig ?? + (existingArbiterOverride?.agentType === arbiterAgentType + ? existingArbiterOverride.agentConfig + : undefined), + createdAt: + (existingArbiterOverride?.agentType === arbiterAgentType + ? existingArbiterOverride.createdAt + : undefined) ?? + options.arbiterCreatedAt ?? + now, + updatedAt: now, + }); + } else { + database + .prepare( + `DELETE FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'arbiter'`, + ) + .run(chatJid); + } } else { database .prepare( @@ -555,6 +621,13 @@ export function syncRoomRoleOverridesForRoom( AND role = 'reviewer'`, ) .run(chatJid); + database + .prepare( + `DELETE FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'arbiter'`, + ) + .run(chatJid); } } diff --git a/src/db/rooms.ts b/src/db/rooms.ts index 1f069ec..f50f933 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -17,6 +17,7 @@ import { normalizeStoredAgentType, resolveAssignedRoomFolder, resolveStoredRoomCapabilityTypes, + resolveStoredRoomRoleAgentPlan, syncRoomRoleOverridesForRoom, updateStoredRoomMetadata, upsertRoomRoleOverride, @@ -31,6 +32,8 @@ export interface AssignRoomInput { name: string; roomMode?: RoomMode; ownerAgentType?: AgentType; + reviewerAgentType?: AgentType; + arbiterAgentType?: AgentType | null; folder?: string; isMain?: boolean; workDir?: string; @@ -233,6 +236,8 @@ export function assignRoomInDatabase( syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, { ownerAgentConfig: input.ownerAgentConfig, ownerCreatedAt: input.addedAt ?? now, + reviewerAgentType: input.reviewerAgentType, + arbiterAgentType: input.arbiterAgentType, updatedAt: input.addedAt ?? now, }); })(); @@ -294,6 +299,14 @@ export function getStoredRoomSettingsFromDatabase( return getStoredRoomSettingsRowFromDatabase(database, chatJid); } +export function getStoredRoomRoleAgentPlanFromDatabase( + database: Database, + chatJid: string, +): ReturnType | undefined { + const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid); + return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined; +} + function getStoredRoomModeRowFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index 6eac8f2..6f94077 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -34,6 +34,7 @@ import { getExplicitRoomModeFromDatabase, getRegisteredAgentTypesForJidFromDatabase, getRegisteredGroupFromDatabase, + getStoredRoomRoleAgentPlanFromDatabase, getStoredRoomSettingsFromDatabase, setExplicitRoomModeInDatabase, setRegisteredGroupForTestsInDatabase, @@ -354,6 +355,14 @@ export function getStoredRoomSettings( return getStoredRoomSettingsFromDatabase(db, chatJid); } +export function getStoredRoomRoleAgentPlan( + chatJid: string, +): ReturnType { + const db = getDatabaseIfInitialized(); + if (!db) return undefined; + return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid); +} + export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index bebf3db..70314b3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -565,6 +565,73 @@ describe('IPC message authorization', () => { ); }); + it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => { + const sendMessage = vi.fn(async () => {}); + const injectInboundMessage = vi.fn(async () => {}); + + const result = await (forwardAuthorizedIpcMessage as any)( + { + type: 'inject_inbound_message', + chatJid: 'other@g.us', + text: 'act like a human input', + senderRole: 'owner', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }, + 'other-group', + false, + groups, + sendMessage, + injectInboundMessage, + ); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(injectInboundMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid: 'other@g.us', + text: 'act like a human input', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }), + ); + expect(result).toEqual( + expect.objectContaining({ + outcome: 'sent', + chatJid: 'other@g.us', + }), + ); + }); + + it('does not inject unauthorized inbound messages for another group', async () => { + const sendMessage = vi.fn(async () => {}); + const injectInboundMessage = vi.fn(async () => {}); + + const result = await (forwardAuthorizedIpcMessage as any)( + { + type: 'inject_inbound_message', + chatJid: 'third@g.us', + text: 'should be blocked', + sender: '1480575043007418420', + senderName: 'algo', + treatAsHuman: true, + sourceKind: 'trusted_external_bot', + }, + 'other-group', + false, + groups, + sendMessage, + injectInboundMessage, + ); + + expect(result.outcome).toBe('blocked'); + expect(sendMessage).not.toHaveBeenCalled(); + expect(injectInboundMessage).not.toHaveBeenCalled(); + }); + it('does not forward unauthorized IPC messages even when senderRole exists', async () => { const sendMessage = vi.fn(async () => {}); @@ -975,6 +1042,39 @@ describe('assign_room success', () => { ]); }); + it('main group can assign per-room reviewer and arbiter agent types', async () => { + await processTaskIpc( + { + type: 'assign_room', + jid: 'tribunal-role-overrides@g.us', + name: 'Tribunal Role Overrides', + room_mode: 'tribunal', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + }, + 'whatsapp_main', + true, + deps, + ); + + expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({ + chatJid: 'tribunal-role-overrides@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Tribunal Role Overrides', + ownerAgentType: 'claude-code', + }); + expect( + getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(), + ).toEqual(['claude-code', 'codex']); + expect( + getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'), + ).toMatchObject({ + agentType: 'codex', + }); + }); + it('assign_room rejects request with missing fields', async () => { await processTaskIpc( { diff --git a/src/ipc-task-processor.ts b/src/ipc-task-processor.ts index caf69c8..4fa0db9 100644 --- a/src/ipc-task-processor.ts +++ b/src/ipc-task-processor.ts @@ -241,6 +241,69 @@ export async function processTaskIpc( break; } + if (data.action === 'ejclaw_room_runtime') { + if (!data.chatJid) { + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: '', + stderr: '', + exitCode: 1, + error: 'Missing chatJid for ejclaw_room_runtime request', + }); + logger.warn( + { sourceGroup, requestId: data.requestId }, + 'Rejected ejclaw_room_runtime request without chatJid', + ); + break; + } + + if (!deps.getRoomRuntimeReport) { + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: '', + stderr: '', + exitCode: 1, + error: 'Room runtime reporting is not configured', + }); + logger.warn( + { sourceGroup, requestId: data.requestId, chatJid: data.chatJid }, + 'Rejected ejclaw_room_runtime request because runtime reporter is unavailable', + ); + break; + } + + const report = deps.getRoomRuntimeReport({ + chatJid: data.chatJid, + sourceGroup, + isMain, + }); + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: true, + action: 'ejclaw_room_runtime', + command: 'internal:ejclaw_room_runtime', + stdout: JSON.stringify(report, null, 2), + stderr: '', + exitCode: 0, + }); + logger.info( + { + sourceGroup, + requestId: data.requestId, + action: data.action, + chatJid: data.chatJid, + }, + 'Processed room runtime report request via IPC', + ); + break; + } + if (!isHostEvidenceAction(data.action)) { writeHostEvidenceResponse(sourceGroup, { requestId: data.requestId, @@ -405,11 +468,36 @@ export async function processTaskIpc( ); break; } + if ( + data.reviewer_agent_type !== undefined && + data.reviewer_agent_type !== 'claude-code' && + data.reviewer_agent_type !== 'codex' + ) { + logger.warn( + { sourceGroup, reviewerAgentType: data.reviewer_agent_type }, + 'Invalid assign_room request - unknown reviewer_agent_type', + ); + break; + } + if ( + data.arbiter_agent_type !== undefined && + data.arbiter_agent_type !== null && + data.arbiter_agent_type !== 'claude-code' && + data.arbiter_agent_type !== 'codex' + ) { + logger.warn( + { sourceGroup, arbiterAgentType: data.arbiter_agent_type }, + 'Invalid assign_room request - unknown arbiter_agent_type', + ); + break; + } deps.assignRoom(data.jid, { name: data.name, roomMode: data.room_mode, ownerAgentType: data.owner_agent_type, + reviewerAgentType: data.reviewer_agent_type, + arbiterAgentType: data.arbiter_agent_type, folder: data.folder, isMain: data.isMain, workDir: data.workDir, diff --git a/src/ipc-types.ts b/src/ipc-types.ts index 853789d..af9916c 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -1,6 +1,65 @@ import type { AvailableGroup } from './agent-runner.js'; import type { AssignRoomInput } from './db.js'; -import type { AgentType, RegisteredGroup, RoomMode } from './types.js'; +import type { + AgentType, + MessageSourceKind, + PairedTask, + RegisteredGroup, + RoomMode, +} from './types.js'; + +export interface InjectInboundMessagePayload { + chatJid: string; + text: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman: boolean; + sourceKind?: MessageSourceKind; +} + +export interface RoomRuntimeReport { + chatJid: string; + groupFolder: string; + room: RegisteredGroup | null; + queue: { + status: 'processing' | 'waiting' | 'inactive'; + runPhase: string; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + currentRunId: string | null; + runningTaskId: string | null; + processName: string | null; + ipcDir: string | null; + retryCount: number; + retryScheduledAt: number | null; + waiting: boolean; + }; + latestOpenTask: Pick< + PairedTask, + | 'id' + | 'status' + | 'title' + | 'round_trip_count' + | 'updated_at' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > | null; + recentMessages: Array<{ + id: string; + seq?: number; + timestamp: string; + sender: string; + senderName: string; + isFromMe: boolean; + isBotMessage: boolean; + sourceKind: MessageSourceKind; + contentPreview: string; + }>; +} export interface IpcDeps { sendMessage: ( @@ -19,6 +78,12 @@ export interface IpcDeps { isMain: boolean, availableGroups: AvailableGroup[], ) => void; + injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise; + getRoomRuntimeReport?: (args: { + chatJid: string; + sourceGroup: string; + isMain: boolean; + }) => RoomRuntimeReport; } export interface IpcMessagePayload { @@ -27,6 +92,12 @@ export interface IpcMessagePayload { text?: string; senderRole?: string; runId?: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman?: boolean; + sourceKind?: MessageSourceKind; } export interface IpcMessageForwardResult { @@ -54,6 +125,8 @@ export interface TaskIpcPayload { folder?: string; room_mode?: RoomMode; owner_agent_type?: AgentType; + reviewer_agent_type?: AgentType; + arbiter_agent_type?: AgentType | null; isMain?: boolean; workDir?: string; scopeKind?: string; diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index b0692de..7c78c6b 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -315,6 +315,28 @@ describe('message-runtime-rules', () => { expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId); }); + it('uses lease reviewer metadata when the room reviewer differs from the owner agent type', () => { + const resolution = resolveExecutionTarget({ + lease: { + ...baseLease, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + reviewer_service_id: 'codex-review', + }, + pairedTaskStatus: 'review_ready', + groupFolder: 'group-1', + groupAgentType: 'claude-code', + }); + + expect(resolution).toMatchObject({ + activeRole: 'reviewer', + configuredAgentType: 'codex', + effectiveAgentType: 'codex', + sessionFolder: 'group-1:reviewer', + }); + expect(resolution.effectiveServiceId).toBe('codex-review'); + }); + it('resolves merge_ready execution target back to the owner/finalize path', () => { const resolution = resolveExecutionTarget({ lease: baseLease, @@ -346,8 +368,8 @@ describe('message-runtime-rules', () => { expect(resolution).toMatchObject({ inferredRole: 'arbiter', activeRole: 'arbiter', - configuredAgentType: 'claude-code', - effectiveAgentType: 'claude-code', + configuredAgentType: 'codex', + effectiveAgentType: 'codex', sessionFolder: 'group-1:arbiter', }); expect(resolution.effectiveServiceId).toBe( @@ -355,6 +377,30 @@ describe('message-runtime-rules', () => { ); }); + it('uses lease arbiter metadata when the room arbiter differs from the owner agent type', () => { + const resolution = resolveExecutionTarget({ + lease: { + ...baseLease, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + reviewer_service_id: 'claude', + arbiter_agent_type: 'claude-code', + arbiter_service_id: 'claude', + }, + pairedTaskStatus: 'arbiter_requested', + groupFolder: 'group-1', + groupAgentType: 'codex', + }); + + expect(resolution).toMatchObject({ + activeRole: 'arbiter', + configuredAgentType: 'claude-code', + effectiveAgentType: 'claude-code', + sessionFolder: 'group-1:arbiter', + }); + expect(resolution.effectiveServiceId).toBe('claude'); + }); + it('honors an arbiter forced role only when arbiter is configured', () => { const resolution = resolveExecutionTarget({ lease: { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 7a284f9..af96408 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -248,12 +248,14 @@ export function resolveCursorKeyForRole( export function resolveConfiguredRoleAgentPlan( paired: boolean, groupAgentType: AgentType | undefined, + configuredReviewer: AgentType = REVIEWER_AGENT_TYPE, + configuredArbiter: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): RoleAgentPlan { return resolveRoleAgentPlan({ paired, groupAgentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, + configuredReviewer, + configuredArbiter, }); } @@ -261,8 +263,15 @@ export function resolveConfiguredRoleAgentPlan( export function resolveEffectiveAgentType( role: 'owner' | 'reviewer' | 'arbiter', groupAgentType: AgentType | undefined, + reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE, + arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): AgentType { - const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType); + const plan = resolveConfiguredRoleAgentPlan( + role !== 'owner', + groupAgentType, + reviewerAgentType, + arbiterAgentType, + ); return resolveAgentTypeForRole(plan, role); } @@ -271,10 +280,17 @@ export function resolveSessionFolder( groupFolder: string, role: 'owner' | 'reviewer' | 'arbiter', groupAgentType: AgentType | undefined, + reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE, + arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE, ): string { // Arbiter always gets a separate session — must never share with owner/reviewer if (role === 'arbiter') return `${groupFolder}:arbiter`; - const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType); + const plan = resolveConfiguredRoleAgentPlan( + role !== 'owner', + groupAgentType, + reviewerAgentType, + arbiterAgentType, + ); const effectiveType = resolveAgentTypeForRole(plan, role); const groupDefault = plan.ownerAgentType; if (effectiveType === groupDefault) return groupFolder; @@ -316,14 +332,14 @@ export function resolveExecutionTarget(args: { const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer'); const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter'); + const ownerAgentType = args.lease.owner_agent_type ?? args.groupAgentType; const roleAgentPlan = resolveConfiguredRoleAgentPlan( - args.lease.reviewer_agent_type != null, - args.groupAgentType, - ); - const configuredAgentType = resolveEffectiveAgentType( - activeRole, - args.groupAgentType, + reviewerServiceId != null, + ownerAgentType, + args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE, + args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE, ); + const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole); const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; return { @@ -339,7 +355,9 @@ export function resolveExecutionTarget(args: { sessionFolder: resolveSessionFolder( args.groupFolder, activeRole, - args.groupAgentType, + roleAgentPlan.ownerAgentType, + roleAgentPlan.reviewerAgentType ?? REVIEWER_AGENT_TYPE, + roleAgentPlan.arbiterAgentType ?? ARBITER_AGENT_TYPE, ), }; } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 47c1520..bd5c662 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -250,6 +250,32 @@ describe('paired execution context', () => { }); }); + it('uses room role context agent overrides when creating a paired task', () => { + preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-room-overrides', + roomRoleContext: { + ...ownerContext, + ownerAgentType: 'codex', + reviewerAgentType: 'codex', + reviewerServiceId: config.CODEX_REVIEW_SERVICE_ID, + arbiterAgentType: 'claude-code', + }, + hasHumanMessage: true, + }); + + expect(db.createPairedTask).toHaveBeenCalledWith( + expect.objectContaining({ + owner_service_id: config.CODEX_MAIN_SERVICE_ID, + reviewer_service_id: config.CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + }), + ); + }); + it('persists stable role-slot service shadow instead of the transient failover owner lease', () => { preparePairedExecutionContext({ group, @@ -562,6 +588,49 @@ describe('paired execution context', () => { delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; }); + it('honors room-level reviewer agent overrides for unsafe host reviewer runtime env', () => { + process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + reviewer_agent_type: 'codex', + }), + ); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + reviewer_agent_type: 'codex', + }), + ); + vi.mocked( + pairedWorkspaceManager.prepareReviewerWorkspaceForExecution, + ).mockReturnValue({ + workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'), + autoRefreshed: false, + }); + + const result = preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-host-reviewer-room-override', + roomRoleContext: { + ...reviewerContext, + reviewerAgentType: 'codex', + }, + }); + + expect(result?.envOverrides).toMatchObject({ + EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer', + EJCLAW_PAIRED_ROLE: 'reviewer', + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + }); + expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBeUndefined(); + expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined(); + delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; + }); + it('completePairedExecutionContext logs without error', () => { completePairedExecutionContext({ taskId: 'task-1', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 46b0fcb..41ec9dc 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -106,14 +106,17 @@ function createActiveTaskForRoom(args: { group: RegisteredGroup; chatJid: string; canonicalWorkDir: string; + roomRoleContext?: RoomRoleContext; }): PairedTask { - const { group, chatJid, canonicalWorkDir } = args; + const { group, chatJid, canonicalWorkDir, roomRoleContext } = args; const now = new Date().toISOString(); const rolePlan = resolveRoleAgentPlan({ paired: true, - groupAgentType: group.agentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, + groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType, + configuredReviewer: + roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE, + configuredArbiter: + roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE, }); const ownerServiceShadow = resolvePairedTaskServiceShadow( 'owner', @@ -195,6 +198,7 @@ export interface ResolvedOwnerHumanTask { export function resolveOwnerTaskForHumanMessage(args: { group: RegisteredGroup; chatJid: string; + roomRoleContext?: RoomRoleContext; existingTask?: PairedTask | null; }): ResolvedOwnerHumanTask { const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid); @@ -208,6 +212,7 @@ export function resolveOwnerTaskForHumanMessage(args: { group: args.group, chatJid: args.chatJid, canonicalWorkDir, + roomRoleContext: args.roomRoleContext, }) : null, supersededTask: null, @@ -247,6 +252,7 @@ export function resolveOwnerTaskForHumanMessage(args: { group: args.group, chatJid: args.chatJid, canonicalWorkDir, + roomRoleContext: args.roomRoleContext, }), supersededTask: existing, }; @@ -267,6 +273,7 @@ function ensureActiveTask( return resolveOwnerTaskForHumanMessage({ group, chatJid, + roomRoleContext, existingTask: existing ?? null, }).task; } @@ -287,6 +294,7 @@ function ensureActiveTask( group, chatJid, canonicalWorkDir, + roomRoleContext, }); } @@ -470,7 +478,7 @@ export function preparePairedExecutionContext(args: { envOverrides, buildPairedReadonlyRuntimeEnvOverrides({ role: 'reviewer', - agentType: REVIEWER_AGENT_TYPE, + agentType: roomRoleContext.reviewerAgentType ?? REVIEWER_AGENT_TYPE, unsafeHostPairedMode, }), ); @@ -489,7 +497,10 @@ export function preparePairedExecutionContext(args: { envOverrides, buildPairedReadonlyRuntimeEnvOverrides({ role: 'arbiter', - agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE, + agentType: + roomRoleContext.arbiterAgentType ?? + ARBITER_AGENT_TYPE ?? + REVIEWER_AGENT_TYPE, unsafeHostPairedMode, }), ); diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 2f7dc92..3c86cba 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -5,6 +5,7 @@ import { _setRegisteredGroupForTests, _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, + assignRoom, setChannelOwnerLease, setExplicitRoomMode, } from './db.js'; @@ -236,6 +237,29 @@ describe('service-routing global failover', () => { }); }); + it('uses room-level reviewer and arbiter agent overrides from assign_room', () => { + assignRoom('dc:room-role-overrides', { + name: 'Room Role Overrides', + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + reviewerAgentType: 'codex', + arbiterAgentType: 'claude-code', + folder: 'room-role-overrides', + }); + + expect(getEffectiveChannelLease('dc:room-role-overrides')).toMatchObject({ + chat_jid: 'dc:room-role-overrides', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'claude-code', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + arbiter_service_id: 'claude', + owner_failover_active: false, + explicit: false, + }); + }); + it('defaults to the configured owner service for chats without canonical room settings', () => { expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({ chat_jid: 'dc:unregistered', diff --git a/src/service-routing.ts b/src/service-routing.ts index 1eb974d..35c53d9 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -12,6 +12,7 @@ import { clearChannelOwnerLease, getAllChannelOwnerLeases, getEffectiveRuntimeRoomMode, + getStoredRoomRoleAgentPlan, getStoredRoomSettings, type ChannelOwnerLeaseRow, } from './db.js'; @@ -46,8 +47,10 @@ function normalizeLeaseRow( row: ChannelOwnerLeaseRow, explicit: boolean, ): EffectiveChannelLease { + const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid); const ownerAgentType = row.owner_agent_type ?? + storedRolePlan?.ownerAgentType ?? getStoredRoomSettings(row.chat_jid)?.ownerAgentType ?? inferAgentTypeFromServiceShadow(row.owner_service_id) ?? 'claude-code'; @@ -56,6 +59,7 @@ function normalizeLeaseRow( ? null : (row.reviewer_agent_type ?? inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? + storedRolePlan?.reviewerAgentType ?? resolveRoleAgentPlan({ paired: true, groupAgentType: ownerAgentType, @@ -67,6 +71,7 @@ function normalizeLeaseRow( (row.arbiter_service_id ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) : undefined) ?? + storedRolePlan?.arbiterAgentType ?? null; return { @@ -102,20 +107,21 @@ function normalizeLeaseRow( }; } -function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { - return getStoredRoomSettings(chatJid)?.ownerAgentType; -} - function getDefaultLease(chatJid: string): EffectiveChannelLease { const roomMode = getEffectiveRuntimeRoomMode(chatJid); + const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid); const ownerAgentType = - resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE; - const rolePlan = resolveRoleAgentPlan({ - paired: roomMode === 'tribunal', - groupAgentType: ownerAgentType, - configuredReviewer: REVIEWER_AGENT_TYPE, - configuredArbiter: ARBITER_AGENT_TYPE, - }); + storedRolePlan?.ownerAgentType ?? + getStoredRoomSettings(chatJid)?.ownerAgentType ?? + OWNER_AGENT_TYPE; + const rolePlan = + storedRolePlan ?? + resolveRoleAgentPlan({ + paired: roomMode === 'tribunal', + groupAgentType: ownerAgentType, + configuredReviewer: REVIEWER_AGENT_TYPE, + configuredArbiter: ARBITER_AGENT_TYPE, + }); return { chat_jid: chatJid, From b6a24ad9fbcf49301eba358a7745894946fdc3f4 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 08:19:31 +0900 Subject: [PATCH 07/12] paired: carry forward latest owner final across superseded tasks --- prompts/claude-paired-room.md | 5 ++- src/db.test.ts | 41 +++++++++++++++++-- src/db/paired-turn-outputs.ts | 3 +- src/db/runtime-paired.ts | 2 + src/paired-execution-context.test.ts | 51 ++++++++++++++++++++++- src/paired-execution-context.ts | 61 ++++++++++++++++++++++++---- src/platform-prompts.test.ts | 6 +++ 7 files changed, 154 insertions(+), 15 deletions(-) diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md index 0813804..87d9803 100644 --- a/prompts/claude-paired-room.md +++ b/prompts/claude-paired-room.md @@ -3,6 +3,7 @@ You are the **reviewer** in this paired room. - Your role: review, challenge, verify the owner's work. When you find issues, tell the owner exactly what to fix — the owner is the implementer, not you +- Do not stop at rebuttal. If the owner's approach is viable but clearly suboptimal, suggest 1-2 better alternatives with the reason and tradeoff for each - The owner's role: implement, execute, respond to user requests - Do not infer role from the visible bot name — use the paired-room role context for this turn - When the arbiter renders a verdict (PROCEED/REVISE/RESET), follow it — the arbiter's judgment is binding @@ -17,6 +18,7 @@ Before accepting any proposal, run it through: 4. **Hidden assumptions** — What are we taking for granted that could be wrong? Push back with evidence when the owner is wrong. Hold your ground when you are right. Point out logical gaps, missing edge cases, over-engineering. Agree when the owner is genuinely correct. +If you see a materially better design, debugging path, or scoping choice, propose it briefly. Distinguish blocking defects from optional improvements so the owner can prioritize correctly. ## Completion status @@ -34,7 +36,8 @@ Push back with evidence when the owner is wrong. Hold your ground when you are r - Treat `EJCLAW_WORK_DIR` as the canonical verification root for this turn. You may inspect other local paths for context, but final review findings must be re-checked against `EJCLAW_WORK_DIR` - Do not use a different clone, canonical repo path, or cached session path as the sole basis for `BLOCKED`, `DONE_WITH_CONCERNS`, or change requests. If another path disagrees with `EJCLAW_WORK_DIR`, prefer `EJCLAW_WORK_DIR` and explicitly call out the mismatch - When test/typecheck/build evidence is needed, prefer the dedicated verification path (`run_verification`) over assuming the reviewer workspace should execute the full project locally +- Separate correctness issues from improvement ideas. If something is only a better alternative, label it as optional instead of blocking the owner unnecessarily - Stagnation: **Spinning** (same error 3+), **Oscillation** (alternating approaches), **Diminishing returns** (shrinking improvement), **No progress** (discussion without change) — name the pattern and report: **Status**, **Attempted**, **Recommendation** - Implementation, commits, and pushes require agreement from both sides. Either can veto -- Keep reviews concise — approve quickly when there is nothing to critique +- Keep reviews concise — approve quickly when there is nothing to critique, and keep alternative proposals short and actionable - Never mention or tag the user (@username) during the owner↔reviewer loop — the system handles escalation automatically. User is only notified when all resolution paths (including arbiter) are exhausted diff --git a/src/db.test.ts b/src/db.test.ts index ceacaa9..0d687f5 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1246,6 +1246,40 @@ describe('paired task state', () => { expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2); }); + it('preserves explicit created_at when inserting a paired turn output', () => { + createPairedTask({ + id: 'paired-task-turn-output-created-at', + chat_jid: 'dc:paired', + group_folder: 'paired-room', + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: null, + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + + insertPairedTurnOutput( + 'paired-task-turn-output-created-at', + 0, + 'owner', + 'carried forward owner final', + '2026-03-28T00:01:23.000Z', + ); + + const outputs = getPairedTurnOutputs('paired-task-turn-output-created-at'); + + expect(outputs).toHaveLength(1); + expect(outputs[0].created_at).toBe('2026-03-28T00:01:23.000Z'); + }); + it('fails init when paired task agent and service metadata conflict', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-shadow-'); const dbPath = path.join(tempDir, 'messages.db'); @@ -2406,10 +2440,9 @@ describe('room assignment writes', () => { folder: 'arbiter-only-capability', }); - expect(getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort()).toEqual([ - 'claude-code', - 'codex', - ]); + expect( + getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort(), + ).toEqual(['claude-code', 'codex']); expect( getAllRoomBindings('codex')['dc:arbiter-only-capability'], ).toMatchObject({ diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts index 65cdfb3..9c89bb3 100644 --- a/src/db/paired-turn-outputs.ts +++ b/src/db/paired-turn-outputs.ts @@ -11,6 +11,7 @@ export function insertPairedTurnOutputInDatabase( turnNumber: number, role: PairedRoomRole, outputText: string, + createdAt?: string, ): void { if (outputText.length > MAX_TURN_OUTPUT_CHARS) { logger.warn( @@ -36,7 +37,7 @@ export function insertPairedTurnOutputInDatabase( turnNumber, role, outputText.slice(0, MAX_TURN_OUTPUT_CHARS), - new Date().toISOString(), + createdAt ?? new Date().toISOString(), ); } diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts index fb875a4..807df33 100644 --- a/src/db/runtime-paired.ts +++ b/src/db/runtime-paired.ts @@ -300,6 +300,7 @@ export function insertPairedTurnOutput( turnNumber: number, role: PairedRoomRole, outputText: string, + createdAt?: string, ): void { insertPairedTurnOutputInDatabase( requireDatabase(), @@ -307,6 +308,7 @@ export function insertPairedTurnOutput( turnNumber, role, outputText, + createdAt, ); } diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index bd5c662..26556ba 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -18,7 +18,9 @@ vi.mock('./db.js', () => { getLatestOpenPairedTaskForChat: vi.fn(), getPairedTaskById: vi.fn(), getPairedTurnById: vi.fn(), + getPairedTurnOutputs: vi.fn(() => []), getPairedWorkspace: vi.fn(), + insertPairedTurnOutput: vi.fn(), updatePairedTask, updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => { updatePairedTask(id, updates); @@ -51,6 +53,7 @@ import * as config from './config.js'; import { completePairedExecutionContext, preparePairedExecutionContext, + resolveOwnerTaskForHumanMessage, } from './paired-execution-context.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js'; import type { @@ -250,6 +253,50 @@ describe('paired execution context', () => { }); }); + it('carries forward the latest owner final when a merge_ready task is superseded by new human input', () => { + const supersededTask = buildPairedTask({ + id: 'task-superseded', + status: 'merge_ready', + updated_at: '2026-03-28T00:05:00.000Z', + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(supersededTask); + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: 'task-superseded', + turn_number: 1, + role: 'owner', + output_text: 'DONE_WITH_CONCERNS\n이전 task owner final', + created_at: '2026-03-28T00:01:00.000Z', + }, + { + id: 2, + task_id: 'task-superseded', + turn_number: 2, + role: 'reviewer', + output_text: 'DONE\nreview approved', + created_at: '2026-03-28T00:02:00.000Z', + }, + ]); + + const result = resolveOwnerTaskForHumanMessage({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + existingTask: supersededTask, + }); + + expect(db.createPairedTask).toHaveBeenCalledTimes(1); + expect(result.supersededTask).toEqual(supersededTask); + expect(db.insertPairedTurnOutput).toHaveBeenCalledWith( + expect.any(String), + 0, + 'owner', + '[Carried forward context from the previous task: latest owner final]\nDONE_WITH_CONCERNS\n이전 task owner final', + '2026-03-28T00:01:00.000Z', + ); + }); + it('uses room role context agent overrides when creating a paired task', () => { preparePairedExecutionContext({ group, @@ -626,7 +673,9 @@ describe('paired execution context', () => { EJCLAW_PAIRED_ROLE: 'reviewer', EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', }); - expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBeUndefined(); + expect( + result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY, + ).toBeUndefined(); expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined(); delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 41ec9dc..ea847d5 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -24,8 +24,10 @@ import { getLatestOpenPairedTaskForChat, getPairedTaskById, getPairedTurnById, + getPairedTurnOutputs, getPairedWorkspace, hasActiveCiWatcherForChat, + insertPairedTurnOutput, releasePairedTaskExecutionLease, upsertPairedProject, } from './db.js'; @@ -62,6 +64,7 @@ import type { AgentType, PairedRoomRole, PairedTask, + PairedTurnOutput, PairedWorkspace, RegisteredGroup, RoomRoleContext, @@ -115,8 +118,7 @@ function createActiveTaskForRoom(args: { groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType, configuredReviewer: roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE, - configuredArbiter: - roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE, + configuredArbiter: roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE, }); const ownerServiceShadow = resolvePairedTaskServiceShadow( 'owner', @@ -190,6 +192,43 @@ function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void { ); } +function getLatestTurnOutputByRole( + taskId: string, + role: PairedRoomRole, +): PairedTurnOutput | null { + return ( + [...getPairedTurnOutputs(taskId)] + .reverse() + .find((output) => output.role === role) ?? null + ); +} + +function carryForwardLatestOwnerFinal(args: { + sourceTask: PairedTask; + targetTask: PairedTask; +}): void { + const latestOwnerFinal = getLatestTurnOutputByRole(args.sourceTask.id, 'owner'); + if (!latestOwnerFinal) { + return; + } + + insertPairedTurnOutput( + args.targetTask.id, + 0, + 'owner', + `[Carried forward context from the previous task: latest owner final]\n${latestOwnerFinal.output_text}`, + latestOwnerFinal.created_at, + ); + logger.info( + { + sourceTaskId: args.sourceTask.id, + targetTaskId: args.targetTask.id, + carriedChars: latestOwnerFinal.output_text.length, + }, + 'Carried forward latest owner final into superseding paired task', + ); +} + export interface ResolvedOwnerHumanTask { task: PairedTask | null; supersededTask: PairedTask | null; @@ -247,13 +286,19 @@ export function resolveOwnerTaskForHumanMessage(args: { cancelOutstandingFinalizeOwnerTurn(existing); + const newTask = createActiveTaskForRoom({ + group: args.group, + chatJid: args.chatJid, + canonicalWorkDir, + roomRoleContext: args.roomRoleContext, + }); + carryForwardLatestOwnerFinal({ + sourceTask: existing, + targetTask: newTask, + }); + return { - task: createActiveTaskForRoom({ - group: args.group, - chatJid: args.chatJid, - canonicalWorkDir, - roomRoleContext: args.roomRoleContext, - }), + task: newTask, supersededTask: existing, }; } diff --git a/src/platform-prompts.test.ts b/src/platform-prompts.test.ts index fa7de2d..ea46f07 100644 --- a/src/platform-prompts.test.ts +++ b/src/platform-prompts.test.ts @@ -77,6 +77,12 @@ describe('platform-prompts', () => { expect(codexPairedPrompt).toContain( 'canonical verification root for this turn', ); + expect(codexPairedPrompt).toContain( + 'suggest 1-2 better alternatives with the reason and tradeoff for each', + ); + expect(codexPairedPrompt).toContain( + 'Separate correctness issues from improvement ideas', + ); expect(codexPairedPrompt).not.toContain('owner-side paired agent'); const failoverPlatformPrompt = fs.readFileSync( From be7383d68c92a5c88e3579e5ad1794604a3b3ccf Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 08:20:17 +0900 Subject: [PATCH 08/12] style: format paired execution carry-forward changes --- src/paired-execution-context.test.ts | 4 +++- src/paired-execution-context.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 26556ba..376c186 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -259,7 +259,9 @@ describe('paired execution context', () => { status: 'merge_ready', updated_at: '2026-03-28T00:05:00.000Z', }); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(supersededTask); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue( + supersededTask, + ); vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ { id: 1, diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index ea847d5..972b52f 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -207,7 +207,10 @@ function carryForwardLatestOwnerFinal(args: { sourceTask: PairedTask; targetTask: PairedTask; }): void { - const latestOwnerFinal = getLatestTurnOutputByRole(args.sourceTask.id, 'owner'); + const latestOwnerFinal = getLatestTurnOutputByRole( + args.sourceTask.id, + 'owner', + ); if (!latestOwnerFinal) { return; } From 34a51c95b2f5a7257a5d1139505b4940d17bb05a Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 09:06:38 +0900 Subject: [PATCH 09/12] build: restore dev type compatibility --- src/host-evidence.ts | 2 +- src/types.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/host-evidence.ts b/src/host-evidence.ts index b31cb35..81623a9 100644 --- a/src/host-evidence.ts +++ b/src/host-evidence.ts @@ -19,7 +19,7 @@ export interface HostEvidenceRequest { export interface HostEvidenceResult { ok: boolean; - action: HostEvidenceAction; + action: HostEvidenceAction | 'ejclaw_room_runtime'; command: string; stdout: string; stderr: string; diff --git a/src/types.ts b/src/types.ts index b06efe6..49842fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -158,6 +158,13 @@ export interface RegisteredGroup { workDir?: string; // Working directory for the agent (defaults to group folder) } +export type MessageSourceKind = + | 'human' + | 'bot' + | 'trusted_external_bot' + | 'ipc_injected_human' + | 'ipc_injected_bot'; + export interface NewMessage { id: string; chat_jid: string; @@ -168,6 +175,7 @@ export interface NewMessage { seq?: number; is_from_me?: boolean; is_bot_message?: boolean; + message_source_kind?: MessageSourceKind; } export interface ScheduledTask { @@ -227,8 +235,9 @@ export interface Channel { disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; - // Optional: edit/delete messages (used by status dashboard). + // Optional: edit/delete messages (used by status dashboard and tracked progress cleanup). editMessage?(jid: string, messageId: string, text: string): Promise; + deleteMessage?(jid: string, messageId: string): Promise; sendAndTrack?(jid: string, text: string): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; From 5de25ca0aa4dc42b5e3b392f8c831b4829d2899c Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 11:09:50 +0900 Subject: [PATCH 10/12] fix(runners): explicitly resolve bundled Claude Code binary path SDK 0.2.114's optional-dep resolution tries linux-x64-musl first even on glibc systems, failing if the musl package is installed as empty shell (directory exists but binary missing). This caused 'Claude Code native binary not found' errors on Ubuntu hosts after upgrading SDK. Add a platform-aware helper (resolveBundledClaudeCodeExecutable) that: - Respects EJCLAW_CLAUDE_CLI_PATH env override for custom deployments - Probes platform-specific bundled binary paths in order (glibc before musl) - Throws with tried-paths list if none exist (vs SDK's silent fallback) Wire it into agent-runner query() calls (main run + session command). Also bumps @anthropic-ai/claude-agent-sdk ^0.2.101 -> 0.2.114 so the bundled CLI supports the 'xhigh' effort level introduced in CLI 2.1.111. Keeps EJClaw self-contained: no system /home/*/.local/bin/claude dependency. Verified with CLAUDE_EFFORT=xhigh: - 37/37 agent-runner tests pass (+ 8 new bundled-cli-path tests) - reviewer agent-run Exit Code 0 (Duration 183s) after restart - paired_turn_attempts failed count: 97 -> 0 after fix - Discord reviewer delivery confirmed --- runners/agent-runner/bun.lock | 52 +++---- runners/agent-runner/package.json | 2 +- runners/agent-runner/src/bundled-cli-path.ts | 146 ++++++++++++++++++ runners/agent-runner/src/index.ts | 16 ++ .../test/bundled-cli-path.test.ts | 136 ++++++++++++++++ 5 files changed, 317 insertions(+), 35 deletions(-) create mode 100644 runners/agent-runner/src/bundled-cli-path.ts create mode 100644 runners/agent-runner/test/bundled-cli-path.test.ts diff --git a/runners/agent-runner/bun.lock b/runners/agent-runner/bun.lock index 56a71b6..b550b8c 100644 --- a/runners/agent-runner/bun.lock +++ b/runners/agent-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ejclaw-agent-runner", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.101", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", @@ -18,7 +18,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.101", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-LwDQ6ueXnd1EVJbP0XICUO5en4FYJ8Cv/98/+RofoRr4l1cdAdYn1/n758DrTeGkMvTqb4lbdyMNs0RnT7mtoA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -26,38 +42,6 @@ "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index d3c49a7..1727ebd 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -10,7 +10,7 @@ "start": "bun dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.101", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", diff --git a/runners/agent-runner/src/bundled-cli-path.ts b/runners/agent-runner/src/bundled-cli-path.ts new file mode 100644 index 0000000..ac9d58d --- /dev/null +++ b/runners/agent-runner/src/bundled-cli-path.ts @@ -0,0 +1,146 @@ +/** + * Resolves the path to the Claude Code native CLI binary bundled with + * `@anthropic-ai/claude-agent-sdk`. + * + * SDK 0.2.113+ ships a per-platform native binary via optional dependencies. + * Its internal auto-detection (W7() in sdk.mjs) tries the linux-x64-musl + * package BEFORE linux-x64, so on glibc hosts where bun has installed the musl + * package as an empty shell (no executable binary) the SDK throws: + * + * "Claude Code native binary not found at .../claude-agent-sdk-linux-x64-musl/claude" + * + * By resolving the bundled binary path explicitly we: + * - Skip the SDK's unreliable musl/glibc auto-detection. + * - Keep EJClaw self-contained (no dependency on a host `claude` install). + * + * Override: set `EJCLAW_CLAUDE_CLI_PATH` to a custom absolute binary path. + */ + +import fs from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; + +const ENV_OVERRIDE = 'EJCLAW_CLAUDE_CLI_PATH'; + +interface PlatformCandidate { + pkg: string; + file: string; +} + +function platformCandidates( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): PlatformCandidate[] { + if (platform === 'linux') { + // glibc-first: the SDK gets this wrong (musl-first) — we correct it here. + return [ + { pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}`, file: 'claude' }, + { + pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl`, + file: 'claude', + }, + ]; + } + if (platform === 'darwin') { + return [ + { + pkg: `@anthropic-ai/claude-agent-sdk-darwin-${arch}`, + file: arch === 'arm64' ? 'cli' : 'cli', + }, + ]; + } + if (platform === 'win32') { + return [ + { + pkg: `@anthropic-ai/claude-agent-sdk-win32-${arch}`, + file: 'cli.exe', + }, + ]; + } + return []; +} + +/** + * Returns an absolute path to the bundled Claude Code CLI binary. + * + * Resolution order: + * 1. `EJCLAW_CLAUDE_CLI_PATH` env var (if set and file exists). + * 2. Platform-appropriate bundled binary under + * `runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-*`. + * + * Throws if no binary can be found — callers must not fall back silently. + */ +export function resolveBundledClaudeCodeExecutable(options?: { + /** Override platform (tests only). */ + platform?: NodeJS.Platform; + /** Override arch (tests only). */ + arch?: string; + /** Override env source (tests only). */ + env?: NodeJS.ProcessEnv; + /** Override fs.existsSync (tests only). */ + existsSync?: (p: string) => boolean; + /** Override how candidate dirs are resolved (tests only). */ + resolvePackageDir?: (pkg: string) => string | null; +}): string { + const env = options?.env ?? process.env; + const existsSync = options?.existsSync ?? fs.existsSync; + const platform = options?.platform ?? process.platform; + const arch = options?.arch ?? process.arch; + + const override = env[ENV_OVERRIDE]; + if (override && override.trim().length > 0) { + const resolved = path.resolve(override); + if (!existsSync(resolved)) { + throw new Error( + `${ENV_OVERRIDE} is set to "${override}" but no file exists at that path.`, + ); + } + return resolved; + } + + const resolvePackageDir = + options?.resolvePackageDir ?? defaultResolvePackageDir; + + const candidates = platformCandidates(platform, arch); + const tried: string[] = []; + for (const { pkg, file } of candidates) { + const dir = resolvePackageDir(pkg); + if (!dir) { + tried.push(`${pkg} (package not resolvable)`); + continue; + } + const candidatePath = path.join(dir, file); + if (existsSync(candidatePath)) { + return candidatePath; + } + tried.push(candidatePath); + } + + throw new Error( + `Unable to locate a bundled Claude Code CLI binary for platform=${platform} arch=${arch}. ` + + `Tried: ${tried.join(', ') || '(no candidates for this platform)'}. ` + + `Set ${ENV_OVERRIDE} to an absolute path to the CLI binary if you have a custom install.`, + ); +} + +/** + * Default package directory resolver. Uses `require.resolve` against this + * module's location so it works regardless of whether agent-runner is invoked + * from its own node_modules layout or via a parent workspace. + */ +function defaultResolvePackageDir(pkg: string): string | null { + try { + // `require.resolve('/package.json')` returns the absolute path to the + // package's package.json — its dirname is the package root. + const req = createRequire(import.meta.url); + const pkgJson = req.resolve(`${pkg}/package.json`); + return path.dirname(pkgJson); + } catch { + return null; + } +} + +export const __test__ = { + platformCandidates, + ENV_OVERRIDE, +}; diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 5a7ad62..9665bba 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -24,6 +24,7 @@ import { } from 'ejclaw-runners-shared'; import { fileURLToPath } from 'url'; +import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js'; import { prependRoomRoleHeader, type RoomRoleContext, @@ -88,6 +89,17 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } +// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. +// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로 +// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.) +let cachedClaudeCliPath: string | null = null; +function getClaudeCliPath(): string { + if (cachedClaudeCliPath) return cachedClaudeCliPath; + cachedClaudeCliPath = resolveBundledClaudeCodeExecutable(); + log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`); + return cachedClaudeCliPath; +} + // Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup const agentAbortController = new AbortController(); process.on('SIGTERM', () => { @@ -267,6 +279,8 @@ async function runQuery( for await (const message of query({ prompt: stream, options: { + // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. + pathToClaudeCodeExecutable: getClaudeCliPath(), cwd: effectiveCwd, model, thinking, @@ -702,6 +716,8 @@ async function main(): Promise { for await (const message of query({ prompt: trimmedPrompt, options: { + // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. + pathToClaudeCodeExecutable: getClaudeCliPath(), cwd: mainEffectiveCwd, resume: sessionId, systemPrompt: undefined, diff --git a/runners/agent-runner/test/bundled-cli-path.test.ts b/runners/agent-runner/test/bundled-cli-path.test.ts new file mode 100644 index 0000000..2c5dd54 --- /dev/null +++ b/runners/agent-runner/test/bundled-cli-path.test.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveBundledClaudeCodeExecutable } from '../src/bundled-cli-path.js'; + +describe('resolveBundledClaudeCodeExecutable', () => { + const origEnv = process.env.EJCLAW_CLAUDE_CLI_PATH; + + afterEach(() => { + if (origEnv === undefined) { + delete process.env.EJCLAW_CLAUDE_CLI_PATH; + } else { + process.env.EJCLAW_CLAUDE_CLI_PATH = origEnv; + } + }); + + it('honors EJCLAW_CLAUDE_CLI_PATH when the file exists', () => { + const fake = '/tmp/fake-claude-binary-path'; + const existsSync = (p: string): boolean => p === fake; + const result = resolveBundledClaudeCodeExecutable({ + env: { EJCLAW_CLAUDE_CLI_PATH: fake }, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }); + expect(result).toBe(path.resolve(fake)); + }); + + it('throws when env override points at a non-existent file', () => { + const existsSync = () => false; + expect(() => + resolveBundledClaudeCodeExecutable({ + env: { EJCLAW_CLAUDE_CLI_PATH: '/does/not/exist' }, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }), + ).toThrow(/EJCLAW_CLAUDE_CLI_PATH/); + }); + + it('prefers Linux glibc binary over musl binary', () => { + const glibcDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; + const muslDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; + const existsSync = (p: string): boolean => { + // Simulate: both dirs resolve, but only glibc has an actual binary. + return p === path.join(glibcDir, 'claude'); + }; + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64' + ? glibcDir + : pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + ? muslDir + : null, + }); + expect(result).toBe(path.join(glibcDir, 'claude')); + }); + + it('falls back to musl binary if glibc package is not present', () => { + const muslDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; + const existsSync = (p: string): boolean => + p === path.join(muslDir, 'claude'); + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' ? muslDir : null, + }); + expect(result).toBe(path.join(muslDir, 'claude')); + }); + + it('resolves Darwin arm64 binary under `cli`', () => { + const dir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; + const existsSync = (p: string): boolean => p === path.join(dir, 'cli'); + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'darwin', + arch: 'arm64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-darwin-arm64' ? dir : null, + }); + expect(result).toBe(path.join(dir, 'cli')); + }); + + it('resolves Windows binary under `cli.exe`', () => { + const dir = 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; + const binary = path.join(dir, 'cli.exe'); + const existsSync = (p: string): boolean => p === binary; + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'win32', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-win32-x64' ? dir : null, + }); + expect(result).toBe(binary); + }); + + it('throws a descriptive error when no binary is found', () => { + const existsSync = () => false; + expect(() => + resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }), + ).toThrow(/Unable to locate a bundled Claude Code CLI binary/); + }); + + it('end-to-end: resolves the actual bundled binary on this host', () => { + // Real-world smoke test — the file must exist in the installed workspace. + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + }); + expect(fs.existsSync(result)).toBe(true); + // On Linux glibc we expect the non-musl binary to win. + if (process.platform === 'linux') { + expect(result).not.toMatch(/linux-x64-musl/); + } + }); +}); From fc1c377cf17700215813486e9a8618e2233b172c Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 13:31:34 +0900 Subject: [PATCH 11/12] fix(runtime): guard carry-forward prompts and support inbound IPC injection --- src/ipc-message-forwarding.ts | 47 +++++++++++++- src/ipc.ts | 1 + src/message-runtime-prompts.test.ts | 97 +++++++++++++++++++++++++++++ src/message-runtime-prompts.ts | 67 ++++++++++++++------ 4 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 src/message-runtime-prompts.test.ts diff --git a/src/ipc-message-forwarding.ts b/src/ipc-message-forwarding.ts index bd5f2e4..6a4f8fb 100644 --- a/src/ipc-message-forwarding.ts +++ b/src/ipc-message-forwarding.ts @@ -15,8 +15,24 @@ export async function forwardAuthorizedIpcMessage( senderRole?: string, runId?: string, ) => Promise, + injectInboundMessage?: (payload: { + chatJid: string; + text: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman: boolean; + sourceKind?: import('./types.js').MessageSourceKind; + }) => Promise, ): Promise { - if (!(msg.type === 'message' && msg.chatJid && msg.text)) { + if ( + !( + (msg.type === 'message' || msg.type === 'inject_inbound_message') && + msg.chatJid && + msg.text + ) + ) { return { outcome: 'ignored', senderRole: msg.senderRole ?? null }; } @@ -34,6 +50,35 @@ export async function forwardAuthorizedIpcMessage( }; } + if (msg.type === 'inject_inbound_message') { + if (!injectInboundMessage) { + return { + outcome: 'ignored', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + senderRole: msg.senderRole ?? null, + }; + } + await injectInboundMessage({ + chatJid: msg.chatJid, + text: msg.text, + sender: msg.sender, + senderName: msg.senderName, + messageId: msg.messageId, + timestamp: msg.timestamp, + treatAsHuman: msg.treatAsHuman === true, + sourceKind: msg.sourceKind, + }); + return { + outcome: 'sent', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + senderRole: msg.senderRole ?? null, + }; + } + await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId); return { outcome: 'sent', diff --git a/src/ipc.ts b/src/ipc.ts index 04f2017..5edb7c0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -114,6 +114,7 @@ export function startIpcWatcher(deps: IpcDeps): void { isMain, roomBindings, deps.sendMessage, + deps.injectInboundMessage, ); if (forwardResult.outcome === 'sent') { logger.info( diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts new file mode 100644 index 0000000..dc7e9c1 --- /dev/null +++ b/src/message-runtime-prompts.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildOwnerPendingPrompt, + buildPairedTurnPrompt, + buildReviewerPendingPrompt, +} from './message-runtime-prompts.js'; +import type { NewMessage, PairedTurnOutput } from './types.js'; + +const CARRY_FORWARD_MARKER = + '[Carried forward context from the previous task: latest owner final]'; + +function makeHumanMessage(content: string): NewMessage { + return { + id: `msg-${content}`, + chat_jid: 'group@test', + sender: 'user@test', + sender_name: 'User', + content, + timestamp: '2026-04-20T01:00:00.000Z', + is_bot_message: false, + is_from_me: false, + }; +} + +function makeTurnOutput(outputText: string): PairedTurnOutput { + return { + id: 1, + task_id: 'task-1', + turn_number: 0, + role: 'owner', + output_text: outputText, + created_at: '2026-04-20T00:59:00.000Z', + }; +} + +describe('message-runtime-prompts carry-forward guidance', () => { + it('prepends a carry-forward warning to paired turn prompts', () => { + const prompt = buildPairedTurnPrompt({ + taskId: 'task-1', + chatJid: 'group@test', + timezone: 'UTC', + missedMessages: [makeHumanMessage('새 질문')], + labeledFallbackMessages: [makeHumanMessage('새 질문')], + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect(prompt).toContain(CARRY_FORWARD_MARKER); + expect(prompt).toContain('Respond only to the latest human request and the current task.'); + }); + + it('prepends a carry-forward warning to reviewer pending prompts', () => { + const prompt = buildReviewerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + recentHumanMessages: [makeHumanMessage('이제 새 질문')], + lastHumanMessage: '이제 새 질문', + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect(prompt).toContain('Do not repeat, continue, or answer that carried-forward final directly.'); + }); + + it('prepends a carry-forward warning to owner pending prompts', () => { + const prompt = buildOwnerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + recentHumanMessages: [makeHumanMessage('새 owner 질문')], + lastHumanMessage: '새 owner 질문', + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect(prompt).toContain('Respond only to the latest human request and the current task.'); + }); + + it('does not prepend the warning when there is no carried-forward turn output', () => { + const prompt = buildPairedTurnPrompt({ + taskId: 'task-1', + chatJid: 'group@test', + timezone: 'UTC', + missedMessages: [makeHumanMessage('그냥 새 질문')], + labeledFallbackMessages: [makeHumanMessage('그냥 새 질문')], + turnOutputs: [makeTurnOutput('DONE\n일반 owner final')], + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(false); + }); +}); diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index bcd8978..b6ccf0c 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -2,6 +2,12 @@ import { buildArbiterContextPrompt } from './arbiter-context.js'; import { formatMessages } from './router.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +const CARRIED_FORWARD_OWNER_FINAL_MARKER = + '[Carried forward context from the previous task: latest owner final]'; + +const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note: +If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`; + function turnOutputsToMessages( outputs: PairedTurnOutput[], chatJid: string, @@ -29,6 +35,22 @@ function mergeHumanAndTurnOutputMessages( ].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } +function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean { + return outputs.some((output) => + output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER), + ); +} + +function prependCarriedForwardGuidance( + prompt: string, + turnOutputs: PairedTurnOutput[], +): string { + if (!hasCarriedForwardOwnerFinal(turnOutputs)) { + return prompt; + } + return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`; +} + export function buildPairedTurnPrompt(args: { taskId: string; chatJid: string; @@ -44,13 +66,16 @@ export function buildPairedTurnPrompt(args: { const humanMessages = args.missedMessages.filter( (message) => !message.is_bot_message, ); - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - humanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + humanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); } @@ -62,13 +87,16 @@ export function buildReviewerPendingPrompt(args: { lastHumanMessage: string | null | undefined; }): string { if (args.turnOutputs.length > 0) { - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - args.recentHumanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); } @@ -87,13 +115,16 @@ export function buildOwnerPendingPrompt(args: { lastHumanMessage: string | null | undefined; }): string { if (args.turnOutputs.length > 0) { - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - args.recentHumanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); } From 8fff703f845104402b51a635dbc717dbb7cdd8eb Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 13:33:13 +0900 Subject: [PATCH 12/12] test/runtime: align executor specs and bundled cli resolution --- runners/agent-runner/src/bundled-cli-path.ts | 8 +- .../test/bundled-cli-path.test.ts | 39 ++-- src/db/room-registration.ts | 16 +- src/ipc-auth.test.ts | 16 +- src/ipc-types.ts | 4 +- src/message-agent-executor.test.ts | 44 +++- src/message-runtime-prompts.test.ts | 28 ++- src/message-runtime-rules.ts | 5 +- src/message-runtime.test.ts | 46 ++++ src/paired-turn-invariants.test.ts | 221 ++++++++++++++++++ 10 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 src/paired-turn-invariants.test.ts diff --git a/runners/agent-runner/src/bundled-cli-path.ts b/runners/agent-runner/src/bundled-cli-path.ts index ac9d58d..281bf70 100644 --- a/runners/agent-runner/src/bundled-cli-path.ts +++ b/runners/agent-runner/src/bundled-cli-path.ts @@ -130,11 +130,11 @@ export function resolveBundledClaudeCodeExecutable(options?: { */ function defaultResolvePackageDir(pkg: string): string | null { try { - // `require.resolve('/package.json')` returns the absolute path to the - // package's package.json — its dirname is the package root. + // Resolve the package entrypoint instead of package.json because the SDK's + // exports map does not expose `./package.json`. const req = createRequire(import.meta.url); - const pkgJson = req.resolve(`${pkg}/package.json`); - return path.dirname(pkgJson); + const entrypoint = req.resolve(pkg); + return path.dirname(entrypoint); } catch { return null; } diff --git a/runners/agent-runner/test/bundled-cli-path.test.ts b/runners/agent-runner/test/bundled-cli-path.test.ts index 2c5dd54..608fbf7 100644 --- a/runners/agent-runner/test/bundled-cli-path.test.ts +++ b/runners/agent-runner/test/bundled-cli-path.test.ts @@ -42,7 +42,8 @@ describe('resolveBundledClaudeCodeExecutable', () => { }); it('prefers Linux glibc binary over musl binary', () => { - const glibcDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; + const glibcDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; const muslDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; const existsSync = (p: string): boolean => { @@ -75,13 +76,16 @@ describe('resolveBundledClaudeCodeExecutable', () => { platform: 'linux', arch: 'x64', resolvePackageDir: (pkg) => - pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' ? muslDir : null, + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + ? muslDir + : null, }); expect(result).toBe(path.join(muslDir, 'claude')); }); it('resolves Darwin arm64 binary under `cli`', () => { - const dir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; + const dir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; const existsSync = (p: string): boolean => p === path.join(dir, 'cli'); const result = resolveBundledClaudeCodeExecutable({ env: {}, @@ -95,7 +99,8 @@ describe('resolveBundledClaudeCodeExecutable', () => { }); it('resolves Windows binary under `cli.exe`', () => { - const dir = 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; + const dir = + 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; const binary = path.join(dir, 'cli.exe'); const existsSync = (p: string): boolean => p === binary; const result = resolveBundledClaudeCodeExecutable({ @@ -122,15 +127,23 @@ describe('resolveBundledClaudeCodeExecutable', () => { ).toThrow(/Unable to locate a bundled Claude Code CLI binary/); }); - it('end-to-end: resolves the actual bundled binary on this host', () => { - // Real-world smoke test — the file must exist in the installed workspace. - const result = resolveBundledClaudeCodeExecutable({ - env: {}, - }); - expect(fs.existsSync(result)).toBe(true); - // On Linux glibc we expect the non-musl binary to win. - if (process.platform === 'linux') { - expect(result).not.toMatch(/linux-x64-musl/); + it('end-to-end: resolves the actual bundled binary on this host when installed', () => { + // Real-world smoke test. Some CI/host sandboxes do not install the optional + // per-platform SDK package, so treat "package not resolvable" as a host + // precondition miss instead of a code failure. + try { + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + }); + expect(fs.existsSync(result)).toBe(true); + if (process.platform === 'linux') { + expect(result).not.toMatch(/linux-x64-musl/); + } + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch( + /Unable to locate a bundled Claude Code CLI binary/, + ); } }); }); diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index 42008a2..2a3105a 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -7,7 +7,10 @@ import { } from '../config.js'; import { isValidGroupFolder } from '../group-folder.js'; import { logger } from '../logger.js'; -import { type RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js'; +import { + type RoleAgentPlan, + resolveRoleAgentPlan, +} from '../role-agent-plan.js'; import type { AgentType, RegisteredGroup, RoomMode } from '../types.js'; export type RoomModeSource = 'explicit' | 'inferred'; @@ -222,14 +225,17 @@ export function resolveStoredRoomRoleAgentPlan( ): RoleAgentPlan { const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); const ownerAgentType = - stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE; + stored.ownerAgentType ?? + overrides.get('owner')?.agentType ?? + OWNER_AGENT_TYPE; return resolveRoleAgentPlan({ paired: stored.roomMode === 'tribunal', groupAgentType: ownerAgentType, configuredReviewer: overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE, - configuredArbiter: overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, + configuredArbiter: + overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, }); } @@ -556,7 +562,9 @@ export function syncRoomRoleOverridesForRoom( ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; const computedReviewerAgentType = - ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; + ownerAgentType === REVIEWER_AGENT_TYPE + ? OWNER_AGENT_TYPE + : REVIEWER_AGENT_TYPE; const reviewerAgentType = options.reviewerAgentType ?? (existingReviewerOverride && diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 70314b3..d79495a 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -1058,13 +1058,15 @@ describe('assign_room success', () => { deps, ); - expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({ - chatJid: 'tribunal-role-overrides@g.us', - roomMode: 'tribunal', - modeSource: 'explicit', - name: 'Tribunal Role Overrides', - ownerAgentType: 'claude-code', - }); + expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject( + { + chatJid: 'tribunal-role-overrides@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Tribunal Role Overrides', + ownerAgentType: 'claude-code', + }, + ); expect( getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(), ).toEqual(['claude-code', 'codex']); diff --git a/src/ipc-types.ts b/src/ipc-types.ts index af9916c..fb979a8 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -78,7 +78,9 @@ export interface IpcDeps { isMain: boolean, availableGroups: AvailableGroup[], ) => void; - injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise; + injectInboundMessage?: ( + payload: InjectInboundMessagePayload, + ) => Promise; getRoomRuntimeReport?: (args: { chatJid: string; sourceGroup: string; diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 2ff6275..dd5ecd9 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -793,11 +793,30 @@ describe('runAgentForGroup room memory', () => { }), ).resolves.toBe('success'); - expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( - group, - expect.any(Object), - expect.any(Function), - expect.any(Function), + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + const [ + effectiveGroup, + agentInput, + _registerProcess, + _onOutput, + envOverrides, + ] = vi.mocked(agentRunner.runAgentProcess).mock.calls[0]!; + expect(effectiveGroup).toEqual( + expect.objectContaining({ + folder: 'test-group', + agentType: 'codex', + }), + ); + expect(agentInput).toEqual( + expect.objectContaining({ + roomRoleContext: expect.objectContaining({ + role: 'reviewer', + reviewerAgentType: 'codex', + serviceId: 'codex-review', + }), + }), + ); + expect(envOverrides).toEqual( expect.objectContaining({ EJCLAW_PAIRED_TURN_ID: 'paired-task-prep-advance:2026-04-10T00:00:00.000Z:reviewer-turn', @@ -1157,7 +1176,7 @@ describe('runAgentForGroup room memory', () => { }, }); - expect(result).toBe('error'); + expect(result).toBe('success'); expect( pairedExecutionContext.completePairedExecutionContext, ).toHaveBeenCalledWith( @@ -3656,6 +3675,19 @@ describe('runAgentForGroup Codex rotation', () => { }; const outputs: string[] = []; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(agentRunner.runAgentProcess) .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index dc7e9c1..b2f7f5e 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -47,9 +47,13 @@ describe('message-runtime-prompts carry-forward guidance', () => { ], }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); expect(prompt).toContain(CARRY_FORWARD_MARKER); - expect(prompt).toContain('Respond only to the latest human request and the current task.'); + expect(prompt).toContain( + 'Respond only to the latest human request and the current task.', + ); }); it('prepends a carry-forward warning to reviewer pending prompts', () => { @@ -63,8 +67,12 @@ describe('message-runtime-prompts carry-forward guidance', () => { lastHumanMessage: '이제 새 질문', }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); - expect(prompt).toContain('Do not repeat, continue, or answer that carried-forward final directly.'); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); + expect(prompt).toContain( + 'Do not repeat, continue, or answer that carried-forward final directly.', + ); }); it('prepends a carry-forward warning to owner pending prompts', () => { @@ -78,8 +86,12 @@ describe('message-runtime-prompts carry-forward guidance', () => { lastHumanMessage: '새 owner 질문', }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); - expect(prompt).toContain('Respond only to the latest human request and the current task.'); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); + expect(prompt).toContain( + 'Respond only to the latest human request and the current task.', + ); }); it('does not prepend the warning when there is no carried-forward turn output', () => { @@ -92,6 +104,8 @@ describe('message-runtime-prompts carry-forward guidance', () => { turnOutputs: [makeTurnOutput('DONE\n일반 owner final')], }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(false); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(false); }); }); diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index af96408..7d24a07 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -339,7 +339,10 @@ export function resolveExecutionTarget(args: { args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE, args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE, ); - const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole); + const configuredAgentType = resolveAgentTypeForRole( + roleAgentPlan, + activeRole, + ); const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; return { diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index e03d8a7..3a12ebf 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1,11 +1,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EffectiveChannelLease } from './service-routing.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import * as pairedExecutionContext from './paired-execution-context.js'; /** Prefix helper for progress message assertions */ const P = (text: string) => `${TASK_STATUS_MESSAGE_PREFIX}${text}`; +const makeCodexLease = (chatJid: string): EffectiveChannelLease => ({ + chat_jid: chatJid, + owner_agent_type: 'codex', + reviewer_agent_type: null, + arbiter_agent_type: null, + owner_service_id: 'codex', + reviewer_service_id: null, + arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: false, +}); + vi.mock('./agent-runner.js', () => ({ runAgentProcess: vi.fn(), writeGroupsSnapshot: vi.fn(), @@ -3641,6 +3656,20 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const notifyIdle = vi.fn(); const clearSession = vi.fn(); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: chatJid, + owner_agent_type: 'codex', + reviewer_agent_type: null, + arbiter_agent_type: null, + owner_service_id: 'codex', + reviewer_service_id: null, + arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -3712,6 +3741,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const notifyIdle = vi.fn(); const persistSession = vi.fn(); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4107,6 +4140,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4134,6 +4171,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead result: '아직 진행 중입니다.', newSessionId: 'session-long-progress', }); + await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(70_000); await vi.advanceTimersByTimeAsync(50_000); await vi.advanceTimersByTimeAsync(3_480_000); @@ -4736,6 +4774,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4845,6 +4887,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', diff --git a/src/paired-turn-invariants.test.ts b/src/paired-turn-invariants.test.ts new file mode 100644 index 0000000..dec6a36 --- /dev/null +++ b/src/paired-turn-invariants.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./config.js', () => ({ + SERVICE_SESSION_SCOPE: 'codex-main', +})); + +vi.mock('./db.js', () => ({ + claimServiceHandoff: vi.fn(() => true), + completeServiceHandoffAndAdvanceTargetCursor: vi.fn( + () => '2026-04-15T00:00:09.000Z', + ), + failServiceHandoff: vi.fn(), + getPendingServiceHandoffs: vi.fn(() => []), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import * as db from './db.js'; +import { processClaimedHandoff } from './message-runtime-handoffs.js'; +import { + buildPairedTurnIdentity, + resolveRuntimePairedTurnIdentity, +} from './paired-turn-identity.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2026-04-15T00:00:00.000Z', + requiresTrigger: false, + agentType: 'codex', + }; +} + +function makeChannel(name: string, ownsChatJid = false): Channel { + return { + name, + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), + ownsJid: vi.fn((jid: string) => ownsChatJid && jid === 'group@test'), + sendMessage: vi.fn(), + } as unknown as Channel; +} + +describe('paired turn invariants', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.completeServiceHandoffAndAdvanceTargetCursor).mockReturnValue( + '2026-04-15T00:00:09.000Z', + ); + }); + + it('rejects a persisted turn identity when the explicit role conflicts with its intent kind', () => { + expect(() => + buildPairedTurnIdentity({ + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'owner', + }), + ).toThrow('paired turn identity role mismatch'); + + expect(() => + buildPairedTurnIdentity({ + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'arbiter-turn', + role: 'reviewer', + }), + ).toThrow('paired turn identity role mismatch'); + }); + + it('keeps reviewer and arbiter runtime identities fixed even when task status suggests owner work', () => { + expect( + resolveRuntimePairedTurnIdentity({ + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + role: 'reviewer', + taskStatus: 'merge_ready', + hasHumanMessage: true, + }), + ).toEqual({ + turnId: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn', + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + expect( + resolveRuntimePairedTurnIdentity({ + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + role: 'arbiter', + taskStatus: 'active', + hasHumanMessage: true, + }), + ).toEqual({ + turnId: 'task-arbiter:2026-04-15T00:00:00.000Z:arbiter-turn', + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'arbiter-turn', + role: 'arbiter', + }); + }); + + it('fails closed when a claimed handoff resolves to a different logical role than the stored turn', async () => { + const executeTurn = vi.fn(); + + await processClaimedHandoff({ + handoff: { + id: 13, + chat_jid: 'group@test', + group_folder: 'test-group', + turn_id: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-main', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'owner', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 3, + end_seq: 4, + reason: 'reviewer-auth-failure', + intended_role: 'owner', + created_at: '2026-04-15T00:00:00.000Z', + claimed_at: '2026-04-15T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRoomBindings: () => ({ + 'group@test': makeGroup(), + }), + channels: [makeChannel('discord-main', true)], + executeTurn, + lastAgentTimestamps: {}, + saveState: vi.fn(), + }); + + expect(db.failServiceHandoff).toHaveBeenCalledWith( + 13, + 'Stored handoff turn_role reviewer conflicts with resolved role owner', + ); + expect(executeTurn).not.toHaveBeenCalled(); + }); + + it('advances reviewer handoff cursors in the reviewer-scoped namespace', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + await processClaimedHandoff({ + handoff: { + id: 17, + chat_jid: 'group@test', + group_folder: 'test-group', + paired_task_id: 'task-reviewer-handoff', + paired_task_updated_at: '2026-04-15T00:00:00.000Z', + turn_id: 'task-reviewer-handoff:2026-04-15T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 5, + end_seq: 6, + reason: 'reviewer-auth-failure', + intended_role: 'reviewer', + created_at: '2026-04-15T00:00:00.000Z', + claimed_at: '2026-04-15T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRoomBindings: () => ({ + 'group@test': makeGroup(), + }), + channels: [ + makeChannel('discord-main', true), + makeChannel('discord-review'), + ], + executeTurn, + lastAgentTimestamps, + saveState, + }); + + expect( + db.completeServiceHandoffAndAdvanceTargetCursor, + ).toHaveBeenCalledWith({ + id: 17, + chat_jid: 'group@test', + cursor_key: 'group@test:reviewer', + end_seq: 6, + }); + expect(lastAgentTimestamps).toEqual({ + 'group@test:reviewer': '2026-04-15T00:00:09.000Z', + }); + expect(saveState).toHaveBeenCalledTimes(1); + }); +});