From 35a59218d5cf4adcd05a65076bb8a790b5ab29b6 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:17 +0900 Subject: [PATCH 01/24] refactor: internalize setRegisteredGroup export --- src/db.test.ts | 32 +++++++++++++-------------- src/db.ts | 18 ++++++++++++++-- src/duplicate-suppression.test.ts | 36 +++++++++++++++---------------- src/ipc-auth.test.ts | 10 ++++----- src/service-routing.test.ts | 24 ++++++++++----------- 5 files changed, 67 insertions(+), 53 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index 330314b..de7487e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _setRegisteredGroupForTests, assignRoom, claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, @@ -43,7 +44,6 @@ import { markWorkItemDelivered, markWorkItemDeliveryRetry, setSession, - setRegisteredGroup, setRouterStateForService, setExplicitRoomMode, storeChatMetadata, @@ -720,7 +720,7 @@ describe('message query LIMIT', () => { describe('registered group isMain', () => { it('persists isMain=true through set/get round-trip', () => { - setRegisteredGroup('dc:main', { + _setRegisteredGroupForTests('dc:main', { name: 'Main Chat', folder: 'discord_main', trigger: '@Andy', @@ -736,7 +736,7 @@ describe('registered group isMain', () => { }); it('omits isMain for non-main groups', () => { - setRegisteredGroup('group@g.us', { + _setRegisteredGroupForTests('group@g.us', { name: 'Family Chat', folder: 'discord_family-chat', trigger: '@Andy', @@ -750,14 +750,14 @@ describe('registered group isMain', () => { }); it('filters duplicate jid registrations by agent type', () => { - setRegisteredGroup('dc:shared', { + _setRegisteredGroupForTests('dc:shared', { name: 'Shared Room', folder: 'shared-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:shared', { + _setRegisteredGroupForTests('dc:shared', { name: 'Shared Room', folder: 'shared-room', trigger: '@Codex', @@ -828,14 +828,14 @@ describe('room assignment writes', () => { describe('paired room registration', () => { it('detects when both Claude and Codex are registered on the same jid', () => { - setRegisteredGroup('dc:123', { + _setRegisteredGroupForTests('dc:123', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:123', { + _setRegisteredGroupForTests('dc:123', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -853,7 +853,7 @@ describe('paired room registration', () => { }); it('does not mark solo rooms as paired', () => { - setRegisteredGroup('dc:solo', { + _setRegisteredGroupForTests('dc:solo', { name: 'Solo Claude Room', folder: 'solo-claude', trigger: '@Andy', @@ -866,14 +866,14 @@ describe('paired room registration', () => { }); it('keeps inferred room mode available when no explicit override exists', () => { - setRegisteredGroup('dc:legacy-paired', { + _setRegisteredGroupForTests('dc:legacy-paired', { name: 'Legacy Paired', folder: 'legacy-paired', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:legacy-paired', { + _setRegisteredGroupForTests('dc:legacy-paired', { name: 'Legacy Paired', folder: 'legacy-paired', trigger: '@Codex', @@ -966,14 +966,14 @@ describe('paired room registration', () => { }); it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => { - setRegisteredGroup('dc:room-settings', { + _setRegisteredGroupForTests('dc:room-settings', { name: 'Room Settings Test', folder: 'room-settings-test', trigger: '@Claude', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:room-settings', { + _setRegisteredGroupForTests('dc:room-settings', { name: 'Room Settings Test', folder: 'room-settings-test', trigger: '@Codex', @@ -1082,14 +1082,14 @@ describe('paired room registration', () => { }); it('lets explicit single override dual registration for paired-room checks', () => { - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Codex', @@ -1105,7 +1105,7 @@ describe('paired room registration', () => { }); it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => { - setRegisteredGroup('dc:explicit-tribunal', { + _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', folder: 'explicit-tribunal-claude', trigger: '@Andy', @@ -1131,7 +1131,7 @@ describe('paired room registration', () => { }); it('keeps explicit tribunal non-runnable when the configured reviewer service is unavailable', () => { - setRegisteredGroup('dc:explicit-tribunal-codex', { + _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', trigger: '@Codex', diff --git a/src/db.ts b/src/db.ts index d34428b..32ecf88 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1987,7 +1987,10 @@ export function getRegisteredGroup( return getLegacyRegisteredGroup(db, jid, agentType); } -export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { +function writeLegacyRegisteredGroupAndSyncRoomSettings( + jid: string, + group: RegisteredGroup, +): void { const existingRoomMode = getStoredRoomModeRow(jid); if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); @@ -2017,6 +2020,17 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { tx(); } +/** + * @internal Test/migration helper for seeding legacy capability rows. + * Runtime code must use assignRoom() so room_settings remains the explicit SSOT. + */ +export function _setRegisteredGroupForTests( + jid: string, + group: RegisteredGroup, +): void { + writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); +} + export function assignRoom( chatJid: string, input: AssignRoomInput, @@ -3337,7 +3351,7 @@ function migrateJsonState(): void { if (groups) { for (const [jid, group] of Object.entries(groups)) { try { - setRegisteredGroup(jid, group); + writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); } catch (err) { logger.warn( { jid, folder: group.folder, err }, diff --git a/src/duplicate-suppression.test.ts b/src/duplicate-suppression.test.ts index af938af..8552a9d 100644 --- a/src/duplicate-suppression.test.ts +++ b/src/duplicate-suppression.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { + _setRegisteredGroupForTests, _initTestDatabase, storeMessage, storeChatMetadata, createProducedWorkItem, getOpenWorkItem, - setRegisteredGroup, getLastBotFinalMessage, markWorkItemDelivered, } from './db.js'; @@ -56,7 +56,7 @@ describe('hasReviewerLease', () => { it('returns true when both claude-code and codex are registered', () => { const jid = 'dc:paired-room'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -64,7 +64,7 @@ describe('hasReviewerLease', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -78,7 +78,7 @@ describe('hasReviewerLease', () => { it('returns false when only claude-code is registered', () => { const jid = 'dc:single-claude'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Claude', folder: 'single-claude', trigger: '@claude', @@ -92,7 +92,7 @@ describe('hasReviewerLease', () => { it('returns false when only codex is registered', () => { const jid = 'dc:single-codex'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Codex', folder: 'single-codex', trigger: '@codex', @@ -180,7 +180,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -188,7 +188,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -225,7 +225,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register only one bot - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Bot', folder: 'single-bot', trigger: '@claude', @@ -257,7 +257,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -265,7 +265,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -297,7 +297,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -305,7 +305,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -336,7 +336,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -344,7 +344,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -384,7 +384,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -392,7 +392,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -440,7 +440,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -448,7 +448,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index cba6575..3b39ec3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ASSISTANT_NAME } from './config.js'; import { + _setRegisteredGroupForTests, _initTestDatabase, assignRoom, createTask, @@ -10,7 +11,6 @@ import { getRegisteredGroup, getStoredRoomSettings, getTaskById, - setRegisteredGroup, } from './db.js'; import { processTaskIpc, IpcDeps } from './ipc.js'; import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js'; @@ -52,9 +52,9 @@ beforeEach(() => { }; // Populate DB as well - setRegisteredGroup('main@g.us', MAIN_GROUP); - setRegisteredGroup('other@g.us', OTHER_GROUP); - setRegisteredGroup('third@g.us', THIRD_GROUP); + _setRegisteredGroupForTests('main@g.us', MAIN_GROUP); + _setRegisteredGroupForTests('other@g.us', OTHER_GROUP); + _setRegisteredGroupForTests('third@g.us', THIRD_GROUP); deps = { sendMessage: async () => {}, @@ -119,7 +119,7 @@ describe('schedule_task authorization', () => { ...OTHER_GROUP, agentType: 'codex', }; - setRegisteredGroup('other@g.us', groups['other@g.us']); + _setRegisteredGroupForTests('other@g.us', groups['other@g.us']); await processTaskIpc( { diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 0e6e766..63f3c1e 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { + _setRegisteredGroupForTests, _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, setExplicitRoomMode, - setRegisteredGroup, } from './db.js'; import { activateCodexFailover, @@ -22,14 +22,14 @@ beforeEach(() => { describe('service-routing global failover', () => { it('uses codex-review as owner during global failover', () => { - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -57,14 +57,14 @@ describe('service-routing global failover', () => { }); it('restores default lease after global failover is cleared', () => { - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -85,14 +85,14 @@ describe('service-routing global failover', () => { }); it('uses explicit single room mode to suppress reviewer lease on dual registration', () => { - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Codex', @@ -110,14 +110,14 @@ describe('service-routing global failover', () => { }); it('uses stored owner agent type when room_settings selects a different owner', () => { - setRegisteredGroup('dc:stored-owner-claude', { + _setRegisteredGroupForTests('dc:stored-owner-claude', { name: 'Stored Owner Claude', folder: 'stored-owner-claude', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:stored-owner-claude', { + _setRegisteredGroupForTests('dc:stored-owner-claude', { name: 'Stored Owner Claude', folder: 'stored-owner-claude', trigger: '@Codex', @@ -139,7 +139,7 @@ describe('service-routing global failover', () => { }); it('falls back to the available service when stored owner agent type is unavailable', () => { - setRegisteredGroup('dc:stored-owner-fallback', { + _setRegisteredGroupForTests('dc:stored-owner-fallback', { name: 'Stored Owner Fallback', folder: 'stored-owner-fallback', trigger: '@Andy', @@ -157,7 +157,7 @@ describe('service-routing global failover', () => { }); it('creates a same-service reviewer lease when explicit tribunal is runnable on a solo registration', () => { - setRegisteredGroup('dc:explicit-tribunal', { + _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', folder: 'explicit-tribunal-claude', trigger: '@Andy', @@ -175,7 +175,7 @@ describe('service-routing global failover', () => { }); it('keeps reviewer lease disabled when explicit tribunal cannot deliver the configured reviewer', () => { - setRegisteredGroup('dc:explicit-tribunal-codex', { + _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', trigger: '@Codex', From c590fdaaa32537241c93659a80f2f1f9798f44d8 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:36 +0900 Subject: [PATCH 02/24] refactor: prefer room_settings over registered_groups in runtime reads --- src/db.test.ts | 4 ++-- src/db.ts | 7 ++++++- src/service-routing.test.ts | 8 ++++---- src/service-routing.ts | 13 +++++-------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index de7487e..1b34f2e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1130,7 +1130,7 @@ describe('paired room registration', () => { expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single'); }); - it('keeps explicit tribunal non-runnable when the configured reviewer service is unavailable', () => { + it('trusts stored tribunal mode even when legacy capability rows are incomplete', () => { _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', @@ -1143,7 +1143,7 @@ describe('paired room registration', () => { expect(getEffectiveRoomMode('dc:explicit-tribunal-codex')).toBe('tribunal'); expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal-codex')).toBe( - 'single', + 'tribunal', ); }); }); diff --git a/src/db.ts b/src/db.ts index 32ecf88..1f113f4 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2770,7 +2770,12 @@ function canRunTribunalFromRegisteredAgentTypes( } export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode { - return getEffectiveRoomMode(chatJid) === 'tribunal' && + const stored = getStoredRoomSettings(chatJid); + if (stored) { + return stored.roomMode; + } + + return inferStoredRoomModeForJid(chatJid) === 'tribunal' && canRunTribunalFromRegisteredAgentTypes( getRegisteredAgentTypesForJid(chatJid), ) diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 63f3c1e..a2ce005 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -138,7 +138,7 @@ describe('service-routing global failover', () => { }); }); - it('falls back to the available service when stored owner agent type is unavailable', () => { + it('trusts stored owner agent type over incomplete legacy capability rows', () => { _setRegisteredGroupForTests('dc:stored-owner-fallback', { name: 'Stored Owner Fallback', folder: 'stored-owner-fallback', @@ -150,7 +150,7 @@ describe('service-routing global failover', () => { expect(getEffectiveChannelLease('dc:stored-owner-fallback')).toMatchObject({ chat_jid: 'dc:stored-owner-fallback', - owner_service_id: 'claude', + owner_service_id: 'codex-main', reviewer_service_id: null, explicit: false, }); @@ -174,7 +174,7 @@ describe('service-routing global failover', () => { }); }); - it('keeps reviewer lease disabled when explicit tribunal cannot deliver the configured reviewer', () => { + it('builds reviewer lease from stored tribunal mode even when legacy rows are incomplete', () => { _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', @@ -189,7 +189,7 @@ describe('service-routing global failover', () => { ).toMatchObject({ chat_jid: 'dc:explicit-tribunal-codex', owner_service_id: 'codex-main', - reviewer_service_id: null, + reviewer_service_id: 'claude', explicit: false, }); }); diff --git a/src/service-routing.ts b/src/service-routing.ts index 9194228..f20bbd4 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -72,17 +72,14 @@ function inferFallbackOwnerAgentType( } function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { + const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType; + if (storedOwnerAgentType) { + return storedOwnerAgentType; + } + const types = getRegisteredAgentTypesForJid(chatJid); const hasClaude = types.includes('claude-code'); const hasCodex = types.includes('codex'); - const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType; - - if (storedOwnerAgentType === 'claude-code' && hasClaude) { - return 'claude-code'; - } - if (storedOwnerAgentType === 'codex' && hasCodex) { - return 'codex'; - } return inferFallbackOwnerAgentType(hasClaude, hasCodex); } From 85cbce555a28b10a80958896dade20b5c3520b6d Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:43 +0900 Subject: [PATCH 03/24] docs: update README with room assignment model --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index c8471c9..de13fa6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,25 @@ A single unified service (`ejclaw`) manages three Discord bots in one process: All agent types and models are independently configurable per role via `.env`. +## Room Assignment Model + +Per-room routing now uses an explicit assignment model: + +- `room_settings` is the room-level source of truth (SSOT) +- Each room stores: + - `room_mode`: `single` or `tribunal` + - `owner_agent_type`: `codex` or `claude-code` +- Public room assignment uses `assign_room` +- Legacy `register_group` public interfaces were removed +- `registered_groups` remains as a materialized capability/read-model layer and legacy fallback, not the authoritative room configuration + +Operationally: + +- `single` → one owner bot +- `tribunal` → per-room owner + globally configured reviewer + optional arbiter + +This means tribunal is no longer inferred from “two bots registered on one room”; it is an explicit room setting. + ## Tribunal 3-Agent System ``` From cb0d29494105577b9587a90f381d85d100da6b2a Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 03:59:47 +0900 Subject: [PATCH 04/24] feat: honor handoff agent type + local memory store - fix: pass forcedRole/forcedAgentType through handoff execution chain so reviewer failover actually runs codex instead of retrying Claude - fix: preserve intended_role in service handoff records - feat: replace Memento MCP with local SQLite memory store - feat: add memory selection with TTL decay --- runners/agent-runner/src/index.ts | 141 ++----- runners/agent-runner/src/memory-selection.ts | 73 ++++ .../test/memory-selection.test.ts | 43 ++ src/agent-runner-environment.ts | 24 +- src/db.test.ts | 147 +++++++ src/db.ts | 397 +++++++++++++++++- src/ipc-auth.test.ts | 51 +++ src/ipc.ts | 76 ++++ src/memento-client.test.ts | 55 --- src/memento-client.ts | 222 ---------- src/message-agent-executor.test.ts | 123 +++++- src/message-agent-executor.ts | 49 ++- src/message-runtime.test.ts | 32 +- src/message-runtime.ts | 64 ++- src/paired-execution-context.test.ts | 45 ++ src/paired-execution-context.ts | 24 ++ src/sqlite-memory-store.test.ts | 103 +++++ src/sqlite-memory-store.ts | 62 +++ 18 files changed, 1309 insertions(+), 422 deletions(-) create mode 100644 runners/agent-runner/src/memory-selection.ts create mode 100644 runners/agent-runner/test/memory-selection.test.ts delete mode 100644 src/memento-client.test.ts delete mode 100644 src/memento-client.ts create mode 100644 src/sqlite-memory-store.test.ts create mode 100644 src/sqlite-memory-store.ts diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 8a0bb44..6c469fc 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -17,8 +17,6 @@ import fs from 'fs'; import path from 'path'; import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { fileURLToPath } from 'url'; import { @@ -30,6 +28,7 @@ import { isReviewerMutatingShellCommand, isReviewerRuntime, } from './reviewer-runtime.js'; +import { selectCompactMemoriesFromSummary } from './memory-selection.js'; interface ContainerInput { prompt: string; @@ -87,23 +86,18 @@ interface AssistantContentBlock { text?: string; } -interface MementoCallToolResult { - isError?: boolean; -} - // Paths configurable via env vars. const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group'; const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc'; +const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR; // Optional: override cwd (agent works in this directory instead of GROUP_DIR) const WORK_DIR = process.env.EJCLAW_WORK_DIR || ''; const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || ''; -const MEMENTO_SSE_URL = process.env.MEMENTO_MCP_SSE_URL || ''; -const MEMENTO_ACCESS_KEY = process.env.MEMENTO_ACCESS_KEY || ''; -const MEMENTO_TIMEOUT_MS = 4_000; const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; +const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks'); /** SSOT: src/agent-protocol.ts — keep in sync */ const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; @@ -270,101 +264,54 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string | return null; } -function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - function trimSummary(summary: string, maxChars: number): string { if (summary.length <= maxChars) return summary; return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…'; } -async function callMementoTool( - name: string, - args: Record, - timeoutMs = MEMENTO_TIMEOUT_MS, -): Promise { - if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false; - - const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), { - requestInit: { - headers: { - Authorization: `Bearer ${MEMENTO_ACCESS_KEY}`, - }, - }, - }); - const client = new Client({ name: 'ejclaw-precompact', version: '1.0.0' }); - - try { - await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`); - const result = await withTimeout( - client.callTool({ - name, - arguments: args, - }) as Promise, - timeoutMs, - `${name}/call`, - ); - - if (result.isError) { - log(`Memento tool returned error: ${name}`); - return false; - } - - return true; - } catch (err) { - log(`Memento tool failed (${name}): ${err instanceof Error ? err.message : String(err)}`); - return false; - } finally { - await client.close().catch(() => {}); - } +function writeHostTaskIpcFile(data: object): string { + fs.mkdirSync(HOST_TASKS_DIR, { recursive: true }); + const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; + const filepath = path.join(HOST_TASKS_DIR, filename); + const tempPath = `${filepath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.renameSync(tempPath, filepath); + return filename; } async function persistCompactMemory(summary: string, sessionId: string): Promise { const normalized = summary.trim(); - if (!normalized) return; + if (!normalized || !GROUP_FOLDER) return; - const tasks: Promise[] = [ - callMementoTool('reflect', { - summary: normalized, - }), - ]; + try { + const scopeKey = `room:${GROUP_FOLDER}`; + const selected = selectCompactMemoriesFromSummary(normalized, scopeKey); + if (selected.length === 0) { + log('Skipped compact memory persist - no salient room memory found'); + return; + } - if (GROUP_FOLDER) { - tasks.push( - callMementoTool('remember', { - content: trimSummary(normalized, 300), - topic: 'room-memory', - type: 'fact', - keywords: [`room:${GROUP_FOLDER}`], - source: `compact:${sessionId}`, - }), + for (const memory of selected) { + const file = writeHostTaskIpcFile({ + type: 'persist_memory', + scopeKind: 'room', + scopeKey, + content: trimSummary(memory.content, 300), + keywords: memory.keywords, + memory_kind: memory.memoryKind, + source_kind: 'compact', + source_ref: sessionId ? `compact:${sessionId}` : null, + timestamp: new Date().toISOString(), + }); + log(`Persisted compact memory via IPC (${file})`); + } + } catch (err) { + log( + `Failed to persist compact memory via IPC: ${ + err instanceof Error ? err.message : String(err) + }`, ); } - - const results = await Promise.allSettled(tasks); - const succeeded = results.filter( - (result) => result.status === 'fulfilled' && result.value, - ).length; - - if (succeeded > 0) { - log(`Persisted compact memory (${succeeded}/${results.length})`); - } } /** @@ -729,18 +676,6 @@ async function runQuery( }), }, }, - ...(process.env.MEMENTO_MCP_SSE_URL - ? { - 'memento-mcp': { - command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote', - args: [ - process.env.MEMENTO_MCP_SSE_URL, - '--header', - `Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`, - ], - }, - } - : {}), }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], diff --git a/runners/agent-runner/src/memory-selection.ts b/runners/agent-runner/src/memory-selection.ts new file mode 100644 index 0000000..52a715a --- /dev/null +++ b/runners/agent-runner/src/memory-selection.ts @@ -0,0 +1,73 @@ +export interface SelectedCompactMemory { + content: string; + keywords: string[]; + memoryKind: string | null; +} + +const MAX_SELECTED_MEMORIES = 3; + +const MEMORY_PATTERNS: Array<{ + kind: string | null; + regex: RegExp; + keyword: string; +}> = [ + { kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' }, + { kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' }, + { kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' }, + { kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' }, +]; + +function normalizeSentence(raw: string): string | null { + const normalized = raw + .trim() + .replace(/^[-*]\s*/, '') + .replace(/\s+/g, ' '); + return normalized ? normalized : null; +} + +function splitSummaryIntoSentences(summary: string): string[] { + return summary + .split(/\n+|(?<=[.!?。])\s+/) + .map(normalizeSentence) + .filter((value): value is string => Boolean(value)); +} + +function classifyMemorySentence(content: string): SelectedCompactMemory | null { + if (!content) return null; + + const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content)); + if (matchedPatterns.length === 0) return null; + + const primary = matchedPatterns[0]; + const keywords = [ + ...new Set( + matchedPatterns + .map((pattern) => pattern.keyword) + .filter(Boolean), + ), + ]; + + return { + content, + keywords, + memoryKind: primary?.kind ?? null, + }; +} + +export function selectCompactMemoriesFromSummary( + summary: string, + roomKey: string, +): SelectedCompactMemory[] { + if (!summary.trim()) return []; + + const selected = splitSummaryIntoSentences(summary) + .map((sentence) => classifyMemorySentence(sentence)) + .filter((value): value is SelectedCompactMemory => Boolean(value)) + .slice(0, MAX_SELECTED_MEMORIES) + .map((entry) => ({ + ...entry, + keywords: [...new Set([roomKey, ...entry.keywords])], + })); + + return selected; +} diff --git a/runners/agent-runner/test/memory-selection.test.ts b/runners/agent-runner/test/memory-selection.test.ts new file mode 100644 index 0000000..154068b --- /dev/null +++ b/runners/agent-runner/test/memory-selection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { selectCompactMemoriesFromSummary } from '../src/memory-selection.js'; + +describe('memory selection', () => { + it('keeps stable room memories and drops transient status lines', () => { + const selected = selectCompactMemoriesFromSummary( + [ + '방 메모리는 새 세션 시작 시에만 주입한다.', + '테스트 599개 통과.', + '사용자는 수동 명령보다 자동 기억 형성을 원한다.', + ].join(' '), + 'room:ejclaw', + ); + + expect(selected).toHaveLength(2); + expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.'); + expect(selected[1].memoryKind).toBe('preference'); + expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true); + }); + + it('returns no memories for purely operational summaries', () => { + const selected = selectCompactMemoriesFromSummary( + '메멘토 대체 v1 로컬 구현은 끝냈습니다. 테스트 599개 통과.', + 'room:ejclaw', + ); + + expect(selected).toEqual([]); + }); + + it('keeps stable rule sentences even when they contain operational words', () => { + const selected = selectCompactMemoriesFromSummary( + '배포 전에 항상 테스트를 돌리는 것이 원칙이다.', + 'room:ejclaw', + ); + + expect(selected).toHaveLength(1); + expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + }); +}); diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index d1da400..0cae6cc 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -316,10 +316,6 @@ function prepareCodexSessionEnvironment(args: { ? fs.readFileSync(sessionConfigPath, 'utf-8') : ''; toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, ''); - toml = toml.replace( - /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, - '', - ); const mcpSection = ` [mcp_servers.ejclaw] command = "node" @@ -333,24 +329,9 @@ EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)} `; - const mementoSseUrl = - args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; - const mementoAccessKey = - args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; - const mementoRemotePath = - args.envVars.MEMENTO_MCP_REMOTE_PATH || - process.env.MEMENTO_MCP_REMOTE_PATH || - 'mcp-remote'; - const mementoSection = mementoSseUrl - ? ` -[mcp_servers.memento-mcp] -command = ${JSON.stringify(mementoRemotePath)} -args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] -` - : ''; fs.writeFileSync( sessionConfigPath, - toml.trimEnd() + '\n' + mcpSection + mementoSection, + toml.trimEnd() + '\n' + mcpSection, ); } @@ -474,9 +455,6 @@ export function prepareGroupEnvironment( 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT', - 'MEMENTO_MCP_SSE_URL', - 'MEMENTO_ACCESS_KEY', - 'MEMENTO_MCP_REMOTE_PATH', ]); const env = buildBaseRunnerEnv({ diff --git a/src/db.test.ts b/src/db.test.ts index 1b34f2e..c8c0ef8 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _setMemoryTimestampsForTests, _setRegisteredGroupForTests, assignRoom, claimServiceHandoff, @@ -30,6 +31,7 @@ import { getNewMessagesBySeq, getOpenWorkItem, getPendingServiceHandoffs, + recallMemories, getRegisteredAgentTypesForJid, getMessagesSince, getNewMessages, @@ -46,6 +48,7 @@ import { setSession, setRouterStateForService, setExplicitRoomMode, + rememberMemory, storeChatMetadata, storeMessage, updateRegisteredGroupName, @@ -1224,6 +1227,28 @@ describe('service handoff completion', () => { 'dc:handoff': '5', }); }); + + it('stores the intended handoff role when provided', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-role', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-review', + target_agent_type: 'codex', + prompt: 'please review', + reason: 'reviewer-claude-429', + intended_role: 'reviewer', + }); + + expect(handoff.intended_role).toBe('reviewer'); + expect(getPendingServiceHandoffs('codex-review')).toEqual([ + expect.objectContaining({ + id: handoff.id, + intended_role: 'reviewer', + reason: 'reviewer-claude-429', + }), + ]); + }); }); describe('message seq cursors', () => { @@ -1280,6 +1305,128 @@ describe('message seq cursors', () => { }); }); +describe('memories', () => { + it('recalls scoped memories through FTS and exact keyword matching', () => { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:test-group', + content: '세션 재시작 후에도 방 메모리를 주입한다.', + keywords: ['room:test-group', 'session-reset'], + sourceKind: 'compact', + sourceRef: 'compact:1', + }); + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:test-group', + content: '이 메모리는 다른 검색어다.', + keywords: ['room:test-group'], + sourceKind: 'compact', + sourceRef: 'compact:2', + }); + + const byText = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:test-group', + text: 'session reset', + limit: 5, + }); + expect(byText).toHaveLength(1); + expect(byText[0].content).toContain('방 메모리를 주입한다'); + + const byKeyword = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:test-group', + keywords: ['session-reset'], + limit: 5, + }); + expect(byKeyword).toHaveLength(1); + expect(byKeyword[0].content).toContain('방 메모리를 주입한다'); + }); + + it('archives old memories when a scope exceeds its bounded limit', () => { + for (let index = 0; index < 305; index += 1) { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:bounded', + content: `memory-${index}`, + keywords: ['room:bounded'], + sourceKind: 'compact', + sourceRef: `compact:${index}`, + }); + } + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:bounded', + limit: 500, + }); + + expect(recalled).toHaveLength(300); + expect(recalled.some((memory) => memory.content === 'memory-0')).toBe(false); + expect(recalled.some((memory) => memory.content === 'memory-304')).toBe(true); + }); + + it('archives stale compact memories before recall using last_used_at TTL', () => { + const staleId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '오래된 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:stale', + }); + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '최근에 다시 쓰인 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:fresh', + }); + + _setMemoryTimestampsForTests(staleId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl', + limit: 10, + }); + + expect(recalled.some((memory) => memory.content === '오래된 compact memory')).toBe(false); + expect( + recalled.some((memory) => memory.content === '최근에 다시 쓰인 compact memory'), + ).toBe(true); + }); + + it('keeps explicit memories even when they are old', () => { + const explicitId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + content: '관리자가 남긴 고정 규칙', + keywords: ['room:ttl-explicit'], + sourceKind: 'explicit', + sourceRef: 'msg:1', + }); + + _setMemoryTimestampsForTests(explicitId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + limit: 10, + }); + + expect(recalled).toHaveLength(1); + expect(recalled[0].content).toBe('관리자가 남긴 고정 규칙'); + }); +}); + describe('work items', () => { it('tracks produced, retry, and delivered states', () => { const item = createProducedWorkItem({ diff --git a/src/db.ts b/src/db.ts index 1f113f4..0360656 100644 --- a/src/db.ts +++ b/src/db.ts @@ -72,6 +72,31 @@ export interface AssignRoomInput { ownerAgentConfig?: RegisteredGroup['agentConfig']; } +export type MemoryScopeKind = 'room' | 'user' | 'project' | 'global'; +export type MemorySourceKind = 'compact' | 'explicit' | 'import' | 'system'; + +export interface MemoryRecord { + id: number; + scopeKind: MemoryScopeKind; + scopeKey: string; + content: string; + keywords: string[]; + memoryKind: string | null; + sourceKind: MemorySourceKind; + sourceRef: string | null; + createdAt: string; + lastUsedAt: string | null; + archivedAt: string | null; +} + +export interface RecallMemoryQuery { + scopeKind: MemoryScopeKind; + scopeKey: string; + text?: string; + keywords?: string[]; + limit?: number; +} + interface RoomRegistrationSnapshot { name: string; folder: string; @@ -134,6 +159,7 @@ export interface ServiceHandoff { start_seq: number | null; end_seq: number | null; reason: string | null; + intended_role: PairedRoomRole | null; created_at: string; claimed_at: string | null; completed_at: string | null; @@ -383,14 +409,53 @@ function createSchema(database: Database): void { start_seq INTEGER, end_seq INTEGER, reason TEXT, + intended_role TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), claimed_at TEXT, completed_at TEXT, last_error TEXT, - CHECK (status IN ('pending', 'claimed', 'completed', 'failed')) + CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), + CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL) ); CREATE INDEX IF NOT EXISTS idx_service_handoffs_target ON service_handoffs(target_service_id, status, created_at); + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY, + scope_kind TEXT NOT NULL, + scope_key TEXT NOT NULL, + content TEXT NOT NULL, + keywords_json TEXT NOT NULL DEFAULT '[]', + memory_kind TEXT, + source_kind TEXT NOT NULL, + source_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + archived_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_memories_scope + ON memories(scope_kind, scope_key); + CREATE INDEX IF NOT EXISTS idx_memories_active + ON memories(scope_kind, scope_key, archived_at, created_at); + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + content, + keywords, + content='', + tokenize='unicode61' + ); + CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, content, keywords) + VALUES (new.id, new.content, new.keywords_json); + END; + CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content, keywords) + VALUES ('delete', old.id, old.content, old.keywords_json); + END; + CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content, keywords) + VALUES ('delete', old.id, old.content, old.keywords_json); + INSERT INTO memories_fts(rowid, content, keywords) + VALUES (new.id, new.content, new.keywords_json); + END; `); // Add context_mode column if it doesn't exist (migration for existing DBs) @@ -506,6 +571,12 @@ function createSchema(database: Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`); + } catch { + /* column already exists */ + } + database.exec( `UPDATE room_settings SET mode_source = 'explicit' @@ -955,6 +1026,323 @@ export function _setStoredRoomOwnerAgentTypeForTests( ).run(ownerAgentType, new Date().toISOString(), chatJid); } +/** @internal - for tests only. */ +export function _setMemoryTimestampsForTests( + id: number, + args: { + createdAt?: string; + lastUsedAt?: string | null; + archivedAt?: string | null; + }, +): void { + const existing = db + .prepare( + `SELECT created_at, last_used_at, archived_at + FROM memories + WHERE id = ?`, + ) + .get(id) as + | { + created_at: string; + last_used_at: string | null; + archived_at: string | null; + } + | undefined; + if (!existing) throw new Error(`Memory ${id} not found`); + + db.prepare( + `UPDATE memories + SET created_at = ?, last_used_at = ?, archived_at = ? + WHERE id = ?`, + ).run( + args.createdAt ?? existing.created_at, + args.lastUsedAt === undefined ? existing.last_used_at : args.lastUsedAt, + args.archivedAt === undefined ? existing.archived_at : args.archivedAt, + id, + ); +} + +const MEMORY_SCOPE_LIMITS: Record = { + room: 300, + user: 100, + project: 200, + global: 100, +}; +const COMPACT_MEMORY_TTL_DAYS = 30; + +interface MemoryDatabaseRow { + id: number; + scope_kind: string; + scope_key: string; + content: string; + keywords_json: string; + memory_kind: string | null; + source_kind: string; + source_ref: string | null; + created_at: string; + last_used_at: string | null; + archived_at: string | null; +} + +function normalizeMemoryKeywords(keywords?: string[]): string[] { + if (!Array.isArray(keywords)) return []; + return [ + ...new Set( + keywords + .map((keyword) => keyword.trim().toLowerCase()) + .filter(Boolean), + ), + ]; +} + +function parseMemoryKeywords(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return normalizeMemoryKeywords(Array.isArray(parsed) ? parsed : []); + } catch { + return []; + } +} + +function mapMemoryRow(row: MemoryDatabaseRow): MemoryRecord { + return { + id: row.id, + scopeKind: row.scope_kind as MemoryScopeKind, + scopeKey: row.scope_key, + content: row.content, + keywords: parseMemoryKeywords(row.keywords_json), + memoryKind: row.memory_kind, + sourceKind: row.source_kind as MemorySourceKind, + sourceRef: row.source_ref, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + archivedAt: row.archived_at, + }; +} + +function buildMemoryFtsQuery(query: RecallMemoryQuery): string | null { + const tokens = normalizeMemoryKeywords([ + ...(query.text?.match(/[\p{L}\p{N}_:-]+/gu) ?? []), + ...(query.keywords ?? []), + ]); + if (tokens.length === 0) return null; + return tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(' OR '); +} + +function getMemoryRowsForScope( + scopeKind: MemoryScopeKind, + scopeKey: string, +): MemoryDatabaseRow[] { + return db + .prepare( + `SELECT * + FROM memories + WHERE scope_kind = ? + AND scope_key = ? + AND archived_at IS NULL + ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC`, + ) + .all(scopeKind, scopeKey) as MemoryDatabaseRow[]; +} + +function queryFtsRowOrder(query: RecallMemoryQuery): Map { + const ftsQuery = buildMemoryFtsQuery(query); + if (!ftsQuery) return new Map(); + try { + const rows = db + .prepare( + `SELECT memories.id AS id + FROM memories_fts + JOIN memories ON memories.id = memories_fts.rowid + WHERE memories_fts MATCH ? + AND memories.scope_kind = ? + AND memories.scope_key = ? + AND memories.archived_at IS NULL + ORDER BY bm25(memories_fts), memories.created_at DESC + LIMIT ?`, + ) + .all( + ftsQuery, + query.scopeKind, + query.scopeKey, + Math.max(25, (query.limit ?? 10) * 8), + ) as Array<{ id: number }>; + return new Map(rows.map((row, index) => [row.id, rows.length - index])); + } catch (error) { + logger.warn( + { query, error }, + 'Memory FTS query failed; falling back to scope-only recall', + ); + return new Map(); + } +} + +export function touchMemories(ids: number[]): void { + const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; + if (uniqueIds.length === 0) return; + const now = new Date().toISOString(); + const stmt = db.prepare( + `UPDATE memories + SET last_used_at = ? + WHERE id = ?`, + ); + const tx = db.transaction(() => { + for (const id of uniqueIds) stmt.run(now, id); + }); + tx(); +} + +export function archiveMemory(id: number): void { + db.prepare( + `UPDATE memories + SET archived_at = COALESCE(archived_at, ?) + WHERE id = ?`, + ).run(new Date().toISOString(), id); +} + +function buildCompactMemoryExpiryCutoff(nowIso: string): string { + return new Date( + new Date(nowIso).getTime() - COMPACT_MEMORY_TTL_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); +} + +export function expireStaleMemories(args?: { + scopeKind?: MemoryScopeKind; + scopeKey?: string; + now?: string; +}): number { + const nowIso = args?.now ?? new Date().toISOString(); + const cutoff = buildCompactMemoryExpiryCutoff(nowIso); + const scopeClause = + args?.scopeKind && args?.scopeKey + ? 'AND scope_kind = ? AND scope_key = ?' + : ''; + const stmt = db.prepare( + `UPDATE memories + SET archived_at = COALESCE(archived_at, ?) + WHERE archived_at IS NULL + AND source_kind = 'compact' + AND COALESCE(last_used_at, created_at) < ? + ${scopeClause}`, + ); + const result = ( + args?.scopeKind && args?.scopeKey + ? stmt.run(nowIso, cutoff, args.scopeKind, args.scopeKey) + : stmt.run(nowIso, cutoff) + ) as { changes?: number }; + return result.changes ?? 0; +} + +export function enforceMemoryBounds( + scopeKind: MemoryScopeKind, + scopeKey: string, +): void { + const limit = MEMORY_SCOPE_LIMITS[scopeKind]; + const rows = db + .prepare( + `SELECT id + FROM memories + WHERE scope_kind = ? + AND scope_key = ? + AND archived_at IS NULL + ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC + LIMIT -1 OFFSET ?`, + ) + .all(scopeKind, scopeKey, limit) as Array<{ id: number }>; + if (rows.length === 0) return; + const now = new Date().toISOString(); + const stmt = db.prepare( + `UPDATE memories + SET archived_at = ? + WHERE id = ?`, + ); + const tx = db.transaction(() => { + for (const row of rows) stmt.run(now, row.id); + }); + tx(); +} + +export function rememberMemory(input: { + scopeKind: MemoryScopeKind; + scopeKey: string; + content: string; + keywords?: string[]; + memoryKind?: string | null; + sourceKind: MemorySourceKind; + sourceRef?: string | null; +}): number { + const normalizedContent = input.content.trim(); + if (!normalizedContent) { + throw new Error('Memory content cannot be empty'); + } + const normalizedKeywords = normalizeMemoryKeywords(input.keywords); + const createdAt = new Date().toISOString(); + db.prepare( + `INSERT INTO memories ( + scope_kind, + scope_key, + content, + keywords_json, + memory_kind, + source_kind, + source_ref, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.scopeKind, + input.scopeKey, + normalizedContent, + JSON.stringify(normalizedKeywords), + input.memoryKind ?? null, + input.sourceKind, + input.sourceRef ?? null, + createdAt, + ); + const row = db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }; + expireStaleMemories({ + scopeKind: input.scopeKind, + scopeKey: input.scopeKey, + }); + enforceMemoryBounds(input.scopeKind, input.scopeKey); + return row.id; +} + +export function recallMemories(query: RecallMemoryQuery): MemoryRecord[] { + const limit = Math.max(1, query.limit ?? 6); + expireStaleMemories({ + scopeKind: query.scopeKind, + scopeKey: query.scopeKey, + }); + const rows = getMemoryRowsForScope(query.scopeKind, query.scopeKey); + if (rows.length === 0) return []; + + const exactKeywords = new Set(normalizeMemoryKeywords(query.keywords)); + const ftsOrder = queryFtsRowOrder(query); + const useQueryScoring = exactKeywords.size > 0 || Boolean(query.text?.trim()); + + const scored = rows + .map((row, index) => { + const keywords = parseMemoryKeywords(row.keywords_json); + const exactMatches = keywords.filter((keyword) => exactKeywords.has(keyword)) + .length; + const ftsScore = ftsOrder.get(row.id) ?? 0; + const recencyScore = rows.length - index; + return { + row, + matched: exactMatches > 0 || ftsScore > 0, + score: exactMatches * 100 + ftsScore * 10 + recencyScore, + }; + }) + .filter((entry) => (useQueryScoring ? entry.matched : true)) + .sort((a, b) => b.score - a.score || b.row.id - a.row.id) + .slice(0, limit); + + const memories = scored.map((entry) => mapMemoryRow(entry.row)); + touchMemories(memories.map((memory) => memory.id)); + return memories; +} + /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. @@ -3139,6 +3527,7 @@ export function createServiceHandoff(input: { start_seq?: number | null; end_seq?: number | null; reason?: string | null; + intended_role?: PairedRoomRole | null; }): ServiceHandoff { db.prepare( `INSERT INTO service_handoffs ( @@ -3150,8 +3539,9 @@ export function createServiceHandoff(input: { prompt, start_seq, end_seq, - reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + reason, + intended_role + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.chat_jid, input.group_folder, @@ -3162,6 +3552,7 @@ export function createServiceHandoff(input: { input.start_seq ?? null, input.end_seq ?? null, input.reason ?? null, + input.intended_role ?? null, ); const lastId = ( diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 3b39ec3..d0eeecc 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -7,6 +7,7 @@ import { assignRoom, createTask, getAllTasks, + recallMemories, getRegisteredAgentTypesForJid, getRegisteredGroup, getStoredRoomSettings, @@ -196,6 +197,56 @@ describe('schedule_task authorization', () => { }); }); +describe('persist_memory authorization', () => { + it('allows a room runtime to persist memory for its own room scope', async () => { + await processTaskIpc( + { + type: 'persist_memory', + scopeKind: 'room', + scopeKey: 'room:other-group', + content: 'compact summary', + keywords: ['room:other-group'], + source_kind: 'compact', + source_ref: 'compact:test', + }, + 'other-group', + false, + deps, + ); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:other-group', + limit: 10, + }); + expect(recalled).toHaveLength(1); + expect(recalled[0].content).toBe('compact summary'); + }); + + it('blocks persist_memory when scopeKey does not match the source group', async () => { + await processTaskIpc( + { + type: 'persist_memory', + scopeKind: 'room', + scopeKey: 'room:third-group', + content: 'should be denied', + keywords: ['room:third-group'], + source_kind: 'compact', + }, + 'other-group', + false, + deps, + ); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:third-group', + limit: 10, + }); + expect(recalled).toHaveLength(0); + }); +}); + // --- pause_task authorization --- describe('pause_task authorization', () => { diff --git a/src/ipc.ts b/src/ipc.ts index 829dd09..06528f8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -12,6 +12,7 @@ import { deleteTask, findDuplicateCiWatcher, getTaskById, + rememberMemory, updateTask, } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; @@ -303,6 +304,13 @@ export async function processTaskIpc( owner_agent_type?: AgentType; isMain?: boolean; workDir?: string; + scopeKind?: string; + scopeKey?: string; + content?: string; + keywords?: string[]; + memory_kind?: string | null; + source_kind?: string; + source_ref?: string | null; }, sourceGroup: string, // Verified identity from IPC directory isMain: boolean, // Verified from directory path @@ -657,6 +665,74 @@ export async function processTaskIpc( } break; + case 'persist_memory': { + if ( + data.scopeKind !== 'room' || + typeof data.scopeKey !== 'string' || + typeof data.content !== 'string' + ) { + logger.warn( + { sourceGroup, data }, + 'Invalid persist_memory request - missing required fields', + ); + break; + } + + const expectedScopeKey = `room:${sourceGroup}`; + if (data.scopeKey !== expectedScopeKey) { + logger.warn( + { sourceGroup, scopeKey: data.scopeKey, expectedScopeKey }, + 'Unauthorized persist_memory attempt blocked', + ); + break; + } + + if ( + data.source_kind !== undefined && + data.source_kind !== 'compact' && + data.source_kind !== 'explicit' && + data.source_kind !== 'import' && + data.source_kind !== 'system' + ) { + logger.warn( + { sourceGroup, sourceKind: data.source_kind }, + 'Invalid persist_memory request - unknown source_kind', + ); + break; + } + + if (Array.isArray(data.keywords) && !data.keywords.every((v) => typeof v === 'string')) { + logger.warn( + { sourceGroup, keywords: data.keywords }, + 'Invalid persist_memory request - keywords must be strings', + ); + break; + } + + rememberMemory({ + scopeKind: 'room', + scopeKey: data.scopeKey, + content: data.content, + keywords: data.keywords, + memoryKind: + typeof data.memory_kind === 'string' ? data.memory_kind : null, + sourceKind: + (data.source_kind as 'compact' | 'explicit' | 'import' | 'system' | undefined) ?? + 'compact', + sourceRef: + typeof data.source_ref === 'string' ? data.source_ref : null, + }); + logger.info( + { + sourceGroup, + scopeKey: data.scopeKey, + sourceKind: data.source_kind ?? 'compact', + }, + 'Memory persisted via IPC', + ); + break; + } + default: logger.warn({ type: data.type }, 'Unknown IPC task type'); } diff --git a/src/memento-client.test.ts b/src/memento-client.test.ts deleted file mode 100644 index 00c0212..0000000 --- a/src/memento-client.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - buildRoomMemoryKey, - formatRoomMemoryBriefing, -} from './memento-client.js'; - -describe('memento-client helpers', () => { - it('builds a stable room memory key from the group folder', () => { - expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw'); - }); - - it('formats recalled fragments into a compact session briefing', () => { - const briefing = formatRoomMemoryBriefing('room:ejclaw', [ - { - id: 'frag-1', - content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', - type: 'decision', - topic: 'room-memory', - }, - { - id: 'frag-2', - content: '자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.', - type: 'fact', - }, - ]); - - expect(briefing).toContain('## Shared Room Memory'); - expect(briefing).toContain('room:ejclaw'); - expect(briefing).toContain( - '[decision / room-memory] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', - ); - expect(briefing).toContain( - '[fact] 자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.', - ); - }); - - it('trims overly long briefings to the configured max length', () => { - const briefing = formatRoomMemoryBriefing( - 'room:ejclaw', - [ - { - id: 'frag-1', - content: 'a'.repeat(300), - type: 'fact', - }, - ], - 120, - ); - - expect(briefing).toBeDefined(); - expect(briefing!.length).toBeLessThanOrEqual(120); - expect(briefing!.endsWith('…')).toBe(true); - }); -}); diff --git a/src/memento-client.ts b/src/memento-client.ts deleted file mode 100644 index 5c696c7..0000000 --- a/src/memento-client.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; - -import { getEnv } from './env.js'; -import { logger } from './logger.js'; - -const DEFAULT_TIMEOUT_MS = 4_000; -const DEFAULT_PAGE_SIZE = 6; -const DEFAULT_TOKEN_BUDGET = 1_200; -const DEFAULT_MAX_BRIEFING_CHARS = 2_000; - -interface MementoConfig { - sseUrl: string; - accessKey: string; -} - -interface MementoContentBlock { - type?: string; - text?: string; -} - -interface MementoCallToolResult { - content?: MementoContentBlock[]; - isError?: boolean; -} - -interface MementoRecallFragment { - id: string; - content: string; - topic?: string; - type?: string; - importance?: number; -} - -interface MementoRecallResponse { - success?: boolean; - fragments?: MementoRecallFragment[]; -} - -let cachedConfig: MementoConfig | null | undefined; - -function getMementoConfig(): MementoConfig | null { - if (cachedConfig !== undefined) return cachedConfig; - - const sseUrl = getEnv('MEMENTO_MCP_SSE_URL'); - const accessKey = getEnv('MEMENTO_ACCESS_KEY'); - - cachedConfig = - sseUrl && accessKey - ? { - sseUrl, - accessKey, - } - : null; - - return cachedConfig; -} - -function withTimeout( - promise: Promise, - timeoutMs: number, - label: string, -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - -function extractToolText(result: MementoCallToolResult): string | null { - if (!Array.isArray(result.content)) return null; - - const text = result.content - .filter( - (block): block is MementoContentBlock & { text: string } => - block.type === 'text' && typeof block.text === 'string', - ) - .map((block) => block.text) - .join('\n') - .trim(); - - return text || null; -} - -function parseToolJson( - result: MementoCallToolResult, - toolName: string, -): T | null { - const text = extractToolText(result); - if (!text) return null; - - try { - return JSON.parse(text) as T; - } catch (error) { - logger.warn( - { toolName, error, text }, - 'Failed to parse Memento tool response JSON', - ); - return null; - } -} - -async function callMementoTool( - name: string, - args: Record, - timeoutMs = DEFAULT_TIMEOUT_MS, -): Promise { - const config = getMementoConfig(); - if (!config) return null; - - const transport = new SSEClientTransport(new URL(config.sseUrl), { - requestInit: { - headers: { - Authorization: `Bearer ${config.accessKey}`, - }, - }, - }); - const client = new Client({ name: 'ejclaw-host', version: '1.0.0' }); - - try { - await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`); - const result = await withTimeout( - client.callTool({ - name, - arguments: args, - }) as Promise, - timeoutMs, - `${name}/call`, - ); - - if (result.isError) { - logger.warn({ toolName: name, args }, 'Memento tool returned an error'); - return null; - } - - return parseToolJson(result, name); - } catch (error) { - logger.warn({ toolName: name, args, error }, 'Memento tool call failed'); - return null; - } finally { - await client.close().catch(() => {}); - } -} - -function trimToMaxChars(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…'; -} - -export function buildRoomMemoryKey(groupFolder: string): string { - return `room:${groupFolder}`; -} - -export function formatRoomMemoryBriefing( - roomKey: string, - fragments: MementoRecallFragment[], - maxChars = DEFAULT_MAX_BRIEFING_CHARS, -): string | undefined { - if (fragments.length === 0) return undefined; - - const lines = fragments - .map((fragment) => { - const meta = [fragment.type, fragment.topic].filter(Boolean).join(' / '); - const prefix = meta ? `- [${meta}] ` : '- '; - return `${prefix}${fragment.content.trim()}`; - }) - .filter(Boolean); - - if (lines.length === 0) return undefined; - - const text = [ - '## Shared Room Memory', - `Room key: \`${roomKey}\``, - 'Treat this as background context for a fresh session start. The current conversation always takes precedence.', - ...lines, - ].join('\n'); - - return trimToMaxChars(text, maxChars); -} - -export async function buildRoomMemoryBriefing(args: { - groupFolder: string; - groupName: string; - timeoutMs?: number; - maxChars?: number; -}): Promise { - const roomKey = buildRoomMemoryKey(args.groupFolder); - const recallResponse = await callMementoTool( - 'recall', - { - text: `shared room memory for ${args.groupName} (${roomKey})`, - keywords: [roomKey], - pageSize: DEFAULT_PAGE_SIZE, - tokenBudget: DEFAULT_TOKEN_BUDGET, - includeLinks: false, - excludeSeen: false, - }, - args.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ); - - if (!recallResponse?.success || !recallResponse.fragments?.length) { - return undefined; - } - - return formatRoomMemoryBriefing( - roomKey, - recallResponse.fragments, - args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS, - ); -} diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 127034c..89ec15f 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -142,7 +142,7 @@ vi.mock('./codex-token-rotation.js', () => ({ markCodexTokenHealthy: vi.fn(), })); -vi.mock('./memento-client.js', () => ({ +vi.mock('./sqlite-memory-store.js', () => ({ buildRoomMemoryBriefing: vi.fn(), })); @@ -155,7 +155,7 @@ import * as agentRunner from './agent-runner.js'; import type { AgentOutput } from './agent-runner.js'; import * as codexTokenRotation from './codex-token-rotation.js'; import * as db from './db.js'; -import { buildRoomMemoryBriefing } from './memento-client.js'; +import { buildRoomMemoryBriefing } from './sqlite-memory-store.js'; import { runAgentForGroup } from './message-agent-executor.js'; import * as pairedExecutionContext from './paired-execution-context.js'; import * as sessionRecovery from './session-recovery.js'; @@ -397,6 +397,123 @@ describe('runAgentForGroup room memory', () => { ); }); + it('honors a forced reviewer role even when the paired task status is active', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-active', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + + await runAgentForGroup(makeDeps(), { + group, + prompt: 'please retry review', + chatJid: 'group@test', + runId: 'run-forced-reviewer', + forcedRole: 'reviewer', + }); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + group, + expect.objectContaining({ + roomRoleContext: { + serviceId: 'claude', + role: 'reviewer', + ownerServiceId: 'codex-main', + reviewerServiceId: 'claude', + failoverOwner: false, + }, + }), + expect.any(Function), + undefined, + undefined, + ); + }); + + it('honors a forced agent type for reviewer failover handoffs', async () => { + const group: RegisteredGroup = { + ...makeGroup(), + agentType: 'codex', + folder: 'test-group', + }; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-reviewer-failover', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + + await runAgentForGroup( + { + ...makeDeps(), + getSessions: () => ({ 'test-group:reviewer': 'claude-review-session' }), + }, + { + group, + prompt: 'please retry review with codex', + chatJid: 'group@test', + runId: 'run-forced-reviewer-codex', + forcedRole: 'reviewer', + forcedAgentType: 'codex', + }, + ); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'codex', + }), + expect.objectContaining({ + sessionId: undefined, + }), + expect.any(Function), + undefined, + undefined, + ); + }); + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ @@ -1005,6 +1122,7 @@ describe('runAgentForGroup Claude rotation', () => { start_seq: 10, end_seq: 12, reason: 'claude-usage-exhausted', + intended_role: 'owner', }), ); }); @@ -1207,6 +1325,7 @@ describe('runAgentForGroup Claude rotation', () => { target_service_id: 'codex-review', target_agent_type: 'codex', reason: 'claude-org-access-denied', + intended_role: 'owner', }), ); }); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index e865cb5..d7ba990 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -22,7 +22,7 @@ import { } from './db.js'; import { GroupQueue } from './group-queue.js'; import { createScopedLogger } from './logger.js'; -import { buildRoomMemoryBriefing } from './memento-client.js'; +import { buildRoomMemoryBriefing } from './sqlite-memory-store.js'; import { completePairedExecutionContext, preparePairedExecutionContext, @@ -44,7 +44,9 @@ import { shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; import { + ARBITER_AGENT_TYPE, CODEX_REVIEW_SERVICE_ID, + REVIEWER_AGENT_TYPE, SERVICE_SESSION_SCOPE, TIMEZONE, isClaudeService, @@ -69,7 +71,7 @@ import { } from './codex-token-rotation.js'; import type { CodexRotationReason } from './agent-error-detection.js'; import { getTokenCount } from './token-rotation.js'; -import type { RegisteredGroup } from './types.js'; +import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js'; // ── Main executor ───────────────────────────────────────────────── @@ -92,6 +94,8 @@ export async function runAgentForGroup( startSeq?: number | null; endSeq?: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; onOutput?: (output: AgentOutput) => Promise; }, ): Promise<'success' | 'error'> { @@ -107,7 +111,13 @@ export async function runAgentForGroup( const pairedTask = currentLease.reviewer_service_id ? getLatestOpenPairedTaskForChat(chatJid) : null; - const activeRole = resolveActiveRole(pairedTask?.status); + const inferredRole = resolveActiveRole(pairedTask?.status); + const canHonorForcedRole = Boolean( + args.forcedRole === 'owner' || + (args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) || + (args.forcedRole === 'arbiter' && currentLease.arbiter_service_id), + ); + const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; const effectiveServiceId = activeRole === 'arbiter' ? currentLease.arbiter_service_id! @@ -121,10 +131,11 @@ export async function runAgentForGroup( group.agentType, ); - const effectiveAgentType = resolveEffectiveAgentType( + const configuredAgentType = resolveEffectiveAgentType( activeRole, group.agentType, ); + const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; const effectiveGroup = effectiveAgentType !== roleAgentPlan.ownerAgentType ? { ...group, agentType: effectiveAgentType } @@ -137,7 +148,9 @@ export async function runAgentForGroup( ); // Arbiter always starts fresh — never resume a previous session const sessionId = - activeRole === 'arbiter' ? undefined : sessions[sessionFolder]; + activeRole === 'arbiter' || args.forcedAgentType + ? undefined + : sessions[sessionFolder]; const memoryBriefing = sessionId ? undefined : await buildRoomMemoryBriefing({ @@ -203,6 +216,29 @@ export async function runAgentForGroup( role: activeRole, serviceId: effectiveServiceId, }); + log.info( + { + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType ?? null, + inferredRole, + canHonorForcedRole, + pairedTaskId: pairedTask?.id, + pairedTaskStatus: pairedTask?.status, + configuredAgentType, + effectiveServiceId, + effectiveAgentType, + groupAgentType: group.agentType, + configuredReviewerAgentType: REVIEWER_AGENT_TYPE, + configuredArbiterAgentType: ARBITER_AGENT_TYPE, + reviewerServiceId: currentLease.reviewer_service_id, + arbiterServiceId: currentLease.arbiter_service_id, + reviewerMode, + arbiterMode, + sessionFolder, + resumedSession: sessionId ?? null, + }, + 'Resolved execution target for agent turn', + ); // ── MoA prompt enrichment ───────────────────────────────────── // When MoA is enabled and we're in arbiter mode, query external API @@ -293,6 +329,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `arbiter-claude-${reason}`, + intended_role: 'arbiter', }); log.warn( { reason }, @@ -314,6 +351,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `reviewer-claude-${reason}`, + intended_role: 'reviewer', }); log.warn( { reason }, @@ -333,6 +371,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `claude-${reason}`, + intended_role: activeRole, }); log.warn( { reason }, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index bd17e04..fb3188f 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -183,7 +183,10 @@ vi.mock('./session-commands.js', () => ({ import * as agentRunner from './agent-runner.js'; import * as db from './db.js'; import { resolveGroupIpcPath } from './group-folder.js'; -import { createMessageRuntime } from './message-runtime.js'; +import { + createMessageRuntime, + resolveHandoffRoleOverride, +} from './message-runtime.js'; import * as config from './config.js'; import * as serviceRouting from './service-routing.js'; import type { Channel, RegisteredGroup } from './types.js'; @@ -223,6 +226,33 @@ describe('createMessageRuntime', () => { vi.mocked(config.isReviewService).mockReturnValue(false); }); + it('prefers intended_role over reason prefixes for handoff role resolution', () => { + expect( + resolveHandoffRoleOverride({ + intended_role: 'reviewer', + reason: 'claude-429', + }), + ).toBe('reviewer'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'arbiter-claude-429', + }), + ).toBe('arbiter'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'reviewer-claude-usage-exhausted', + }), + ).toBe('reviewer'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'claude-usage-exhausted', + }), + ).toBeUndefined(); + }); + it('ignores generic failure bot messages in paired rooms', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index d76595d..69aab4a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -58,6 +58,8 @@ import { import { Channel, NewMessage, + type AgentType, + type PairedRoomRole, RegisteredGroup, type PairedTask, type PairedTurnOutput, @@ -94,6 +96,21 @@ export function isDuplicateOfLastBotFinal( return normalizedLast === normalizedCurrent && normalizedLast.length > 0; } +export function resolveHandoffRoleOverride( + handoff: Pick, +): PairedRoomRole | undefined { + if (handoff.intended_role) { + return handoff.intended_role; + } + if (handoff.reason?.startsWith('reviewer-')) { + return 'reviewer'; + } + if (handoff.reason?.startsWith('arbiter-')) { + return 'arbiter'; + } + return undefined; +} + export interface MessageRuntimeDeps { assistantName: string; idleTimeout: number; @@ -415,6 +432,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq?: number | null; endSeq?: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }, ): Promise<'success' | 'error'> => runAgentForGroup( @@ -434,6 +453,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq: options?.startSeq, endSeq: options?.endSeq, hasHumanMessage: options?.hasHumanMessage, + forcedRole: options?.forcedRole, + forcedAgentType: options?.forcedAgentType, onOutput, }, ); @@ -447,6 +468,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq: number | null; endSeq: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }): Promise<{ outputStatus: 'success' | 'error'; deliverySucceeded: boolean; @@ -460,7 +483,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }> => { const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args; const isClaudeCodeAgent = - (group.agentType || 'claude-code') === 'claude-code'; + (args.forcedAgentType ?? group.agentType ?? 'claude-code') === + 'claude-code'; const turnController = new MessageTurnController({ chatJid, @@ -478,7 +502,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const workItem = createProducedWorkItem({ group_folder: group.folder, chat_jid: chatJid, - agent_type: group.agentType || 'claude-code', + agent_type: + args.forcedAgentType ?? group.agentType ?? 'claude-code', start_seq: startSeq, end_seq: endSeq, result_payload: text, @@ -503,7 +528,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chatJid, runId, (result) => turnController.handleOutput(result), - { startSeq, endSeq, hasHumanMessage: args.hasHumanMessage }, + { + startSeq, + endSeq, + hasHumanMessage: args.hasHumanMessage, + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType, + }, ); const { deliverySucceeded, visiblePhase } = @@ -574,20 +605,35 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // Reviewer/arbiter failover handoffs should run via the appropriate // channel so they execute in the correct role mode. - const isReviewerHandoff = handoff.reason?.startsWith('reviewer-'); - const isArbiterHandoff = handoff.reason?.startsWith('arbiter-'); + const handoffRole = resolveHandoffRoleOverride(handoff); let handoffChannel = channel; - if (isReviewerHandoff) { + if (handoffRole === 'reviewer') { const revChName = - REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; + handoff.target_agent_type === 'claude-code' + ? 'discord' + : 'discord-review'; handoffChannel = findChannelByName(deps.channels, revChName) || channel; - } else if (isArbiterHandoff) { + } else if (handoffRole === 'arbiter') { handoffChannel = findChannelByName(deps.channels, 'discord-review') || channel; } const runId = `handoff-${handoff.id}`; try { + logger.info( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + runId, + handoffRole, + targetServiceId: handoff.target_service_id, + targetAgentType: handoff.target_agent_type, + reason: handoff.reason, + intendedRole: handoff.intended_role ?? null, + channelName: handoffChannel.name, + }, + 'Dispatching claimed service handoff', + ); const result = await executeTurn({ group, prompt: handoff.prompt, @@ -596,6 +642,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel: handoffChannel, startSeq: handoff.start_seq, endSeq: handoff.end_seq, + forcedRole: handoffRole, + forcedAgentType: handoff.target_agent_type, }); if (!result.deliverySucceeded) { diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index d82475f..004090d 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -445,6 +445,51 @@ describe('paired execution context', () => { ); }); + it('requests arbiter instead of re-reviewing when repeated DONE finalize loops exceed the threshold', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + + const repoDir = createCanonicalRepoWithCommit('reviewed'); + const approvedSourceRef = resolveTreeRef(repoDir); + fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed again\n'); + execFileSync('git', ['add', 'README.md'], { + cwd: repoDir, + stdio: 'ignore', + }); + execFileSync('git', ['commit', '-m', 'code change'], { + cwd: repoDir, + stdio: 'ignore', + }); + + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'merge_ready', + source_ref: approvedSourceRef, + round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD, + }), + ); + vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => + role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'DONE', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + arbiter_requested_at: expect.any(String), + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + it.each(['BLOCKED', 'NEEDS_CONTEXT'])( 'escalates immediately when owner reports %s during finalize without arbiter', (summary) => { diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index d476457..5bcc6d1 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -525,6 +525,30 @@ export function completePairedExecutionContext(args: { : null; if (hasNewChanges === true) { + if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) { + if (isArbiterEnabled()) { + updatePairedTask(taskId, { + status: 'arbiter_requested', + arbiter_requested_at: now, + updated_at: now, + }); + logger.info( + { taskId, roundTrips: task.round_trip_count, hasNewChanges }, + 'Owner finalize DONE loop detected — requesting arbiter', + ); + } else { + updatePairedTask(taskId, { + status: 'completed', + completion_reason: 'escalated', + updated_at: now, + }); + logger.info( + { taskId, roundTrips: task.round_trip_count, hasNewChanges }, + 'Owner finalize DONE loop detected — escalating to user', + ); + } + return; + } // Owner made changes after approval → needs re-review logger.info( { diff --git a/src/sqlite-memory-store.test.ts b/src/sqlite-memory-store.test.ts new file mode 100644 index 0000000..200e8fb --- /dev/null +++ b/src/sqlite-memory-store.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { _initTestDatabase, rememberMemory } from './db.js'; +import { + buildRoomMemoryBriefing, + buildRoomMemoryKey, + formatRoomMemoryBriefing, +} from './sqlite-memory-store.js'; + +beforeEach(() => { + _initTestDatabase(); +}); + +describe('sqlite-memory-store helpers', () => { + it('builds a stable room memory key from the group folder', () => { + expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw'); + }); + + it('formats recalled memories into a compact session briefing', () => { + const briefing = formatRoomMemoryBriefing('room:ejclaw', [ + { + id: 1, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', + keywords: ['room:ejclaw'], + memoryKind: 'decision', + sourceKind: 'explicit', + sourceRef: 'msg:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + { + id: 2, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.', + keywords: ['room:ejclaw'], + memoryKind: null, + sourceKind: 'compact', + sourceRef: 'compact:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + ]); + + expect(briefing).toContain('## Shared Room Memory'); + expect(briefing).toContain('room:ejclaw'); + expect(briefing).toContain( + '[decision] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', + ); + expect(briefing).toContain( + '- 자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.', + ); + }); + + it('builds a room briefing from stored SQLite memories', async () => { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '방 메모리는 새 세션 시작 시에만 주입한다.', + keywords: ['room:ejclaw', 'session-start'], + sourceKind: 'compact', + sourceRef: 'compact:test', + }); + + const briefing = await buildRoomMemoryBriefing({ + groupFolder: 'ejclaw', + groupName: 'EJClaw', + }); + + expect(briefing).toContain('## Shared Room Memory'); + expect(briefing).toContain('방 메모리는 새 세션 시작 시에만 주입한다.'); + }); + + it('trims overly long briefings to the configured max length', () => { + const briefing = formatRoomMemoryBriefing( + 'room:ejclaw', + [ + { + id: 1, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: 'a'.repeat(300), + keywords: [], + memoryKind: null, + sourceKind: 'compact', + sourceRef: 'compact:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + ], + 120, + ); + + expect(briefing).toBeDefined(); + expect(briefing!.length).toBeLessThanOrEqual(120); + expect(briefing!.endsWith('…')).toBe(true); + }); +}); diff --git a/src/sqlite-memory-store.ts b/src/sqlite-memory-store.ts new file mode 100644 index 0000000..74cbaf2 --- /dev/null +++ b/src/sqlite-memory-store.ts @@ -0,0 +1,62 @@ +import { type MemoryRecord, recallMemories } from './db.js'; + +const DEFAULT_PAGE_SIZE = 6; +const DEFAULT_MAX_BRIEFING_CHARS = 2_000; + +function trimToMaxChars(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…'; +} + +export function buildRoomMemoryKey(groupFolder: string): string { + return `room:${groupFolder}`; +} + +export function formatRoomMemoryBriefing( + roomKey: string, + memories: MemoryRecord[], + maxChars = DEFAULT_MAX_BRIEFING_CHARS, +): string | undefined { + if (memories.length === 0) return undefined; + + const lines = memories + .map((memory) => { + const content = memory.content.trim(); + if (!content) return null; + const prefix = memory.memoryKind ? `- [${memory.memoryKind}] ` : '- '; + return `${prefix}${content}`; + }) + .filter((line): line is string => Boolean(line)); + + if (lines.length === 0) return undefined; + + const text = [ + '## Shared Room Memory', + `Room key: \`${roomKey}\``, + 'Treat this as background context for a fresh session start. The current conversation always takes precedence.', + ...lines, + ].join('\n'); + + return trimToMaxChars(text, maxChars); +} + +export async function buildRoomMemoryBriefing(args: { + groupFolder: string; + groupName?: string; + maxChars?: number; + limit?: number; +}): Promise { + void args.groupName; + const roomKey = buildRoomMemoryKey(args.groupFolder); + const memories = recallMemories({ + scopeKind: 'room', + scopeKey: roomKey, + limit: args.limit ?? DEFAULT_PAGE_SIZE, + }); + + return formatRoomMemoryBriefing( + roomKey, + memories, + args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS, + ); +} From 73156bf3751916427e639e857eb142f2319c222c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 04:18:15 +0900 Subject: [PATCH 05/24] fix: only count final-phase output as visible for handoff decisions Progress messages (phase: 'progress') were setting sawVisibleOutput=true, which blocked failover handoffs when Claude usage was exhausted. Now only final-phase output counts as visible, allowing reviewer handoff to codex even after progress messages were shown. --- src/message-agent-executor.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index d7ba990..4c66fce 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -114,8 +114,8 @@ export async function runAgentForGroup( const inferredRole = resolveActiveRole(pairedTask?.status); const canHonorForcedRole = Boolean( args.forcedRole === 'owner' || - (args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) || - (args.forcedRole === 'arbiter' && currentLease.arbiter_service_id), + (args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) || + (args.forcedRole === 'arbiter' && currentLease.arbiter_service_id), ); const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; const effectiveServiceId = @@ -534,7 +534,16 @@ export async function runAgentForGroup( if (!evaluation.shouldForwardOutput) { return; } - if (typeof outputText === 'string' && outputText.length > 0) { + // Only count final-phase output as "visible" for handoff decisions. + // Progress messages (phase: 'progress') should not block failover + // handoffs — they don't represent meaningful completed work. + const isFinalPhase = + output.phase === undefined || output.phase === 'final'; + if ( + isFinalPhase && + typeof outputText === 'string' && + outputText.length > 0 + ) { streamedState = { ...evaluation.state, sawVisibleOutput: true, From 404cbd6b0421135f72b82aaf069355fbcc8e386c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 04:50:05 +0900 Subject: [PATCH 06/24] feat: support codex runner in reviewer container - Build both agent-runner and codex-runner into container image - Select runner path based on group.agentType at docker exec time - Update README to role-centric overview, generic MoA references - Add MoA reference opinions guidance to arbiter prompt --- README.md | 26 ++++++++++++++------------ container/Dockerfile | 20 +++++++++++++------- prompts/arbiter-paired-room.md | 9 +++++++++ src/container-runner.ts | 6 +++++- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index de13fa6..1302d9f 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ Tribunal arbiter system inspired by multi-agent consensus architectures. ## Overview -A single unified service (`ejclaw`) manages three Discord bots in one process: +A single unified service (`ejclaw`) runs a **Tribunal** of three roles: -- **Codex-main** (`@codex`) — Owner agent. Handles user requests, writes code. -- **Claude** (`@claude`) — Reviewer agent. Critically reviews owner's work, verifies design direction. -- **Codex-review** (`@codex-review`) — Arbiter agent. Summoned on-demand to break deadlocks between owner and reviewer. +| Role | Purpose | Default | +|------|---------|---------| +| **Owner** | Handles user requests, writes code | Codex (`@codex`) | +| **Reviewer** | Critically reviews owner's work, verifies design direction | Claude Code (`@claude`) | +| **Arbiter** | On-demand deadlock breaker between owner and reviewer | Codex (`@codex-review`) | -All agent types and models are independently configurable per role via `.env`. +Each role's agent type and model are independently configurable via `.env` (`OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, `ARBITER_AGENT_TYPE`, `*_MODEL`). Three Discord bots provide the identity layer — which bot speaks is determined by the active role, not hardcoded. ## Room Assignment Model @@ -60,22 +62,22 @@ User message ### Mixture of Agents (MoA) -When enabled, the arbiter collects opinions from external models (Kimi, GLM, etc.) before rendering its verdict: +When enabled, the arbiter collects opinions from configurable external reference models before rendering its verdict: ``` -Deadlock detected → MoA reference queries (Kimi + GLM, parallel) +Deadlock detected → MoA reference queries (parallel, configurable via MOA_REF_MODELS) → Opinions injected into arbiter's prompt - → SDK arbiter (subscription-based) aggregates all perspectives + → Arbiter aggregates all perspectives → Final verdict: PROCEED / REVISE / RESET / ESCALATE ``` -No extra SDK processes. External references use lightweight API calls (Anthropic-compatible). +No extra SDK processes. External references use lightweight OpenAI/Anthropic-compatible API calls. ## Features - **Tribunal 3-agent system** — Owner/reviewer/arbiter with on-demand deadlock resolution - **Discord-independent communication** — Agent-to-agent data flows directly via DB, Discord is display-only -- **Mixture of Agents** — External model opinions (Kimi, GLM) enrich arbiter verdicts +- **Mixture of Agents** — Configurable external reference models enrich arbiter verdicts - **Per-role model selection** — `OWNER_MODEL`, `REVIEWER_MODEL`, `ARBITER_MODEL` + effort + fallback toggle - **Container-isolated reviewer** — Persistent Docker container with read-only source mount - **Global failover** — Account-level Claude failure → all channels switch to codex, auto-recovers @@ -107,8 +109,8 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho ├──► Arbiter (on-demand, fresh session each time) │ │ │ ┌───┴─── MoA (if enabled) ───┐ - │ │ Kimi API ──► opinion │ - │ │ GLM API ──► opinion │ + │ │ Ref model A ──► opinion │ + │ │ Ref model B ──► opinion │ │ │ → injected into prompt │ │ └────────────────────────────┘ │ │ diff --git a/container/Dockerfile b/container/Dockerfile index 6ef6f21..e54ebea 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -52,18 +52,24 @@ RUN curl -fsSL https://bun.sh/install | bash # Create app directory WORKDIR /app -# Copy host's agent-runner (same code, same SDK version) -COPY runners/agent-runner/package*.json ./ -RUN bun install -COPY runners/agent-runner/ ./ -RUN bun run build +# Copy agent-runner (Claude Code) +COPY runners/agent-runner/package*.json ./agent/ +RUN bun install --cwd ./agent +COPY runners/agent-runner/ ./agent/ +RUN bun run --cwd ./agent build + +# Copy codex-runner +COPY runners/codex-runner/package*.json ./codex/ +RUN bun install --cwd ./codex +COPY runners/codex-runner/ ./codex/ +RUN bun run --cwd ./codex build # Create workspace directories RUN mkdir -p /workspace/project /workspace/group /workspace/global \ /workspace/ipc/messages /workspace/ipc/input -# Create entrypoint script -RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nbun /app/dist/index.js < /tmp/input.json\n' \ +# Entrypoint (fallback — persistent containers use docker exec with explicit runner path) +RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nbun /app/agent/dist/index.js < /tmp/input.json\n' \ > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) diff --git a/prompts/arbiter-paired-room.md b/prompts/arbiter-paired-room.md index d0803e0..48af0bd 100644 --- a/prompts/arbiter-paired-room.md +++ b/prompts/arbiter-paired-room.md @@ -22,6 +22,15 @@ You have been summoned because the owner and reviewer reached a deadlock after m - The situation cannot be resolved without user input, regardless of technical agreement - The same NEEDS_CONTEXT or BLOCKED is repeated after a prior PROCEED — this means your PROCEED did not resolve the issue +## MoA (Mixture of Agents) Reference Opinions + +You may receive reference opinions from external models appended to your prompt. When present: + +- **Cite them explicitly** in your verdict — e.g., "Reference model A agrees that...", "Reference model B raises a concern about..." +- **Cross-reference** their opinions against the owner/reviewer conversation and code evidence +- **Resolve conflicts** — if reference opinions disagree with each other or with owner/reviewer, state which view you adopt and why +- Do NOT blindly follow reference opinions — they are inputs to your judgment, not authorities + ## Rules - Base your verdict on evidence (code, test output, logs), not on who said what first diff --git a/src/container-runner.ts b/src/container-runner.ts index de25084..500c297 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -484,7 +484,11 @@ export async function runReviewerContainer(args: { } } } - execArgs.push(containerName, 'bun', '/app/dist/index.js'); + const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + const runnerPath = isCodexAgent + ? '/app/codex/dist/index.js' + : '/app/agent/dist/index.js'; + execArgs.push(containerName, 'bun', runnerPath); return new Promise((resolve) => { const proc = spawn(CONTAINER_RUNTIME_BIN, execArgs, { From 527ce08eee1157ada15d00bbb9cf950d08c738aa Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 04:54:07 +0900 Subject: [PATCH 07/24] docs: slim README to landing page format Remove internal architecture details, full .env examples, and room assignment model. Keep role overview, tribunal flow, key features, and quick start. --- README.md | 170 +++++++----------------------------------------------- 1 file changed, 22 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 1302d9f..a9f4df9 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,8 @@ Tribunal multi-agent AI assistant (Owner + Reviewer + Arbiter) over Discord with autonomous paired review and Mixture of Agents. -Originally derived from [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw), now independently maintained as EJClaw. -Prompt design inspired by [Q00/ouroboros](https://github.com/Q00/ouroboros) and [garrytan/gstack](https://github.com/garrytan/gstack), adapted for EJClaw's Discord and dual-service workflow. -Tribunal arbiter system inspired by multi-agent consensus architectures. +Originally derived from [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw). +Prompt design inspired by [Q00/ouroboros](https://github.com/Q00/ouroboros) and [garrytan/gstack](https://github.com/garrytan/gstack). ## Overview @@ -24,170 +23,45 @@ A single unified service (`ejclaw`) runs a **Tribunal** of three roles: Each role's agent type and model are independently configurable via `.env` (`OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, `ARBITER_AGENT_TYPE`, `*_MODEL`). Three Discord bots provide the identity layer — which bot speaks is determined by the active role, not hardcoded. -## Room Assignment Model - -Per-room routing now uses an explicit assignment model: - -- `room_settings` is the room-level source of truth (SSOT) -- Each room stores: - - `room_mode`: `single` or `tribunal` - - `owner_agent_type`: `codex` or `claude-code` -- Public room assignment uses `assign_room` -- Legacy `register_group` public interfaces were removed -- `registered_groups` remains as a materialized capability/read-model layer and legacy fallback, not the authoritative room configuration - -Operationally: - -- `single` → one owner bot -- `tribunal` → per-room owner + globally configured reviewer + optional arbiter - -This means tribunal is no longer inferred from “two bots registered on one room”; it is an explicit room setting. - -## Tribunal 3-Agent System +### Tribunal Flow ``` User message - → Owner responds (implementation, answer, etc.) - → Reviewer auto-triggered (critical review, design check) - → Verdict: - DONE → Owner finalizes → Task completed → @user ✅ - DONE_WITH_CONCERNS → Owner addresses feedback → loop - BLOCKED/NEEDS_CONTEXT - ├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE - └─ Arbiter disabled → Escalate to user → @user ⚠️ - → Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer) - → Deadlock (3+ round trips without progress) - → Arbiter summoned → binding verdict → loop resumes + → Owner responds + → Reviewer auto-triggered (critical review) + → DONE → Owner finalizes → @user ✅ + → Feedback → Owner addresses → loop + → BLOCKED → Arbiter (if enabled) or @user ⚠️ + → Deadlock (3+ rounds) → Arbiter summoned → binding verdict ``` ### Mixture of Agents (MoA) -When enabled, the arbiter collects opinions from configurable external reference models before rendering its verdict: - -``` -Deadlock detected → MoA reference queries (parallel, configurable via MOA_REF_MODELS) - → Opinions injected into arbiter's prompt - → Arbiter aggregates all perspectives - → Final verdict: PROCEED / REVISE / RESET / ESCALATE -``` - -No extra SDK processes. External references use lightweight OpenAI/Anthropic-compatible API calls. +When enabled, the arbiter collects opinions from configurable external reference models before rendering its verdict. No extra SDK processes — lightweight API calls only. ## Features - **Tribunal 3-agent system** — Owner/reviewer/arbiter with on-demand deadlock resolution -- **Discord-independent communication** — Agent-to-agent data flows directly via DB, Discord is display-only -- **Mixture of Agents** — Configurable external reference models enrich arbiter verdicts -- **Per-role model selection** — `OWNER_MODEL`, `REVIEWER_MODEL`, `ARBITER_MODEL` + effort + fallback toggle -- **Container-isolated reviewer** — Persistent Docker container with read-only source mount -- **Global failover** — Account-level Claude failure → all channels switch to codex, auto-recovers -- **Post-approval change detection** — Re-triggers review if owner modifies code after approval -- **Auto user notification** — @mention on task completion (✅ done, ⚠️ escalated) -- **Loop protection** — Deadlock threshold, merge_ready oscillation guard, arbiter re-invocation limit -- **Voice transcription** — Groq Whisper (primary) / OpenAI Whisper (fallback) +- **Per-role model selection** — `OWNER_MODEL`, `REVIEWER_MODEL`, `ARBITER_MODEL` + effort + fallback +- **Container-isolated reviewer** — Docker container with read-only source mount (supports both Claude and Codex runners) +- **Global failover** — Claude exhaustion → automatic codex fallback, auto-recovers +- **Mixture of Agents** — External reference models enrich arbiter verdicts - **Token rotation** — Multi-account Claude/Codex rotation on rate limits -- **Kimi usage dashboard** — Coding plan 5h/7d usage displayed alongside Claude/Codex -- **MCP integration** — Memento (persistent memory) + EJClaw host tools -- **Session persistence** — Separate sessions per role (owner/reviewer/arbiter) -- **Scheduled tasks** — Cron/interval/once via MCP tool +- **Voice transcription** — Groq/OpenAI Whisper +- **Session persistence** — Separate sessions per role - **Mid-turn steering** — Inject follow-up messages while agent is working -- **Bun runtime** — Native SQLite (bun:sqlite), fast startup, no native addon builds -## Architecture - -``` -Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process) - │ │ - │ ▼ (auto-trigger) - ├──► Reviewer (Docker container, :ro mount) - │ │ - │ Verdict routing - │ ├─ DONE → change detection → finalize or re-review - │ ├─ BLOCKED → Arbiter (if enabled) or User - │ └─ Feedback → Owner (loop) - │ - ├──► Arbiter (on-demand, fresh session each time) - │ │ - │ ┌───┴─── MoA (if enabled) ───┐ - │ │ Ref model A ──► opinion │ - │ │ Ref model B ──► opinion │ - │ │ → injected into prompt │ - │ └────────────────────────────┘ - │ │ - │ PROCEED/REVISE/RESET/ESCALATE - │ - IPC polling ◄── follow-up messages - │ - ┌────────── Router ──────────┐ - ▼ ▼ - paired_turn_outputs Discord (display only) - (agent ↔ agent data) (user observation, @mention) -``` - -## Setup - -### Prerequisites - -- Linux (Ubuntu 22.04+) or macOS -- [Bun](https://bun.sh/) 1.3+ -- Docker (required for reviewer container isolation) -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) -- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`) -- Discord bot tokens (3: Claude, Codex-main, Codex-review/Arbiter) - -### Install +## Quick Start ```bash git clone https://github.com/phj1081/EJClaw.git cd EJClaw bun install -bun run build:runners -bun run build -bun run build:container # Build reviewer Docker image -``` - -### Environment - -All configuration in a single `.env` file: - -```bash -# Discord bots (3 tokens for 3 bots) -DISCORD_BOT_TOKEN= # Claude bot -DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner) -DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (arbiter) - -# Agent types -OWNER_AGENT_TYPE=codex # codex | claude-code -REVIEWER_AGENT_TYPE=claude-code # claude-code | codex -ARBITER_AGENT_TYPE=codex # codex | claude-code (optional, enables 3rd agent) - -# Per-role model overrides -OWNER_MODEL=gpt-5.4 -REVIEWER_MODEL=claude-opus-4-6 -ARBITER_MODEL=gpt-5.4 - -# API keys -CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token -CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated for multi-account rotation -GROQ_API_KEY= # Voice transcription (Groq Whisper) - -# Mixture of Agents (MoA) -MOA_ENABLED=true -MOA_REF_MODELS=kimi,glm -MOA_KIMI_MODEL=kimi-k2.5 -MOA_KIMI_BASE_URL=https://api.kimi.com/coding -MOA_KIMI_API_KEY=sk-kimi-xxx -MOA_KIMI_API_FORMAT=anthropic -MOA_GLM_MODEL=glm-5.1 -MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic -MOA_GLM_API_KEY=xxx -MOA_GLM_API_FORMAT=anthropic -``` - -### Deploy - -```bash -bun run deploy +bun run build:runners # Build both runners +bun run build # Build main project +bun run build:container # Build reviewer Docker image +cp .env.example .env # Configure tokens and settings +bun run deploy # Or: bun run dev ``` ## Development From 14c8608f932d22627934e6bce11d8c17a4160896 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 04:56:06 +0900 Subject: [PATCH 08/24] docs: move architecture and configuration details to docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/architecture.md — data flow, room model, tribunal flow, container isolation, key files - docs/configuration.md — full .env reference, debugging paths - README links to docs/ --- README.md | 5 ++ docs/architecture.md | 106 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 83 +++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md diff --git a/README.md b/README.md index a9f4df9..31361e6 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ cp .env.example .env # Configure tokens and settings bun run deploy # Or: bun run dev ``` +## Documentation + +- [Architecture](docs/architecture.md) — Data flow, room model, container isolation, key files +- [Configuration](docs/configuration.md) — Full `.env` reference, debugging paths + ## Development ```bash diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a953b08 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,106 @@ +# Architecture + +## Service Stack + +Single unified service (`ejclaw`) manages all three Discord bots in one process: + +- `ejclaw.service` — Single unified process +- Discord bots: `DISCORD_BOT_TOKEN` (Claude), `DISCORD_CODEX_BOT_TOKEN` (Codex-main), `DISCORD_REVIEW_BOT_TOKEN` (Codex-review) +- Paired review: owner ↔ reviewer (agent types configurable per role) +- Reviewer fallback: Claude exhaustion → codex-review auto-handoff +- Shared dirs: `store/`, `groups/`, `data/` +- SQLite WAL mode + `busy_timeout=5000` for concurrent access + +## Data Flow + +``` +Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process) + │ │ + │ ▼ (auto-trigger) + ├──► Reviewer (Docker container, :ro mount) + │ │ + │ Verdict routing + │ ├─ DONE → change detection → finalize or re-review + │ ├─ BLOCKED → Arbiter (if enabled) or User + │ └─ Feedback → Owner (loop) + │ + ├──► Arbiter (on-demand, fresh session each time) + │ │ + │ ┌───┴─── MoA (if enabled) ───┐ + │ │ Ref model A ──► opinion │ + │ │ Ref model B ──► opinion │ + │ │ → injected into prompt │ + │ └────────────────────────────┘ + │ │ + │ PROCEED/REVISE/RESET/ESCALATE + │ + IPC polling ◄── follow-up messages + │ + ┌────────── Router ──────────┐ + ▼ ▼ + paired_turn_outputs Discord (display only) + (agent ↔ agent data) (user observation, @mention) +``` + +## Room Assignment Model + +Per-room routing uses an explicit assignment model: + +- `room_settings` is the room-level source of truth (SSOT) +- Each room stores: + - `room_mode`: `single` or `tribunal` + - `owner_agent_type`: `codex` or `claude-code` +- Public room assignment uses `assign_room` +- `registered_groups` remains as a materialized capability/read-model layer + +Operationally: + +- `single` → one owner bot +- `tribunal` → per-room owner + globally configured reviewer + optional arbiter + +Tribunal is no longer inferred from "two bots registered on one room"; it is an explicit room setting. + +## Tribunal 3-Agent System + +``` +User message + → Owner responds (implementation, answer, etc.) + → Reviewer auto-triggered (critical review, design check) + → Verdict: + DONE → Owner finalizes → Task completed → @user ✅ + DONE_WITH_CONCERNS → Owner addresses feedback → loop + BLOCKED/NEEDS_CONTEXT + ├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE + └─ Arbiter disabled → Escalate to user → @user ⚠️ + → Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer) + → Deadlock (3+ round trips without progress) + → Arbiter summoned → binding verdict → loop resumes +``` + +## Container Isolation + +Reviewer and arbiter run inside a Docker container with: + +- Read-only source mount (kernel-level write protection) +- Both Claude (agent-runner) and Codex (codex-runner) supported +- Runner selected at `docker exec` time based on agent type +- tmpfs overlays for test runner caches +- IPC via filesystem for follow-up messages +- Credentials injected per-exec (never baked into container) + +## Key Files + +| File | Purpose | +|------|---------| +| `src/index.ts` | Orchestrator: state, message loop, agent invocation | +| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | +| `src/container-runner.ts` | Docker container execution for reviewer/arbiter | +| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) | +| `src/ipc.ts` | IPC watcher and task processing | +| `src/router.ts` | Message formatting and outbound routing | +| `src/config.ts` | Trigger pattern, paths, intervals | +| `src/task-scheduler.ts` | Runs scheduled tasks | +| `src/db.ts` | SQLite operations | +| `runners/agent-runner/` | Claude Code runner (Agent SDK) | +| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | +| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..349e86e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,83 @@ +# Configuration + +All configuration in a single `.env` file. + +## Discord Bots + +```bash +DISCORD_BOT_TOKEN= # Claude bot +DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner) +DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (arbiter) +``` + +## Agent Types & Models + +```bash +# Agent types per role +OWNER_AGENT_TYPE=codex # codex | claude-code +REVIEWER_AGENT_TYPE=claude-code # claude-code | codex +ARBITER_AGENT_TYPE=codex # codex | claude-code (optional, enables 3rd agent) + +# Per-role model overrides +OWNER_MODEL=gpt-5.4 +REVIEWER_MODEL=claude-opus-4-6 +ARBITER_MODEL=gpt-5.4 + +# Per-role effort level +OWNER_EFFORT=xhigh +REVIEWER_EFFORT=high +ARBITER_EFFORT=xhigh + +# Per-role fallback toggle +REVIEWER_FALLBACK_ENABLED=true # Auto-handoff to codex when Claude exhausted +ARBITER_FALLBACK_ENABLED=false + +# Response language +AGENT_LANGUAGE=Korean # Injected into all agent prompts +``` + +## Authentication + +```bash +CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token +CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated for multi-account rotation +OPENAI_API_KEY= # Codex API key (if not using OAuth) +``` + +## Voice Transcription + +```bash +GROQ_API_KEY= # Groq whisper-large-v3-turbo (primary) +OPENAI_API_KEY= # OpenAI whisper-1 (fallback) +``` + +## Mixture of Agents (MoA) + +```bash +MOA_ENABLED=true +MOA_REF_MODELS=kimi,glm # Configurable reference model list + +# Per-model config (pattern: MOA_{NAME}_{SETTING}) +MOA_KIMI_MODEL=kimi-k2.5 +MOA_KIMI_BASE_URL=https://api.kimi.com/coding +MOA_KIMI_API_KEY=sk-kimi-xxx +MOA_KIMI_API_FORMAT=anthropic + +MOA_GLM_MODEL=glm-5.1 +MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic +MOA_GLM_API_KEY=xxx +MOA_GLM_API_FORMAT=anthropic +``` + +## Debugging Paths + +| Item | Path | +|------|------| +| **DB** | `store/messages.db` (shared, WAL mode) | +| Service log | `journalctl --user -u ejclaw -f` or `logs/ejclaw.log` | +| Per-group logs | `groups/{name}/logs/` | +| Claude sessions | `data/sessions/{name}/.claude/` | +| Codex sessions | `data/sessions/{name}/.codex/` | +| Claude platform rules | `prompts/claude-platform.md` | +| Codex platform rules | `prompts/codex-platform.md` | +| Global memory | `groups/global/CLAUDE.md` | From f5de14680bbe5d2e0a23a0fd03db93eafa884370 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 04:57:07 +0900 Subject: [PATCH 09/24] docs: remove bot names from role table --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 31361e6..e9a7cde 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ Prompt design inspired by [Q00/ouroboros](https://github.com/Q00/ouroboros) and A single unified service (`ejclaw`) runs a **Tribunal** of three roles: -| Role | Purpose | Default | -|------|---------|---------| -| **Owner** | Handles user requests, writes code | Codex (`@codex`) | -| **Reviewer** | Critically reviews owner's work, verifies design direction | Claude Code (`@claude`) | -| **Arbiter** | On-demand deadlock breaker between owner and reviewer | Codex (`@codex-review`) | +| Role | Purpose | +|------|---------| +| **Owner** | Handles user requests, writes code | +| **Reviewer** | Critically reviews owner's work, verifies design direction | +| **Arbiter** | On-demand deadlock breaker between owner and reviewer | Each role's agent type and model are independently configurable via `.env` (`OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, `ARBITER_AGENT_TYPE`, `*_MODEL`). Three Discord bots provide the identity layer — which bot speaks is determined by the active role, not hardcoded. From 6e81bdc21544dbedf5e93a4c842b924b536203d4 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:01:44 +0900 Subject: [PATCH 10/24] fix: mount codex OAuth credentials into reviewer container Mount host's ~/.codex (read-only) into container so codex-runner can authenticate via ChatGPT OAuth. Set CODEX_HOME env on exec. --- src/container-runner.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/container-runner.ts b/src/container-runner.ts index 500c297..5884e68 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -321,6 +321,16 @@ export function buildReviewerMounts( readonly: false, }); + // Codex OAuth: mount host's ~/.codex (read-only) so codex-runner can authenticate + const hostCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + if (fs.existsSync(hostCodexHome)) { + mounts.push({ + hostPath: hostCodexHome, + containerPath: '/home/node/.codex', + readonly: true, + }); + } + return mounts; } @@ -485,6 +495,9 @@ export async function runReviewerContainer(args: { } } const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + if (isCodexAgent) { + execArgs.push('-e', 'CODEX_HOME=/home/node/.codex'); + } const runnerPath = isCodexAgent ? '/app/codex/dist/index.js' : '/app/agent/dist/index.js'; From 5a109e37177f6405dad73f0c7f35923b0e33cf77 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:03:29 +0900 Subject: [PATCH 11/24] feat: show fallback model in status dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When global failover is active, claude-code roles now display the fallback model inline: `claude-opus-4-6 (fallback → codex)` --- src/unified-dashboard.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 75970fb..6cef723 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -591,6 +591,7 @@ function buildModelConfigSection(): string { }, ]; + const failover = getGlobalFailoverInfo(); const lines = ['🤖 *모델 구성*']; for (const role of roleConfigs) { if (!role.agentType && role.label === 'Arbiter') continue; @@ -600,7 +601,14 @@ function buildModelConfigSection(): string { ? process.env.CODEX_MODEL || 'codex' : process.env.CLAUDE_MODEL || 'claude'; const model = role.model || defaultModel; - lines.push(` **${role.label}** — ${type} \`${model}\``); + + // Show fallback status for claude-code roles when global failover is active + const isFallback = failover.active && type === 'claude-code'; + const fallbackModel = process.env.CODEX_MODEL || 'codex'; + const modelDisplay = isFallback + ? `\`${model}\` (fallback → \`${fallbackModel}\`)` + : `\`${model}\``; + lines.push(` **${role.label}** — ${type} ${modelDisplay}`); } // MoA status @@ -612,8 +620,6 @@ function buildModelConfigSection(): string { lines.push(` **MoA** — ${refs}`); } - // Global failover - const failover = getGlobalFailoverInfo(); if (failover.active) { lines.push(` ⚠️ **Failover 활성** — ${failover.reason || '알 수 없음'}`); } From f4410be779f182760a37348bf0214918ddf4dabe Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:04:04 +0900 Subject: [PATCH 12/24] fix: show only fallback model when failover is active --- src/unified-dashboard.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 6cef723..d2c77b6 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -604,11 +604,12 @@ function buildModelConfigSection(): string { // Show fallback status for claude-code roles when global failover is active const isFallback = failover.active && type === 'claude-code'; - const fallbackModel = process.env.CODEX_MODEL || 'codex'; - const modelDisplay = isFallback - ? `\`${model}\` (fallback → \`${fallbackModel}\`)` - : `\`${model}\``; - lines.push(` **${role.label}** — ${type} ${modelDisplay}`); + if (isFallback) { + const fallbackModel = process.env.CODEX_MODEL || 'codex'; + lines.push(` **${role.label}** — codex \`${fallbackModel}\` (fallback)`); + } else { + lines.push(` **${role.label}** — ${type} \`${model}\``); + } } // MoA status From 5420fa2ee81ac2b2e58d33e13479ffc77d1151d2 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:15:24 +0900 Subject: [PATCH 13/24] fix: handoff to codex on persistent session failure When Claude reviewer hits retryable session failure twice, hand off to codex instead of silently failing. Adds 'session-failure' to AgentTriggerReason and shouldHandoffToCodex check. --- src/agent-error-detection.ts | 3 ++- src/message-agent-executor.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent-error-detection.ts b/src/agent-error-detection.ts index bbfdbb7..47e13c7 100644 --- a/src/agent-error-detection.ts +++ b/src/agent-error-detection.ts @@ -101,7 +101,8 @@ export type AgentTriggerReason = | 'org-access-denied' | 'overloaded' | 'network-error' - | 'success-null-result'; + | 'success-null-result' + | 'session-failure'; export type ClaudeRotationReason = Extract< AgentTriggerReason, diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 4c66fce..515076b 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -295,7 +295,8 @@ export async function runAgentForGroup( reason === '429' || reason === 'usage-exhausted' || reason === 'auth-expired' || - reason === 'org-access-denied' + reason === 'org-access-denied' || + reason === 'session-failure' ); }; @@ -781,7 +782,7 @@ export async function runAgentForGroup( log.error( 'Retryable Claude session failure persisted after fresh retry', ); - return 'error'; + return maybeHandoffAfterError('session-failure', primaryAttempt); } } From 7c9050ba9f91a8db5f83c53dd423f65d41cfe61b Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:18:10 +0900 Subject: [PATCH 14/24] fix: only inject matching model env vars for container agent type When codex runs in container, skip CLAUDE_MODEL/EFFORT to prevent 'claude-opus-4-6 not supported with ChatGPT account' errors. --- src/container-runner.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 5884e68..d0ae9a5 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -322,7 +322,8 @@ export function buildReviewerMounts( }); // Codex OAuth: mount host's ~/.codex (read-only) so codex-runner can authenticate - const hostCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + const hostCodexHome = + process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); if (fs.existsSync(hostCodexHome)) { mounts.push({ hostPath: hostCodexHome, @@ -479,14 +480,11 @@ export async function runReviewerContainer(args: { ''; execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } - // Inject model/effort overrides at exec-time so per-role config - // takes effect even with persistent containers. - const modelEnvKeys = [ - 'CLAUDE_MODEL', - 'CLAUDE_EFFORT', - 'CODEX_MODEL', - 'CODEX_EFFORT', - ]; + const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + // Inject model/effort overrides matching the active agent type. + const modelEnvKeys = isCodexAgent + ? ['CODEX_MODEL', 'CODEX_EFFORT'] + : ['CLAUDE_MODEL', 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT']; if (envOverrides) { for (const key of modelEnvKeys) { if (envOverrides[key]) { @@ -494,7 +492,6 @@ export async function runReviewerContainer(args: { } } } - const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; if (isCodexAgent) { execArgs.push('-e', 'CODEX_HOME=/home/node/.codex'); } From a648a08bf08310bec54ce2c10f57a5e6a696e64a Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:20:55 +0900 Subject: [PATCH 15/24] fix: clear CLAUDE_MODEL env on codex container exec Container-level env has CLAUDE_MODEL baked in from creation. Explicitly clear it on docker exec when running codex agent to prevent 'model not supported with ChatGPT account' errors. --- src/container-runner.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index d0ae9a5..43a9985 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -481,17 +481,19 @@ export async function runReviewerContainer(args: { execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; - // Inject model/effort overrides matching the active agent type. - const modelEnvKeys = isCodexAgent - ? ['CODEX_MODEL', 'CODEX_EFFORT'] - : ['CLAUDE_MODEL', 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT']; if (envOverrides) { + const modelEnvKeys = ['CLAUDE_MODEL', 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT']; for (const key of modelEnvKeys) { if (envOverrides[key]) { execArgs.push('-e', `${key}=${envOverrides[key]}`); } } } + // Clear Claude model/effort for codex runs — container-level env may + // have CLAUDE_MODEL baked in from creation, which codex SDK rejects. + if (isCodexAgent) { + execArgs.push('-e', 'CLAUDE_MODEL=', '-e', 'CLAUDE_EFFORT='); + } if (isCodexAgent) { execArgs.push('-e', 'CODEX_HOME=/home/node/.codex'); } From df6298f340f80064e09b00e0ffb28aba9544ccc9 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:22:20 +0900 Subject: [PATCH 16/24] fix: exclude model env from container creation, inject per-exec only Model/effort keys (CLAUDE_MODEL, CODEX_MODEL, etc.) are now excluded from docker run creation args and only injected at docker exec time matching the active agent type. Removes the empty-value override hack. --- src/container-runner.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 43a9985..3c99cab 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -378,12 +378,20 @@ function buildCreateArgs( args.push('-e', `SENTRY_AUTH_TOKEN=${process.env.SENTRY_AUTH_TOKEN}`); } - // Extra env overrides from paired-execution-context + // Extra env overrides from paired-execution-context. + // Model/effort keys are excluded here — they are injected per-exec so the + // correct agent-type-specific values are used (claude vs codex). + const perExecKeys = new Set([ + 'CLAUDE_MODEL', + 'CLAUDE_EFFORT', + 'CODEX_MODEL', + 'CODEX_EFFORT', + ]); if (envOverrides) { for (const [key, value] of Object.entries(envOverrides)) { - // Already set above — skip duplicates if (key === 'ANTHROPIC_API_KEY' || key === 'CLAUDE_CODE_OAUTH_TOKEN') continue; + if (perExecKeys.has(key)) continue; if (key === 'EJCLAW_WORK_DIR') { args.push('-e', 'EJCLAW_WORK_DIR=/workspace/project'); continue; @@ -481,19 +489,17 @@ export async function runReviewerContainer(args: { execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + // Inject only agent-type-appropriate model/effort overrides per exec. if (envOverrides) { - const modelEnvKeys = ['CLAUDE_MODEL', 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT']; + const modelEnvKeys = isCodexAgent + ? ['CODEX_MODEL', 'CODEX_EFFORT'] + : ['CLAUDE_MODEL', 'CLAUDE_EFFORT']; for (const key of modelEnvKeys) { if (envOverrides[key]) { execArgs.push('-e', `${key}=${envOverrides[key]}`); } } } - // Clear Claude model/effort for codex runs — container-level env may - // have CLAUDE_MODEL baked in from creation, which codex SDK rejects. - if (isCodexAgent) { - execArgs.push('-e', 'CLAUDE_MODEL=', '-e', 'CLAUDE_EFFORT='); - } if (isCodexAgent) { execArgs.push('-e', 'CODEX_HOME=/home/node/.codex'); } From 9b71bc58788d4fb1cfbec3a40bcb194be7420021 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:25:47 +0900 Subject: [PATCH 17/24] debug: log container runner selection --- src/container-runner.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/container-runner.ts b/src/container-runner.ts index 3c99cab..ea3139c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -489,6 +489,15 @@ export async function runReviewerContainer(args: { execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + logger.info( + { + containerName, + groupAgentType: group.agentType, + isCodexAgent, + runnerPath: isCodexAgent ? '/app/codex/dist/index.js' : '/app/agent/dist/index.js', + }, + 'Container exec runner selection', + ); // Inject only agent-type-appropriate model/effort overrides per exec. if (envOverrides) { const modelEnvKeys = isCodexAgent From bfd781c55223ba1ab551106afb8fb2247feacc18 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:31:02 +0900 Subject: [PATCH 18/24] fix: skip role model override on failover handoffs When forcedAgentType is set (failover), the role's configured model (e.g. claude-opus-4-6 for reviewer) was injected as CODEX_MODEL, causing codex SDK to reject it. Now model overrides are only injected for the primary agent type, not failover runs. --- src/container-runner.ts | 4 +++- src/message-agent-executor.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index ea3139c..56a73dc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -494,7 +494,9 @@ export async function runReviewerContainer(args: { containerName, groupAgentType: group.agentType, isCodexAgent, - runnerPath: isCodexAgent ? '/app/codex/dist/index.js' : '/app/agent/dist/index.js', + runnerPath: isCodexAgent + ? '/app/codex/dist/index.js' + : '/app/agent/dist/index.js', }, 'Container exec runner selection', ); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 515076b..04e7f03 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -194,8 +194,11 @@ export async function runAgentForGroup( roomRoleContext, hasHumanMessage: args.hasHumanMessage, }); - // Inject role-specific model overrides into envOverrides - if (pairedExecutionContext) { + // Inject role-specific model overrides into envOverrides. + // When running a forced agent type (failover), skip the role's configured + // model — it belongs to the primary agent type (e.g. claude-opus-4-6 for + // a claude-code reviewer) and would be rejected by the fallback runtime. + if (pairedExecutionContext && !args.forcedAgentType) { const roleConfig = getRoleModelConfig(activeRole); if (roleConfig.model) { const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL'; From 671eb18252ea819eb8e5d7b52224f203d05ba709 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 05:37:56 +0900 Subject: [PATCH 19/24] fix: route reviewer failover through reviewer bot, not arbiter bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer handoff channel was based on target_agent_type (codex → discord-review/arbiter bot). Now uses REVIEWER_AGENT_TYPE like the normal reviewer path — role determines the bot, not the fallback type. --- src/message-runtime.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 69aab4a..e0026da 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -608,10 +608,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const handoffRole = resolveHandoffRoleOverride(handoff); let handoffChannel = channel; if (handoffRole === 'reviewer') { + // Use the same channel as the normal reviewer path — role determines + // which bot speaks, not the fallback agent type. const revChName = - handoff.target_agent_type === 'claude-code' - ? 'discord' - : 'discord-review'; + REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; handoffChannel = findChannelByName(deps.channels, revChName) || channel; } else if (handoffRole === 'arbiter') { handoffChannel = From fad3ce3bdffac919dad85a8008718f68b27c86d0 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 06:00:26 +0900 Subject: [PATCH 20/24] fix: inject role prompts into AGENTS.md for codex container reviewer prepareContainerSessionEnvironment now writes AGENTS.md (with platform + paired-room prompts) to a session-local .codex/ dir alongside auth credentials from host. CODEX_HOME points to this dir so codex-runner gets role-appropriate prompts instead of empty instructions. --- src/agent-runner-environment.ts | 31 ++++++++++++++++++++++++++----- src/container-runner.ts | 4 +++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 0cae6cc..291fb66 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -26,7 +26,10 @@ import { readPairedRoomPrompt, readPlatformPrompt, } from './platform-prompts.js'; -import { getEffectiveChannelLease, hasReviewerLease } from './service-routing.js'; +import { + getEffectiveChannelLease, + hasReviewerLease, +} from './service-routing.js'; import type { AgentType, RegisteredGroup } from './types.js'; // writeCodexApiKeyAuth removed — Codex uses OAuth only. @@ -329,10 +332,7 @@ EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)} `; - fs.writeFileSync( - sessionConfigPath, - toml.trimEnd() + '\n' + mcpSection, - ); + fs.writeFileSync(sessionConfigPath, toml.trimEnd() + '\n' + mcpSection); } delete args.env.ANTHROPIC_API_KEY; @@ -553,6 +553,27 @@ export function prepareContainerSessionEnvironment(args: { const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md'); if (sessionClaudeMd) { fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n'); + + // Write identical content as AGENTS.md for codex-runner compatibility. + // Codex SDK reads AGENTS.md from CODEX_HOME, not CLAUDE.md. + // Copy auth.json and config.toml from host .codex so the session-local + // CODEX_HOME has both prompts and authentication. + const codexHomeDir = path.join(sessionDir, '.codex'); + fs.mkdirSync(codexHomeDir, { recursive: true }); + fs.writeFileSync( + path.join(codexHomeDir, 'AGENTS.md'), + sessionClaudeMd + '\n', + ); + const hostCodexHome = + process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + for (const file of ['auth.json', 'config.toml']) { + const src = path.join(hostCodexHome, file); + const dst = path.join(codexHomeDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dst); + } + } + logger.info( { sessionDir, diff --git a/src/container-runner.ts b/src/container-runner.ts index 56a73dc..4e9f30c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -512,7 +512,9 @@ export async function runReviewerContainer(args: { } } if (isCodexAgent) { - execArgs.push('-e', 'CODEX_HOME=/home/node/.codex'); + // Use session-local .codex dir (contains AGENTS.md with role prompts) + // instead of the host-mounted ~/.codex (which has owner-only config). + execArgs.push('-e', 'CODEX_HOME=/home/node/.claude/.codex'); } const runnerPath = isCodexAgent ? '/app/codex/dist/index.js' From 59cf931b8275c45cfcce8cee65f0cadd244512c5 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 13:34:46 +0900 Subject: [PATCH 21/24] fix: copy arbiter AGENTS.md to reviewer session for container mount Container's /home/node/.claude always mounts the reviewer session dir. When arbiter runs codex in the same container, CODEX_HOME reads from reviewer session's .codex/. Copy arbiter AGENTS.md there so codex gets arbiter prompts instead of reviewer prompts. --- src/agent-runner.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 76d5d5c..174a12b 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -97,6 +97,26 @@ export async function runAgentProcess( memoryBriefing: input.memoryBriefing, role: containerRole, }); + // For codex: also write AGENTS.md to the reviewer session dir, because + // the container's /home/node/.claude always mounts the reviewer session. + // Arbiter and reviewer never run simultaneously, so this is safe. + if (containerRole === 'arbiter') { + const reviewerSessionDir = path.join( + path.dirname(sessionDir), + `${group.folder}-reviewer`, + ); + const reviewerCodexDir = path.join(reviewerSessionDir, '.codex'); + if (fs.existsSync(reviewerSessionDir)) { + fs.mkdirSync(reviewerCodexDir, { recursive: true }); + const arbiterAgentsMd = path.join(sessionDir, '.codex', 'AGENTS.md'); + if (fs.existsSync(arbiterAgentsMd)) { + fs.copyFileSync( + arbiterAgentsMd, + path.join(reviewerCodexDir, 'AGENTS.md'), + ); + } + } + } } return runReviewerContainer({ From 74ff662026692a8d12d6aef1bf579816aee7819f Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 13:47:34 +0900 Subject: [PATCH 22/24] feat: mount owner session directory read-only in reviewer container Allows reviewer to verify runtime state files (cron state, configs) that the owner references by absolute host path. Mounted at the same host path so absolute path references work inside the container. --- src/container-runner.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/container-runner.ts b/src/container-runner.ts index 4e9f30c..b81b0db 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -321,6 +321,17 @@ export function buildReviewerMounts( readonly: false, }); + // Owner session directory: read-only so reviewer can verify runtime state + // files (cron state, configs, etc.) that the owner references by absolute path. + const ownerSessionDir = path.join(DATA_DIR, 'sessions', group.folder); + if (fs.existsSync(ownerSessionDir)) { + mounts.push({ + hostPath: ownerSessionDir, + containerPath: ownerSessionDir, + readonly: true, + }); + } + // Codex OAuth: mount host's ~/.codex (read-only) so codex-runner can authenticate const hostCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); From 0d39eada6fe35519558d01c77f3921fdeed5dc7c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 2 Apr 2026 01:39:50 +0900 Subject: [PATCH 23/24] feat: add ripgrep to reviewer container image --- container/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/container/Dockerfile b/container/Dockerfile index e54ebea..7b757b9 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -31,6 +31,7 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ zip \ unzip \ strace \ + ripgrep \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ From 371a3ef0ff6442f683cefa5a009fabe5741c7b22 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 2 Apr 2026 01:40:15 +0900 Subject: [PATCH 24/24] feat: add fd-find, bat, tree to reviewer container --- container/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/container/Dockerfile b/container/Dockerfile index 7b757b9..f6ee1ae 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -32,6 +32,9 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ unzip \ strace \ ripgrep \ + fd-find \ + bat \ + tree \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \