fix: separate reviewer session from owner to preserve review context

Reviewer was starting with undefined sessionId every turn, losing all
conversation context. Now uses `group.folder:reviewer` as the session key
so owner and reviewer maintain independent session histories.

Also updates README to reflect unified service architecture and paired
review workflow.
This commit is contained in:
Eyejoker
2026-03-29 22:22:45 +09:00
parent 33baf2970d
commit 8049db0d47
3 changed files with 86 additions and 60 deletions

View File

@@ -58,7 +58,7 @@ ssh clone-ej@100.64.185.108 'cd ~/EJClaw && git pull && npm run build && npm run
## Service Stack Architecture
Single unified service manages all three Discord bots in one process:
- `ejclaw.service`Unified process, `UNIFIED_MODE=true` (default)
- `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 (`OWNER_AGENT_TYPE`, default: codex) ↔ reviewer (`REVIEWER_AGENT_TYPE`, default: claude-code)
- Reviewer fallback: Claude 429/한도초과 시 codex-review로 자동 핸드오프

121
README.md
View File

@@ -5,41 +5,65 @@
![Node](https://img.shields.io/badge/Node-20+-339933?logo=nodedotjs&logoColor=white)
![Discord](https://img.shields.io/badge/Discord-Bot-5865F2?logo=discord&logoColor=white)
Dual-agent AI assistant (Claude Code + Codex) over Discord.
Dual-agent AI assistant (Claude Code + Codex) over Discord with autonomous paired review.
Originally derived from [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw), now maintained as EJClaw for personal production use.
Prompt design inspired by [Q00/ouroboros](https://github.com/Q00/ouroboros), adapted for EJClaw's Discord and dual-service workflow.
## Overview
Two AI agents running as parallel systemd services on a single host:
A single unified service (`ejclaw`) manages three Discord bots in one process:
- **Claude Code** (`@claude`) — Anthropic Agent SDK, adaptive thinking, Opus/Sonnet
- **Codex** (`@codex`) — OpenAI Codex SDK (`codex exec`), GPT-5.4, xhigh reasoning
- **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`) — Fallback reviewer. Takes over when Claude hits rate limits (429).
Both share the same codebase (`dist/index.js`), differentiated by environment variables. No containers — direct host processes for zero overhead.
Owner and reviewer agent types are configurable via `OWNER_AGENT_TYPE` and `REVIEWER_AGENT_TYPE` in `.env`.
## Paired Review
The core workflow is an autonomous owner-reviewer ping-pong:
```
User message
→ Owner responds (implementation, answer, etc.)
→ Reviewer auto-triggered (critical review, design check)
→ Verdict:
DONE → Owner gets final turn to finalize → Task completed
DONE_WITH_CONCERNS → Owner addresses feedback → Reviewer re-reviews
BLOCKED → Escalate to user (needs decision)
NEEDS_CONTEXT → Escalate to user (missing information)
```
The system stops autonomously when the reviewer approves or escalates. No manual intervention needed for the happy path.
## Features
- **Dual-agent architecture** — Claude Code + Codex as parallel services, shared SQLite (WAL mode)
- **Browser automation** — [gstack browse](https://github.com/garrytan/gstack) skill, headless Chromium daemon, ~100ms/command
- **Paired review** — Autonomous owner/reviewer ping-pong with verdict-based control
- **Configurable agent types** — Owner and reviewer roles independently set to `claude-code` or `codex`
- **Reviewer fallback** — Claude 429/exhaustion → automatic handoff to codex-review
- **Voice transcription** — Groq Whisper (primary) / OpenAI Whisper (fallback), shared file cache with dedup
- **Bidirectional images** — receive Discord attachments as multimodal input, send screenshots back
- **Bidirectional images** — Receive Discord attachments as multimodal input, send screenshots back
- **Token rotation** — Claude 429 / usage exhaustion → automatic multi-account rotation
- **CI monitoring** — `watch_ci` MCP tool for GitHub Actions run polling (structured fast path, no LLM token cost)
- **Usage dashboard** — real-time token usage and service status overview
- **MCP integration** — Memento (persistent memory) + EJClaw host tools (send_message, schedule_task, watch_ci, etc.)
- **Skill sync** — single source of truth, auto-synced to all agent sessions
- **Priority queue** — per-group serialization, global concurrency limit
- **Session persistence** — resume conversations across restarts
- **Scheduled tasks** — cron/interval/once via MCP tool
- **Mid-turn steering** — inject follow-up messages while agent is working (both agents)
- **Browser automation** — [gstack browse](https://github.com/garrytan/gstack) skill, headless Chromium daemon
- **Priority queue** — Per-group serialization, global concurrency limit
- **Session persistence** — Resume conversations across restarts
- **Scheduled tasks** — Cron/interval/once via MCP tool
- **Mid-turn steering** — Inject follow-up messages while agent is working
## Architecture
```
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Claude Code (Agent SDK, MessageStream)
└──► Codex (Codex SDK, codex exec)
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (Codex/Claude Code)
│ │
│ ▼ (auto-trigger)
└──► Reviewer (Claude Code/Codex)
│ │
│ Verdict routing
│ ├─ DONE → merge_ready → Owner finalizes
│ ├─ BLOCKED/NEEDS_CONTEXT → User
│ └─ Feedback → Owner (loop)
IPC polling ◄── follow-up messages (mid-turn steering)
@@ -59,8 +83,7 @@ Each agent has access to:
- Node.js 20+ (24 recommended, fnm for version management)
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
- Bun 1.0+ (for browser automation)
- Discord bot token
- Discord bot tokens (3: Claude, Codex-main, Codex-review)
### Install
@@ -74,51 +97,53 @@ npm run build
### Environment
All configuration in a single `.env` file:
```bash
# .env
DISCORD_BOT_TOKEN= # Discord bot token
ASSISTANT_NAME=claude # Bot trigger name (@claude)
CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token (primary)
# 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 (fallback reviewer)
# Agent type configuration
OWNER_AGENT_TYPE=codex # codex | claude-code
REVIEWER_AGENT_TYPE=claude-code # claude-code | codex
# API keys
CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token
CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated tokens for multi-account rotation
OPENAI_API_KEY= # For Codex
GROQ_API_KEY= # Voice transcription (Groq Whisper)
# Bot names
ASSISTANT_NAME=claude # Claude bot trigger name
CODEX_ASSISTANT_NAME=codex # Codex bot trigger name
```
### Codex Service (optional)
To run the Codex agent alongside Claude, create `.env.codex`:
```bash
# .env.codex
DISCORD_BOT_TOKEN= # Separate Discord bot token for Codex
# .env.codex-review
DISCORD_BOT_TOKEN= # Separate Discord bot token for Codex Review
```
The setup step (`npm run setup -- --step service`) auto-detects `.env.codex` and `.env.codex-review`, then installs `ejclaw-codex` and `ejclaw-review` alongside `ejclaw`. Additional Codex settings (`CODEX_MODEL`, `CODEX_EFFORT`, etc.) can be added to `.env.codex`, `.env.codex-review`, or as `Environment=` lines in the systemd units.
### Authentication
Multi-account OAuth token rotation is supported via `CLAUDE_CODE_OAUTH_TOKENS` (comma-separated). When one account hits a rate limit, the system automatically rotates to the next.
Token auto-refresh runs on the Claude service only, refreshing access tokens 30 minutes before expiry using rotating refresh tokens from `~/.claude/.credentials.json` (account 0) and `~/.claude-accounts/{n}/.credentials.json` (account 1+). Generate tokens with `claude setup-token` (account 0) or `CLAUDE_CONFIG_DIR=~/.claude-accounts/{n} claude setup-token` (account 1+).
### Systemd Services (Linux)
### Systemd Service (Linux)
Single unified service:
```bash
npm run restart:stack
systemctl --user enable ejclaw ejclaw-codex ejclaw-review
systemctl --user start ejclaw ejclaw-codex ejclaw-review
# Logs
journalctl --user -u ejclaw -f
journalctl --user -u ejclaw-codex -f
journalctl --user -u ejclaw-review -f
systemctl --user restart ejclaw # Restart
systemctl --user status ejclaw # Check status
systemctl --user enable ejclaw # Enable on boot
journalctl --user -u ejclaw -f # Follow logs
```
Use `npm run restart:stack` from an external shell or deploy wrapper, not from inside one of the managed EJClaw services.
On existing installs, run `npm run setup -- --step service` once after pulling this slice so the `ejclaw-stack-restart.service` unit is installed.
### Deploy
Build on server, not locally:
```bash
ssh clone-ej@100.64.185.108 'cd ~/EJClaw && git pull && npm run build && npm run build:runners && systemctl --user restart ejclaw'
```
## Development

View File

@@ -128,10 +128,11 @@ export async function runAgentForGroup(
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
// When the reviewer uses a different agent type than the group default,
// the stored session belongs to the other agent. Don't reuse it.
const sessionId = reviewerAgentTypeOverride
? undefined
: sessions[group.folder];
// use a separate session key so owner and reviewer sessions don't collide.
const sessionFolder = reviewerAgentTypeOverride
? `${group.folder}:reviewer`
: group.folder;
const sessionId = sessions[sessionFolder];
const memoryBriefing = sessionId
? undefined
: await buildRoomMemoryBriefing({
@@ -307,7 +308,7 @@ export async function runAgentForGroup(
output.newSessionId &&
!resetSessionRequested
) {
deps.persistSession(group.folder, output.newSessionId);
deps.persistSession(sessionFolder, output.newSessionId);
}
const evaluation = evaluateStreamedOutput(output, streamedState, {
agentType: isClaudeCodeAgent ? 'claude-code' : 'codex',
@@ -443,7 +444,7 @@ export async function runAgentForGroup(
);
if (provider === 'claude' && output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
deps.persistSession(sessionFolder, output.newSessionId);
}
logger.info(
@@ -657,7 +658,7 @@ export async function runAgentForGroup(
})));
if (isRetryableClaudeSessionFailure(primaryAttempt)) {
deps.clearSession(group.folder);
deps.clearSession(sessionFolder);
logger.warn(
{ group: group.name, chatJid, runId },
'Cleared poisoned Claude session before visible output, retrying fresh session',
@@ -666,7 +667,7 @@ export async function runAgentForGroup(
primaryAttempt = await runAttempt('claude');
if (isRetryableClaudeSessionFailure(primaryAttempt)) {
deps.clearSession(group.folder);
deps.clearSession(sessionFolder);
logger.warn(
{ group: group.name, chatJid, runId },
'Fresh Claude retry also hit a retryable session failure',
@@ -791,9 +792,9 @@ export async function runAgentForGroup(
isClaudeCodeAgent &&
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
) {
deps.clearSession(group.folder);
deps.clearSession(sessionFolder);
logger.warn(
{ group: group.name, chatJid, runId },
{ group: group.name, chatJid, runId, sessionFolder },
'Cleared poisoned agent session after unrecoverable error',
);
}