diff --git a/src/channels/discord.ts b/src/channels/discord.ts index dfbb900..c7761d3 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -726,12 +726,18 @@ registerChannel('discord', (opts: ChannelOpts) => { ); }); -// Only register the secondary Codex bot channel when running as the primary (claude-code) service. -// The codex service uses its own DISCORD_BOT_TOKEN via systemd EnvironmentFile override. -if ((process.env.ASSISTANT_NAME || 'claude') !== 'codex') { - registerChannel('discord-codex', (opts: ChannelOpts) => { - const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || ''; - if (!token) return null; // Codex Discord bot is optional - return new DiscordChannel(token, opts, 'codex'); - }); -} +// Register the secondary Codex bot channel. +// In unified mode all bots register unconditionally; in legacy per-service mode +// the codex service uses its own DISCORD_BOT_TOKEN via systemd EnvironmentFile override. +registerChannel('discord-codex', (opts: ChannelOpts) => { + const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || ''; + if (!token) return null; // Codex Discord bot is optional + return new DiscordChannel(token, opts, 'codex'); +}); + +// Register the review bot channel (codex agent type, separate token). +registerChannel('discord-review', (opts: ChannelOpts) => { + const token = getEnv('DISCORD_REVIEW_BOT_TOKEN') || ''; + if (!token) return null; // Review Discord bot is optional + return new DiscordChannel(token, opts, 'codex'); +}); diff --git a/src/config.ts b/src/config.ts index af62a85..1666ab7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,9 @@ export const SERVICE_AGENT_TYPE: AgentType = ? 'codex' : 'claude-code'; +/** Unified mode: single process manages all bots. Disable with UNIFIED_MODE=0. */ +export const UNIFIED_MODE = getEnv('UNIFIED_MODE') !== '0'; + export function normalizeServiceId(serviceId: string): string { if (serviceId === 'codex') { return CODEX_MAIN_SERVICE_ID; diff --git a/src/container-runner.ts b/src/container-runner.ts index 9c44989..810ae87 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -215,11 +215,7 @@ export async function runReviewerContainer(args: { const containerName = `ejclaw-reviewer-${group.folder}-${Date.now()}`; const mounts = buildReviewerMounts(group, ownerWorkspaceDir); - const containerArgs = buildContainerArgs( - mounts, - containerName, - envOverrides, - ); + const containerArgs = buildContainerArgs(mounts, containerName, envOverrides); logger.info( { @@ -275,9 +271,7 @@ export async function runReviewerContainer(args: { // Parse streamed output markers let startIdx: number; - while ( - (startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1 - ) { + while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); if (endIdx === -1) break; diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts index 3cf84af..3d87052 100644 --- a/src/credential-proxy.ts +++ b/src/credential-proxy.ts @@ -52,14 +52,12 @@ export function startCredentialProxy( } // Default: Anthropic/Claude API - const headers: Record< - string, - string | number | string[] | undefined - > = { - ...(req.headers as Record), - host: anthropicUrl.host, - 'content-length': body.length, - }; + const headers: Record = + { + ...(req.headers as Record), + host: anthropicUrl.host, + 'content-length': body.length, + }; delete headers['connection']; delete headers['keep-alive']; diff --git a/src/db.test.ts b/src/db.test.ts index dee379d..1d1924e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -607,13 +607,6 @@ describe('paired task state', () => { updated_at: '2026-03-28T00:10:00.000Z', }); - expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe( - 'implementation_start', - ); - expect(getPairedTaskById('paired-task-2')?.reviewer_verdict).toBe( - 'done_with_concerns', - ); - upsertPairedWorkspace({ id: 'paired-task-2:reviewer', task_id: 'paired-task-2', diff --git a/src/db.ts b/src/db.ts index 46fbfa6..e592b12 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1752,7 +1752,7 @@ export function getAllRegisteredGroups( ); continue; } - result[row.jid] = { + const group: RegisteredGroup = { name: row.name, folder: row.folder, trigger: row.trigger_pattern, @@ -1764,6 +1764,17 @@ export function getAllRegisteredGroups( agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, workDir: row.work_dir || undefined, }; + // In unified mode (no agentTypeFilter), when the same JID has both + // claude-code and codex rows, prefer the codex entry (owner). + const existing = result[row.jid]; + if (existing && !agentTypeFilter) { + if (group.agentType === 'codex') { + result[row.jid] = group; + } + // else keep existing (already codex or first-seen) + } else { + result[row.jid] = group; + } } return result; } @@ -1836,11 +1847,6 @@ export function createPairedTask(task: PairedTask): void { source_ref, plan_notes, review_requested_at, - last_finalized_checkpoint, - gate_turn_kind, - reviewer_verdict, - reviewer_verdict_at, - reviewer_verdict_note, status, created_at, updated_at @@ -1857,11 +1863,6 @@ export function createPairedTask(task: PairedTask): void { task.source_ref, task.plan_notes, task.review_requested_at, - task.last_finalized_checkpoint ?? null, - task.gate_turn_kind ?? null, - task.reviewer_verdict ?? null, - task.reviewer_verdict_at ?? null, - task.reviewer_verdict_note ?? null, task.status, task.created_at, task.updated_at, @@ -1916,11 +1917,6 @@ export function updatePairedTask( | 'source_ref' | 'plan_notes' | 'review_requested_at' - | 'last_finalized_checkpoint' - | 'gate_turn_kind' - | 'reviewer_verdict' - | 'reviewer_verdict_at' - | 'reviewer_verdict_note' | 'status' | 'updated_at' > @@ -1945,26 +1941,6 @@ export function updatePairedTask( fields.push('review_requested_at = ?'); values.push(updates.review_requested_at); } - if (updates.last_finalized_checkpoint !== undefined) { - fields.push('last_finalized_checkpoint = ?'); - values.push(updates.last_finalized_checkpoint); - } - if (updates.gate_turn_kind !== undefined) { - fields.push('gate_turn_kind = ?'); - values.push(updates.gate_turn_kind); - } - if (updates.reviewer_verdict !== undefined) { - fields.push('reviewer_verdict = ?'); - values.push(updates.reviewer_verdict); - } - if (updates.reviewer_verdict_at !== undefined) { - fields.push('reviewer_verdict_at = ?'); - values.push(updates.reviewer_verdict_at); - } - if (updates.reviewer_verdict_note !== undefined) { - fields.push('reviewer_verdict_note = ?'); - values.push(updates.reviewer_verdict_note); - } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); diff --git a/src/index.ts b/src/index.ts index d678029..558c47d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,13 @@ import path from 'path'; import { ASSISTANT_NAME, + CREDENTIAL_PROXY_PORT, DATA_DIR, IDLE_TIMEOUT, POLL_INTERVAL, SERVICE_ID, SERVICE_AGENT_TYPE, + UNIFIED_MODE, isClaudeService, isReviewService, isSessionCommandSenderAllowed, @@ -85,6 +87,8 @@ import { restoreDefaultChannelLease, } from './service-routing.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js'; +import { startCredentialProxy } from './credential-proxy.js'; +import { cleanupOrphans } from './container-runtime.js'; // Token rotation is initialized lazily on first use or at startup below @@ -184,13 +188,16 @@ function loadState(): void { lastAgentTimestamp = {}; } sessions = getAllSessions(); - // Load only this service's registrations. The DB can hold both - // claude-code and codex rows for the same Discord JID. - registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE); + // In unified mode, load ALL groups (duplicates resolved by preferring codex). + // In legacy per-service mode, load only this service's registrations. + registeredGroups = UNIFIED_MODE + ? getAllRegisteredGroups() + : getAllRegisteredGroups(SERVICE_AGENT_TYPE); logger.info( { groupCount: Object.keys(registeredGroups).length, agentType: SERVICE_AGENT_TYPE, + unifiedMode: UNIFIED_MODE, }, 'State loaded', ); @@ -316,9 +323,20 @@ async function main(): Promise { initTokenRotation(); initCodexTokenRotation(); - // Only the Claude service owns Claude OAuth refresh to avoid - // cross-service refresh-token races on shared credentials. - if (shouldStartTokenRefreshLoop(SERVICE_AGENT_TYPE)) { + // Unified mode: start credential proxy for container isolation and clean up orphaned containers + if (UNIFIED_MODE) { + startCredentialProxy(CREDENTIAL_PROXY_PORT).catch((err) => + logger.warn( + { err }, + 'Failed to start credential proxy (may already be running)', + ), + ); + cleanupOrphans(); + } + + // In unified mode, always start the Claude OAuth refresh loop. + // In per-service mode, only the Claude service owns refresh to avoid races. + if (UNIFIED_MODE || shouldStartTokenRefreshLoop(SERVICE_AGENT_TYPE)) { startTokenRefreshLoop(); } else { logger.info( @@ -430,7 +448,8 @@ async function main(): Promise { } // Start subsystems (independently of connection handler) - if (!isReviewService()) { + // In unified mode always run the scheduler. In per-service mode, skip for review-only service. + if (UNIFIED_MODE || !isReviewService()) { startSchedulerLoop({ registeredGroups: () => registeredGroups, getSessions: () => sessions, @@ -511,7 +530,7 @@ async function main(): Promise { purgeOnStart: true, }); - if (isClaudeService()) { + if (UNIFIED_MODE || isClaudeService()) { leaseRecoveryTimer = setInterval(() => { if (!hasAvailableClaudeToken()) { return; diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 81844f1..da7ec38 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -304,7 +304,7 @@ describe('runAgentForGroup room memory', () => { ); }); - it('requires a visible structured verdict in reviewer gate turns', async () => { + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', @@ -325,112 +325,21 @@ describe('runAgentForGroup room memory', () => { reviewer_service_id: 'claude', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, + plan_notes: null, status: 'active', created_at: '2026-03-29T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z', }, - execution: { - id: 'run-gate:claude', - task_id: 'paired-task-gate', - service_id: 'claude', - role: 'reviewer', - workspace_id: null, - status: 'running', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: null, - }, workspace: null, envOverrides: {}, - gateTurnKind: 'implementation_start', - requiresVisibleVerdict: true, - }); - - await runAgentForGroup(makeDeps(), { - group, - prompt: 'hello', - chatJid: 'group@test', - runId: 'run-review-gate-prompt', - }); - - expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( - group, - expect.objectContaining({ - prompt: expect.stringContaining( - 'This turn is a paired-room gate turn for implementation_start. Silent output is forbidden.', - ), - }), - expect.any(Function), - undefined, - {}, - ); - }); - - it('blocks silent reviewer output on gate turns and records a silent verdict', async () => { - const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; - vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ - chat_jid: 'group@test', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - activated_at: null, - reason: null, - explicit: false, - }); - vi.mocked( - pairedExecutionContext.preparePairedExecutionContext, - ).mockReturnValue({ - task: { - id: 'paired-task-gate', - chat_jid: 'group@test', - group_folder: 'test-group', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - execution: { - id: 'run-gate:claude', - task_id: 'paired-task-gate', - service_id: 'claude', - role: 'reviewer', - workspace_id: null, - status: 'running', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: null, - }, - workspace: null, - envOverrides: {}, - gateTurnKind: 'implementation_start', - requiresVisibleVerdict: true, }); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', result: null, - output: { visibility: 'silent', verdict: 'silent' }, + output: { visibility: 'silent' }, phase: 'final', }); return { @@ -445,190 +354,7 @@ describe('runAgentForGroup room memory', () => { group, prompt: 'hello', chatJid: 'group@test', - runId: 'run-review-gate-silent', - onOutput: async (output) => { - outputs.push(output); - }, - }); - - expect(result).toBe('error'); - expect(outputs.at(-1)?.output).toEqual({ - visibility: 'public', - text: expect.stringContaining( - 'Reviewer gate turn requires a visible structured verdict.', - ), - }); - expect( - pairedExecutionContext.completePairedExecutionContext, - ).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: 'run-gate:claude', - status: 'failed', - reviewerVerdict: 'silent', - }), - ); - }); - - it('allows structured reviewer verdicts on gate turns', async () => { - const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; - vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ - chat_jid: 'group@test', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - activated_at: null, - reason: null, - explicit: false, - }); - vi.mocked( - pairedExecutionContext.preparePairedExecutionContext, - ).mockReturnValue({ - task: { - id: 'paired-task-gate', - chat_jid: 'group@test', - group_folder: 'test-group', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - execution: { - id: 'run-gate:claude', - task_id: 'paired-task-gate', - service_id: 'claude', - role: 'reviewer', - workspace_id: null, - status: 'running', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: null, - }, - workspace: null, - envOverrides: {}, - gateTurnKind: 'implementation_start', - requiresVisibleVerdict: true, - }); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, _input, _onProcess, onOutput) => { - await onOutput?.({ - status: 'success', - result: null, - output: { - visibility: 'public', - verdict: 'done_with_concerns', - text: '**DONE_WITH_CONCERNS**\n문제는 없지만 확인 포인트는 있습니다.', - }, - phase: 'final', - }); - return { - status: 'success', - result: null, - }; - }, - ); - - const result = await runAgentForGroup(makeDeps(), { - group, - prompt: 'hello', - chatJid: 'group@test', - runId: 'run-review-gate-done', - onOutput: async () => {}, - }); - - expect(result).toBe('success'); - expect( - pairedExecutionContext.completePairedExecutionContext, - ).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: 'run-gate:claude', - status: 'succeeded', - reviewerVerdict: 'done_with_concerns', - }), - ); - }); - - it('still allows silent reviewer outputs on non-gate turns', async () => { - const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; - vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ - chat_jid: 'group@test', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - activated_at: null, - reason: null, - explicit: false, - }); - vi.mocked( - pairedExecutionContext.preparePairedExecutionContext, - ).mockReturnValue({ - task: { - id: 'paired-task-gate', - chat_jid: 'group@test', - group_folder: 'test-group', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: null, - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - execution: { - id: 'run-gate:claude', - task_id: 'paired-task-gate', - service_id: 'claude', - role: 'reviewer', - workspace_id: null, - status: 'running', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: null, - }, - workspace: null, - envOverrides: {}, - gateTurnKind: null, - requiresVisibleVerdict: false, - }); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, _input, _onProcess, onOutput) => { - await onOutput?.({ - status: 'success', - result: null, - output: { visibility: 'silent', verdict: 'silent' }, - phase: 'final', - }); - return { - status: 'success', - result: null, - }; - }, - ); - const outputs: AgentOutput[] = []; - - const result = await runAgentForGroup(makeDeps(), { - group, - prompt: 'hello', - chatJid: 'group@test', - runId: 'run-review-non-gate-silent', + runId: 'run-review-silent', onOutput: async (output) => { outputs.push(output); }, @@ -637,7 +363,7 @@ describe('runAgentForGroup room memory', () => { expect(result).toBe('success'); expect(outputs).toEqual([ expect.objectContaining({ - output: { visibility: 'silent', verdict: 'silent' }, + output: { visibility: 'silent' }, }), ]); }); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index ea71323..9716316 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -51,7 +51,7 @@ import { } from './codex-token-rotation.js'; import type { CodexRotationReason } from './agent-error-detection.js'; import { getTokenCount } from './token-rotation.js'; -import type { PairedReviewerVerdict, RegisteredGroup } from './types.js'; +import type { RegisteredGroup } from './types.js'; // ── Main executor ───────────────────────────────────────────────── @@ -143,21 +143,6 @@ export async function runAgentForGroup( }); let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionSummary: string | null = null; - let pairedReviewerVerdict: PairedReviewerVerdict | null = null; - let pairedReviewerVerdictNote: string | null = null; - let reviewerGateValidationFailed = false; - - const formatReviewerGateFailureMessage = (): string => { - const taskId = pairedExecutionContext?.task.id ?? 'unknown-task'; - const gateTurnKind = - pairedExecutionContext?.gateTurnKind ?? 'implementation_start'; - return [ - 'Reviewer gate turn requires a visible structured verdict.', - `- Task: ${taskId}`, - `- Gate: ${gateTurnKind}`, - '- Allowed verdicts: done, done_with_concerns, blocked', - ].join('\n'); - }; const shouldHandoffToCodex = ( reason: AgentTriggerReason, @@ -386,41 +371,6 @@ export async function runAgentForGroup( sawVisibleOutput: true, }; } - if ( - pairedExecutionContext?.requiresVisibleVerdict && - (output.phase === undefined || output.phase === 'final') - ) { - const structuredOutput = getStructuredAgentOutput(output); - const reviewerVerdict = structuredOutput?.verdict ?? null; - if ( - structuredOutput?.visibility === 'silent' || - reviewerVerdict === null - ) { - reviewerGateValidationFailed = true; - pairedReviewerVerdict = - structuredOutput?.visibility === 'silent' ? 'silent' : null; - pairedReviewerVerdictNote = null; - const failureMessage = formatReviewerGateFailureMessage(); - pairedExecutionSummary = failureMessage.slice(0, 500); - await onOutput({ - status: 'success', - result: null, - output: { - visibility: 'public', - text: failureMessage, - }, - phase: 'final', - }); - return; - } - if (structuredOutput) { - pairedReviewerVerdict = reviewerVerdict; - pairedReviewerVerdictNote = - structuredOutput.visibility === 'public' - ? structuredOutput.text - : null; - } - } await onOutput(output); } : undefined; @@ -915,26 +865,6 @@ export async function runAgentForGroup( return 'error'; } - if ( - pairedExecutionContext?.requiresVisibleVerdict && - (reviewerGateValidationFailed || pairedReviewerVerdict === null) - ) { - if (!reviewerGateValidationFailed && onOutput) { - const failureMessage = formatReviewerGateFailureMessage(); - pairedExecutionSummary = failureMessage.slice(0, 500); - await onOutput({ - status: 'success', - result: null, - output: { - visibility: 'public', - text: failureMessage, - }, - phase: 'final', - }); - } - return 'error'; - } - pairedExecutionStatus = 'succeeded'; return 'success'; } finally { @@ -943,8 +873,6 @@ export async function runAgentForGroup( taskId: pairedExecutionContext.task.id, status: pairedExecutionStatus, summary: pairedExecutionSummary, - reviewerVerdict: pairedReviewerVerdict, - reviewerVerdictNote: pairedReviewerVerdictNote, }); } } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index e5b195f..fab166d 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -217,9 +217,6 @@ describe('createMessageRuntime', () => { vi.mocked(db.isPairedRoomJid).mockReturnValue(false); vi.mocked(config.isClaudeService).mockReturnValue(true); vi.mocked(config.isReviewService).mockReturnValue(false); - vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue( - null, - ); }); it('surfaces the pending review message through the message-runtime /review path', async () => { @@ -2793,31 +2790,6 @@ describe('createMessageRuntime', () => { const enqueueMessageCheck = vi.fn(); const enqueueTask = vi.fn(); - vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue( - { - task: { - id: 'task-1', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - role: 'reviewer', - checkpointFingerprint: 'fingerprint-1', - recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1', - prompt: 'resume reviewer', - }, - ); - vi.mocked(db.getOpenWorkItem).mockReturnValue({ id: 99, group_folder: group.folder, @@ -2866,158 +2838,8 @@ describe('createMessageRuntime', () => { chatJid, resolveGroupIpcPath(group.folder), ); - expect(pairedExecutionContext.planPairedExecutionRecovery).not.toHaveBeenCalled(); expect(enqueueTask).not.toHaveBeenCalled(); expect(db.getMessagesSinceSeq).not.toHaveBeenCalled(); }); - it('recovery queues paired execution resume when no messages are pending', () => { - const chatJid = 'group@test'; - const group = { - ...makeGroup('codex'), - workDir: '/repo/canonical', - }; - const enqueueMessageCheck = vi.fn(); - const enqueueTask = vi.fn(); - - vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue( - { - task: { - id: 'task-1', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - role: 'reviewer', - checkpointFingerprint: 'fingerprint-1', - recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1', - prompt: 'resume reviewer', - }, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [makeChannel(chatJid)], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - enqueueMessageCheck, - enqueueTask, - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - runtime.recoverPendingMessages(); - - expect(pairedExecutionContext.planPairedExecutionRecovery).toHaveBeenCalled(); - expect(enqueueMessageCheck).not.toHaveBeenCalled(); - expect(enqueueTask).toHaveBeenCalledWith( - chatJid, - 'paired-recovery:task-1:reviewer:fingerprint-1', - expect.any(Function), - ); - }); - - it('recovery does not queue paired execution resume when messages are already pending', () => { - const chatJid = 'group@test'; - const group = { - ...makeGroup('codex'), - workDir: '/repo/canonical', - }; - const enqueueMessageCheck = vi.fn(); - const enqueueTask = vi.fn(); - vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue( - { - task: { - id: 'task-1', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }, - role: 'reviewer', - checkpointFingerprint: 'fingerprint-1', - recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1', - prompt: 'resume reviewer', - }, - ); - - vi.mocked(db.getMessagesSinceSeq).mockReturnValue([ - { - id: 'msg-1', - chat_jid: chatJid, - sender: 'user', - sender_name: 'User', - content: 'pending', - timestamp: '2026-03-29T00:00:00.000Z', - seq: 1, - is_bot_message: false, - is_from_me: false, - }, - ] as any); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [makeChannel(chatJid)], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - enqueueMessageCheck, - enqueueTask, - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - runtime.recoverPendingMessages(); - - expect(enqueueMessageCheck).toHaveBeenCalledWith( - chatJid, - resolveGroupIpcPath(group.folder), - ); - expect(pairedExecutionContext.planPairedExecutionRecovery).not.toHaveBeenCalled(); - expect(enqueueTask).not.toHaveBeenCalled(); - }); }); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 3607746..69bda21 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -918,57 +918,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } } - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext(lease, SERVICE_ID); - const recoveryPlan = planPairedExecutionRecovery({ - group, - chatJid, - roomRoleContext, - }); - if (!recoveryPlan) { - continue; - } - - deps.queue.enqueueTask(chatJid, recoveryPlan.recoveryKey, async () => { - const recoveryChannel = findChannel(deps.channels, chatJid); - if (!recoveryChannel) { - logger.warn( - { - chatJid, - taskId: recoveryPlan.task.id, - role: recoveryPlan.role, - recoveryKey: recoveryPlan.recoveryKey, - }, - 'Skipping paired execution recovery because no channel owns the chat', - ); - return; - } - - const runId = `recovery-${Date.now().toString(36)}-${Math.random() - .toString(36) - .slice(2, 8)}`; - logger.info( - { - chatJid, - group: group.name, - taskId: recoveryPlan.task.id, - role: recoveryPlan.role, - checkpointFingerprint: recoveryPlan.checkpointFingerprint, - recoveryKey: recoveryPlan.recoveryKey, - runId, - }, - 'Recovery: resuming paired execution after restart', - ); - await executeTurn({ - group, - prompt: recoveryPlan.prompt, - chatJid, - runId, - channel: recoveryChannel, - startSeq: null, - endSeq: null, - }); - }); } }; diff --git a/src/output-suppression.ts b/src/output-suppression.ts index 11542ed..9d3b874 100644 --- a/src/output-suppression.ts +++ b/src/output-suppression.ts @@ -5,7 +5,7 @@ import { CODEX_REVIEW_SERVICE_ID, normalizeServiceId, } from './config.js'; -import type { PairedGateTurnKind, StructuredAgentOutput } from './types.js'; +import type { StructuredAgentOutput } from './types.js'; const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g; const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/; @@ -64,46 +64,21 @@ export function parseStructuredOutputEnvelope( ): StructuredAgentOutput | null { try { const parsed = JSON.parse(rawText) as { - ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown }; + ejclaw?: { visibility?: unknown; text?: unknown }; }; const envelope = parsed?.ejclaw; if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { return null; } if (envelope.visibility === 'silent') { - if ( - envelope.verdict !== undefined && - !STRUCTURED_SILENT_VERDICTS.has(String(envelope.verdict)) - ) { - return null; - } - return { - visibility: 'silent', - verdict: - envelope.verdict === 'silent' - ? ('silent' as const) - : undefined, - }; + return { visibility: 'silent' }; } if ( envelope.visibility === 'public' && typeof envelope.text === 'string' && envelope.text.length > 0 ) { - if ( - envelope.verdict !== undefined && - !STRUCTURED_PUBLIC_VERDICTS.has(String(envelope.verdict)) - ) { - return null; - } - return { - visibility: 'public', - text: envelope.text, - verdict: - typeof envelope.verdict === 'string' - ? (envelope.verdict as 'done' | 'done_with_concerns' | 'blocked') - : undefined, - }; + return { visibility: 'public', text: envelope.text }; } } catch { return null; @@ -116,7 +91,7 @@ export function buildStructuredOutputPrompt( prompt: string, options?: { reviewerMode?: boolean; - gateTurnKind?: PairedGateTurnKind | null; + gateTurnKind?: string | null; requiresVisibleVerdict?: boolean; }, ): string { diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index aa19c6c..37422ca 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -33,10 +33,8 @@ vi.mock('./logger.js', () => ({ import * as db from './db.js'; import { completePairedExecutionContext, - finalizeRoomDeployment, formatRoomReviewReadyMessage, markRoomReviewReady, - planPairedExecutionRecovery, preparePairedExecutionContext, } from './paired-execution-context.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js'; @@ -130,7 +128,10 @@ async function importExecutionContextForService(serviceId: string) { ); vi.doMock('./db.js', () => dbModule); - vi.doMock('./paired-workspace-manager.js', () => pairedWorkspaceManagerModule); + vi.doMock( + './paired-workspace-manager.js', + () => pairedWorkspaceManagerModule, + ); vi.doMock('./logger.js', () => ({ logger: { debug: vi.fn(), @@ -473,7 +474,9 @@ describe('paired execution context', () => { created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); - pairedWorkspaceManagerModule.markPairedTaskReviewReady.mockReturnValue(null); + pairedWorkspaceManagerModule.markPairedTaskReviewReady.mockReturnValue( + null, + ); const result = isolatedMarkRoomReviewReady({ group, diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 3687cb3..8e200f1 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -103,11 +103,6 @@ function ensureActiveTask( source_ref: resolveCanonicalSourceRef(canonicalWorkDir), plan_notes: null, review_requested_at: null, - last_finalized_checkpoint: null, - gate_turn_kind: null, - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, status: 'active', created_at: now, updated_at: now, @@ -133,7 +128,7 @@ export interface PreparedPairedExecutionContext { task: PairedTask; workspace: PairedWorkspace | null; envOverrides: Record; - gateTurnKind?: PairedGateTurnKind | null; + gateTurnKind?: string | null; requiresVisibleVerdict?: boolean; blockMessage?: string; } @@ -163,22 +158,11 @@ export function preparePairedExecutionContext(args: { } const latestTask = getPairedTaskById(task.id) ?? task; - const gateTurnKind = getGateTurnKind(latestTask); - const requiresVisibleVerdict = - roomRoleContext.role === 'reviewer' && - !!gateTurnKind && - !hasVisibleReviewerGateVerdict(latestTask.reviewer_verdict); let workspace: PairedWorkspace | null = null; let blockMessage: string | undefined; const now = new Date().toISOString(); - if ( - roomRoleContext.role === 'owner' && - gateTurnKind && - !allowsOwnerToProceedForGate(latestTask.reviewer_verdict) - ) { - blockMessage = formatOwnerGateBlockedMessage(latestTask); - } else if (roomRoleContext.role === 'owner') { + if (roomRoleContext.role === 'owner') { workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id); } else { const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); @@ -203,17 +187,12 @@ export function preparePairedExecutionContext(args: { } if (roomRoleContext.role === 'reviewer') { envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; - if (requiresVisibleVerdict && gateTurnKind) { - envOverrides.EJCLAW_PAIRED_GATE_TURN_KIND = gateTurnKind; - } } return { task: getPairedTaskById(task.id) ?? task, workspace, envOverrides, - gateTurnKind, - requiresVisibleVerdict, blockMessage, }; } diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index 0d3f772..79a0aab 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -63,7 +63,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -129,8 +129,7 @@ describe('paired workspace manager', () => { db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at, ).toBeTruthy(); expect( - db.getPairedWorkspace('paired-task-1', 'reviewer') - ?.snapshot_ref, + db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref, ).toBeTruthy(); }); @@ -152,7 +151,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -204,7 +203,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -279,7 +278,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -353,7 +352,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -493,7 +492,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -554,7 +553,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -609,7 +608,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -670,7 +669,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -733,7 +732,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -821,7 +820,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index c4f7a2a..9a7039a 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -198,8 +198,13 @@ function listReviewableTrackedDiffFiles( sourceDir: string, sourceRef: string, ): string[] { - return listGitPaths(sourceDir, ['diff', '--name-only', '-z', sourceRef, '--']) - .filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath)); + return listGitPaths(sourceDir, [ + 'diff', + '--name-only', + '-z', + sourceRef, + '--', + ]).filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath)); } function copySnapshotPaths( @@ -323,9 +328,7 @@ function makeWorkspaceRecord(args: { snapshot_source_dir: args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, snapshot_ref: - args.snapshotSourceFingerprint ?? - existing?.snapshot_ref ?? - null, + args.snapshotSourceFingerprint ?? existing?.snapshot_ref ?? null, status: args.status ?? 'ready', snapshot_refreshed_at: args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null, @@ -334,7 +337,9 @@ function makeWorkspaceRecord(args: { }; } -export function resolvePairedTaskSourceFingerprint(taskId: string): string | null { +export function resolvePairedTaskSourceFingerprint( + taskId: string, +): string | null { const { task } = getTaskAndProject(taskId); const ownerWorkspace = getPairedWorkspace(taskId, 'owner'); if (!ownerWorkspace) { @@ -574,8 +579,7 @@ export function prepareReviewerWorkspaceForExecution( role: 'reviewer', workspaceDir: reviewerWorkspace.workspace_dir, snapshotSourceDir: reviewerWorkspace.snapshot_source_dir, - snapshotSourceFingerprint: - reviewerWorkspace.snapshot_ref, + snapshotSourceFingerprint: reviewerWorkspace.snapshot_ref, snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at, status: 'stale', createdAt: reviewerWorkspace.created_at, diff --git a/src/router.ts b/src/router.ts index 20e9bda..46decf8 100644 --- a/src/router.ts +++ b/src/router.ts @@ -93,6 +93,13 @@ export function findChannel( return channels.find((c) => c.ownsJid(jid)); } +export function findChannelByName( + channels: Channel[], + name: string, +): Channel | undefined { + return channels.find((c) => c.name === name); +} + /** * Normalize message text for deduplication comparison. * - Trim leading/trailing whitespace diff --git a/src/service-routing.ts b/src/service-routing.ts index f491af9..dd5e152 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -4,6 +4,7 @@ import { CODEX_REVIEW_SERVICE_ID, SERVICE_AGENT_TYPE, SERVICE_ID, + UNIFIED_MODE, normalizeServiceId, } from './config.js'; import { @@ -142,6 +143,7 @@ export function shouldServiceProcessChat( chatJid: string, serviceId: string = SERVICE_ID, ): boolean { + if (UNIFIED_MODE) return true; const normalizedServiceId = normalizeServiceId(serviceId); const lease = getEffectiveChannelLease(chatJid); return ( diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 529a0fe..3614161 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -234,23 +234,6 @@ describe('handleSessionCommand', () => { ); }); - it('handles authorized /deploy-complete without invoking the agent', async () => { - const deps = makeDeps(); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/deploy-complete')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.finalizeDeployment).toHaveBeenCalledTimes(1); - expect(deps.runAgent).not.toHaveBeenCalled(); - expect(deps.advanceCursor).toHaveBeenCalledWith('100'); - expect(deps.sendMessage).toHaveBeenCalledWith('Deployment finalized.'); - }); - it('handles authorized /review without invoking the agent', async () => { const deps = makeDeps({ markReviewReady: vi diff --git a/src/types.ts b/src/types.ts index 28bb28d..626f5f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,11 +62,6 @@ export interface PairedTask { source_ref: string | null; plan_notes: string | null; review_requested_at: string | null; - last_finalized_checkpoint?: string | null; - gate_turn_kind?: PairedGateTurnKind | null; - reviewer_verdict?: PairedReviewerVerdict | null; - reviewer_verdict_at?: string | null; - reviewer_verdict_note?: string | null; status: PairedTaskStatus; created_at: string; updated_at: string; @@ -91,11 +86,9 @@ export type StructuredAgentOutput = | { visibility: 'public'; text: string; - verdict?: Exclude; } | { visibility: 'silent'; - verdict?: 'silent'; }; export function normalizeAgentOutputPhase(