diff --git a/.env.example b/.env.example index bd6dc95..5aefc8c 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,13 @@ # Copy this file to .env and fill in the values. # Single unified service manages all three Discord bots in one process. -# --- 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) +# --- Discord bots (canonical role-fixed names) --- +DISCORD_OWNER_BOT_TOKEN= # Owner bot +DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot +DISCORD_ARBITER_BOT_TOKEN= # Arbiter bot + +# Old service-based token names are no longer accepted. +# Rename any existing values to the three canonical role-based keys above. # --- Agent type configuration --- OWNER_AGENT_TYPE=codex # codex | claude-code diff --git a/CLAUDE.md b/CLAUDE.md index 0f30238..cdcfc56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Originally derived f ## Quick Context -Single unified service (`ejclaw`) manages three Discord bots (Claude, Codex-main, Codex-review) in one process. Owner/reviewer agent types are configurable via `OWNER_AGENT_TYPE` and `REVIEWER_AGENT_TYPE` in `.env`. Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). +Single unified service (`ejclaw`) manages three Discord bots (owner, reviewer, arbiter) in one process. Agent types behind each role remain configurable via `OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, and `ARBITER_AGENT_TYPE` in `.env`. Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -57,9 +57,10 @@ bun run deploy Single unified service 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) +- Discord bots: `DISCORD_OWNER_BOT_TOKEN` (owner), `DISCORD_REVIEWER_BOT_TOKEN` (reviewer), `DISCORD_ARBITER_BOT_TOKEN` (arbiter) +- Old service-based token names are no longer accepted; migrate them to the canonical role-based keys above before starting the service - Paired review: owner (`OWNER_AGENT_TYPE`, default: codex) ↔ reviewer (`REVIEWER_AGENT_TYPE`, default: claude-code) -- Reviewer fallback: Claude 429/한도초과 시 codex-review로 자동 핸드오프 +- Provider fallback is internal routing only — visible Discord bots stay owner/reviewer/arbiter fixed - Shared dirs: `store/`, `groups/`, `data/` - SQLite WAL mode + `busy_timeout=5000` for concurrent access diff --git a/README.md b/README.md index de13fa6..0eba661 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,25 @@ 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 while +managing three Discord bots in one process: -- **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 | +|------|---------| +| **Owner** | Handles user requests, writes code | +| **Reviewer** | Critically reviews owner's work, verifies design direction | +| **Arbiter** | On-demand deadlock breaker between owner and reviewer | -All agent types and models are independently configurable per role via `.env`. +The identity layer is role-fixed: + +- **Owner bot** — Handles the owner turn output slot. +- **Reviewer bot** — Handles the reviewer turn output slot. +- **Arbiter bot** — Handles the arbiter turn output slot. + +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 @@ -41,7 +53,7 @@ Operationally: 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 @@ -131,7 +143,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho - 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) +- Discord bot tokens (3: owner, reviewer, arbiter) ### Install @@ -144,15 +156,23 @@ bun run build bun run build:container # Build reviewer Docker image ``` +## Documentation + +- [Architecture](docs/architecture.md) — Data flow, room model, container isolation, key files +- [Configuration](docs/configuration.md) — Full `.env` reference, debugging paths + ### 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) +# Discord bots (canonical role-fixed names) +DISCORD_OWNER_BOT_TOKEN= # Owner bot +DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot +DISCORD_ARBITER_BOT_TOKEN= # Arbiter bot + +# Old service-based token names are no longer accepted. +# Rename existing values to the canonical role-based keys above. # Agent types OWNER_AGENT_TYPE=codex # codex | claude-code diff --git a/runners/agent-runner/src/reviewer-runtime.ts b/runners/agent-runner/src/reviewer-runtime.ts index f0899f7..2d313e5 100644 --- a/runners/agent-runner/src/reviewer-runtime.ts +++ b/runners/agent-runner/src/reviewer-runtime.ts @@ -45,6 +45,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string { }).trim(); } +function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string { + const candidateRoots = [ + baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT, + baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null, + process.cwd() + ? path.join(process.cwd(), '.ejclaw-reviewer-runtime') + : null, + path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'), + ].filter((value): value is string => Boolean(value)); + + const probeContents = '#!/usr/bin/env bash\nexit 0\n'; + const tried = new Set(); + + for (const candidateRoot of candidateRoots) { + if (tried.has(candidateRoot)) { + continue; + } + tried.add(candidateRoot); + try { + fs.mkdirSync(candidateRoot, { recursive: true }); + const wrapperDir = fs.mkdtempSync( + path.join(candidateRoot, 'ejclaw-reviewer-git-'), + ); + const probePath = path.join(wrapperDir, 'probe'); + fs.writeFileSync(probePath, probeContents, { mode: 0o755 }); + execFileSync(probePath, [], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + fs.rmSync(probePath, { force: true }); + return wrapperDir; + } catch { + continue; + } + } + + throw new Error( + 'Unable to create an executable git guard wrapper directory for reviewer runtime.', + ); +} + export function buildReviewerGitGuardEnv( baseEnv: NodeJS.ProcessEnv, reviewerRuntime: boolean, @@ -55,9 +95,7 @@ export function buildReviewerGitGuardEnv( const realGitPath = resolveGitBinary(baseEnv); const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || ''; - const wrapperDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'ejclaw-reviewer-git-'), - ); + const wrapperDir = createExecutableWrapperDir(baseEnv); const wrapperPath = path.join(wrapperDir, 'git'); const blocked = [...BLOCKED_GIT_SUBCOMMANDS] .map((value) => `'${value}'`) diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index 49181c9..ba3677b 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -62,6 +62,27 @@ describe('claude reviewer runtime guard', () => { expect(env.EJCLAW_REAL_GIT).toBeTruthy(); }); + it('prefers an executable HOME-scoped wrapper dir before tmp', () => { + const homeDir = fs.mkdtempSync( + path.join(process.cwd(), '.ejclaw-reviewer-home-'), + ); + try { + const env = buildReviewerGitGuardEnv( + { + PATH: process.env.PATH, + HOME: homeDir, + }, + true, + ); + const wrapperDir = env.PATH?.split(path.delimiter)[0] ?? ''; + expect(wrapperDir).toContain( + path.join(homeDir, '.ejclaw-reviewer-runtime'), + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + it('allows mutating git commands in temp repos outside the protected workspace', () => { const protectedDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-protected-workspace-'), diff --git a/runners/codex-runner/src/reviewer-runtime.ts b/runners/codex-runner/src/reviewer-runtime.ts index 8ee4382..338e565 100644 --- a/runners/codex-runner/src/reviewer-runtime.ts +++ b/runners/codex-runner/src/reviewer-runtime.ts @@ -42,6 +42,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string { }).trim(); } +function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string { + const candidateRoots = [ + baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT, + baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null, + process.cwd() + ? path.join(process.cwd(), '.ejclaw-reviewer-runtime') + : null, + path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'), + ].filter((value): value is string => Boolean(value)); + + const probeContents = '#!/usr/bin/env bash\nexit 0\n'; + const tried = new Set(); + + for (const candidateRoot of candidateRoots) { + if (tried.has(candidateRoot)) { + continue; + } + tried.add(candidateRoot); + try { + fs.mkdirSync(candidateRoot, { recursive: true }); + const wrapperDir = fs.mkdtempSync( + path.join(candidateRoot, 'ejclaw-reviewer-git-'), + ); + const probePath = path.join(wrapperDir, 'probe'); + fs.writeFileSync(probePath, probeContents, { mode: 0o755 }); + execFileSync(probePath, [], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + fs.rmSync(probePath, { force: true }); + return wrapperDir; + } catch { + continue; + } + } + + throw new Error( + 'Unable to create an executable git guard wrapper directory for reviewer runtime.', + ); +} + export function buildReviewerGitGuardEnv( baseEnv: NodeJS.ProcessEnv, reviewerRuntime: boolean, @@ -52,9 +92,7 @@ export function buildReviewerGitGuardEnv( const realGitPath = resolveGitBinary(baseEnv); const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || ''; - const wrapperDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'ejclaw-reviewer-git-'), - ); + const wrapperDir = createExecutableWrapperDir(baseEnv); const wrapperPath = path.join(wrapperDir, 'git'); const blocked = [...BLOCKED_GIT_SUBCOMMANDS] .map((value) => `'${value}'`) diff --git a/runners/codex-runner/test/reviewer-runtime.test.ts b/runners/codex-runner/test/reviewer-runtime.test.ts index 8c4678d..eb629ab 100644 --- a/runners/codex-runner/test/reviewer-runtime.test.ts +++ b/runners/codex-runner/test/reviewer-runtime.test.ts @@ -49,6 +49,27 @@ describe('codex reviewer runtime guard', () => { expect(env.EJCLAW_REAL_GIT).toBeTruthy(); }); + it('prefers an executable HOME-scoped wrapper dir before tmp', () => { + const homeDir = fs.mkdtempSync( + path.join(process.cwd(), '.ejclaw-reviewer-home-'), + ); + try { + const env = buildReviewerGitGuardEnv( + { + PATH: process.env.PATH, + HOME: homeDir, + }, + true, + ); + const wrapperDir = env.PATH?.split(path.delimiter)[0] ?? ''; + expect(wrapperDir).toContain( + path.join(homeDir, '.ejclaw-reviewer-runtime'), + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + it('allows mutating git commands in temp repos outside the protected workspace', () => { const protectedDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-protected-workspace-'), diff --git a/setup/legacy-service-guard.test.ts b/setup/legacy-service-guard.test.ts new file mode 100644 index 0000000..3b35046 --- /dev/null +++ b/setup/legacy-service-guard.test.ts @@ -0,0 +1,123 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { execSyncMock } = vi.hoisted(() => ({ + execSyncMock: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execSync: execSyncMock, +})); + +import { + detectLegacyServiceIssues, + formatLegacyServiceFailureMessage, +} from './legacy-service-guard.js'; + +describe('legacy service guard', () => { + const tempRoots: string[] = []; + + afterEach(() => { + execSyncMock.mockReset(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('detects legacy systemd units in the opposite scope', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-')); + tempRoots.push(projectRoot); + + execSyncMock.mockImplementation((cmd: string) => { + if (cmd === 'systemctl is-active ejclaw-codex') { + throw new Error('inactive'); + } + if (cmd === 'systemctl list-unit-files') { + return 'ejclaw-codex.service enabled\n'; + } + if (cmd === 'systemctl --user is-active ejclaw-codex') { + throw new Error('inactive'); + } + if (cmd === 'systemctl --user list-unit-files') { + return ''; + } + if (cmd === 'systemctl is-active ejclaw-review') { + throw new Error('inactive'); + } + if (cmd === 'systemctl --user is-active ejclaw-review') { + throw new Error('inactive'); + } + if (cmd === 'systemctl list-unit-files' || cmd === 'systemctl --user list-unit-files') { + return ''; + } + throw new Error(`unexpected command: ${cmd}`); + }); + + expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([ + { + name: 'ejclaw-codex', + status: 'stopped', + sources: ['systemd-system'], + }, + ]); + }); + + it('detects legacy nohup artifacts even on systemd hosts', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-')); + tempRoots.push(projectRoot); + fs.writeFileSync(path.join(projectRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n'); + + execSyncMock.mockImplementation((cmd: string) => { + if ( + cmd === 'systemctl is-active ejclaw-codex' || + cmd === 'systemctl --user is-active ejclaw-codex' || + cmd === 'systemctl is-active ejclaw-review' || + cmd === 'systemctl --user is-active ejclaw-review' + ) { + throw new Error('inactive'); + } + if ( + cmd === 'systemctl list-unit-files' || + cmd === 'systemctl --user list-unit-files' + ) { + return ''; + } + throw new Error(`unexpected command: ${cmd}`); + }); + + expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([ + { + name: 'ejclaw-codex', + status: 'stopped', + sources: ['nohup'], + }, + ]); + }); + + it('formats cleanup instructions for the detected scopes only', () => { + const message = formatLegacyServiceFailureMessage({ + projectRoot: '/srv/ejclaw', + serviceManager: 'systemd', + homeDir: '/home/user', + services: [ + { + name: 'ejclaw-codex', + status: 'stopped', + sources: ['systemd-system', 'nohup'], + }, + { + name: 'ejclaw-review', + status: 'running', + sources: ['systemd-user'], + }, + ], + }); + + expect(message).toContain('systemctl disable --now ejclaw-codex'); + expect(message).toContain('systemctl --user disable --now ejclaw-review'); + expect(message).toContain('pkill -F "/srv/ejclaw/ejclaw-codex.pid"'); + expect(message).not.toContain('systemctl --user disable --now ejclaw-codex'); + }); +}); diff --git a/setup/legacy-service-guard.ts b/setup/legacy-service-guard.ts new file mode 100644 index 0000000..2099978 --- /dev/null +++ b/setup/legacy-service-guard.ts @@ -0,0 +1,209 @@ +import os from 'os'; +import path from 'path'; + +import type { ServiceManager } from './platform.js'; +import { getLegacyServiceDefs } from './service-defs.js'; +import { + checkLaunchdServiceArtifact, + checkNohupServiceArtifact, + checkSystemdServiceInScope, + type ServiceCheck, + type SystemdScope, +} from './verify-services.js'; + +type LegacySource = 'launchd' | 'systemd-system' | 'systemd-user' | 'nohup'; + +export interface LegacyServiceIssue extends ServiceCheck { + sources: LegacySource[]; +} + +function summarizeLegacyStatus(statuses: ServiceCheck['status'][]): ServiceCheck['status'] { + if (statuses.includes('running')) { + return 'running'; + } + if (statuses.includes('stopped')) { + return 'stopped'; + } + return 'not_found'; +} + +export function detectLegacyServiceIssues( + projectRoot: string, + serviceManager: ServiceManager, + homeDir = os.homedir(), +): LegacyServiceIssue[] { + return getLegacyServiceDefs(projectRoot) + .map((def) => { + if (serviceManager === 'launchd') { + const status = checkLaunchdServiceArtifact( + def.launchdLabel, + path.join(homeDir, 'Library', 'LaunchAgents', `${def.launchdLabel}.plist`), + ); + if (status === 'not_found') return null; + return { + name: def.name, + status, + sources: ['launchd'] as LegacySource[], + }; + } + + if (serviceManager === 'systemd') { + const scopedChecks: Array<{ source: LegacySource; status: ServiceCheck['status'] }> = [ + { + source: 'systemd-system', + status: checkSystemdServiceInScope(def.name, 'system'), + }, + { + source: 'systemd-user', + status: checkSystemdServiceInScope(def.name, 'user'), + }, + { + source: 'nohup', + status: checkNohupServiceArtifact(projectRoot, def.name), + }, + ]; + const detected = scopedChecks.filter( + (current) => current.status !== 'not_found', + ); + if (detected.length === 0) return null; + return { + name: def.name, + status: summarizeLegacyStatus(detected.map((current) => current.status)), + sources: detected.map((current) => current.source), + }; + } + + const status = checkNohupServiceArtifact(projectRoot, def.name); + if (status === 'not_found') return null; + return { + name: def.name, + status, + sources: ['nohup'] as LegacySource[], + }; + }) + .filter((service): service is LegacyServiceIssue => Boolean(service)); +} + +function formatSystemdCleanupForScope( + serviceNames: string[], + homeDir: string, + scope: SystemdScope, +): string[] { + if (serviceNames.length === 0) return []; + const systemctlPrefix = scope === 'system' ? 'systemctl' : 'systemctl --user'; + const unitDir = + scope === 'system' + ? '/etc/systemd/system' + : path.join(homeDir, '.config', 'systemd', 'user'); + const unitPaths = serviceNames + .map((serviceName) => + JSON.stringify(path.join(unitDir, `${serviceName}.service`)), + ) + .join(' '); + + return [ + `${systemctlPrefix} disable --now ${serviceNames.join(' ')}`, + `rm -f ${unitPaths}`, + `${systemctlPrefix} daemon-reload`, + ]; +} + +function formatLaunchdCleanup(serviceNames: string[], homeDir: string): string { + if (serviceNames.length === 0) { + return ''; + } + const plistPaths = serviceNames.map((serviceName) => { + const label = serviceName === 'ejclaw-codex' + ? 'com.ejclaw-codex' + : 'com.ejclaw-review'; + return path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`); + }); + + return [ + ...plistPaths.map( + (plistPath) => + `launchctl unload ${JSON.stringify(plistPath)} 2>/dev/null || true`, + ), + `rm -f ${plistPaths.map((plistPath) => JSON.stringify(plistPath)).join(' ')}`, + ].join('\n'); +} + +function formatNohupCleanup( + projectRoot: string, + serviceNames: string[], +): string { + if (serviceNames.length === 0) { + return ''; + } + const pidPaths = serviceNames + .map((serviceName) => path.join(projectRoot, `${serviceName}.pid`)) + .map((currentPath) => JSON.stringify(currentPath)); + const wrapperPaths = serviceNames + .map((serviceName) => path.join(projectRoot, `start-${serviceName}.sh`)) + .map((currentPath) => JSON.stringify(currentPath)); + + return [ + ...pidPaths.map((pidPath) => `pkill -F ${pidPath} 2>/dev/null || true`), + `rm -f ${[...pidPaths, ...wrapperPaths].join(' ')}`, + ].join('\n'); +} + +export function formatLegacyServiceFailureMessage(args: { + projectRoot: string; + serviceManager: ServiceManager; + services: LegacyServiceIssue[]; + homeDir?: string; +}): string { + const homeDir = args.homeDir ?? os.homedir(); + const details = args.services + .map( + (service) => + `${service.name}=${service.status} [${service.sources.join(',')}]`, + ) + .join(', '); + + let cleanupLines: string[] = []; + if (args.serviceManager === 'launchd') { + cleanupLines = formatLaunchdCleanup( + args.services + .filter((service) => service.sources.includes('launchd')) + .map((service) => service.name), + homeDir, + ).split('\n'); + } else if (args.serviceManager === 'systemd') { + cleanupLines = [ + ...formatSystemdCleanupForScope( + args.services + .filter((service) => service.sources.includes('systemd-system')) + .map((service) => service.name), + homeDir, + 'system', + ), + ...formatSystemdCleanupForScope( + args.services + .filter((service) => service.sources.includes('systemd-user')) + .map((service) => service.name), + homeDir, + 'user', + ), + ...formatNohupCleanup( + args.projectRoot, + args.services + .filter((service) => service.sources.includes('nohup')) + .map((service) => service.name), + ).split('\n'), + ].filter(Boolean); + } else { + cleanupLines = formatNohupCleanup( + args.projectRoot, + args.services.map((service) => service.name), + ).split('\n'); + } + + return [ + `Legacy EJClaw multi-service install detected: ${details}`, + 'This setup is reinstall-only. Remove the legacy services before continuing.', + 'Suggested cleanup:', + ...cleanupLines, + ].join('\n'); +} diff --git a/setup/restart-stack.test.ts b/setup/restart-stack.test.ts index 011b56a..e71aa0f 100644 --- a/setup/restart-stack.test.ts +++ b/setup/restart-stack.test.ts @@ -21,8 +21,6 @@ describe('restartStackServices', () => { it('dispatches stack restart through the oneshot unit by default', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); - fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n'); const execFileSyncImpl = vi.fn(); @@ -33,7 +31,7 @@ describe('restartStackServices', () => { serviceId: null, }); - expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(services).toEqual(['ejclaw']); expect(execFileSyncImpl).toHaveBeenNthCalledWith( 1, 'systemctl', @@ -43,11 +41,9 @@ describe('restartStackServices', () => { expect(execFileSyncImpl).toHaveBeenCalledTimes(1); }); - it('restarts and verifies the configured three-service stack in direct mode', () => { + it('restarts and verifies the unified service in direct mode', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); - fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n'); const execFileSyncImpl = vi.fn(); @@ -59,11 +55,11 @@ describe('restartStackServices', () => { serviceId: null, }); - expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(services).toEqual(['ejclaw']); expect(execFileSyncImpl).toHaveBeenNthCalledWith( 1, 'systemctl', - ['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'], + ['--user', 'restart', 'ejclaw'], { stdio: 'ignore' }, ); expect(execFileSyncImpl).toHaveBeenNthCalledWith( @@ -72,18 +68,7 @@ describe('restartStackServices', () => { ['--user', 'is-active', '--quiet', 'ejclaw'], { stdio: 'ignore' }, ); - expect(execFileSyncImpl).toHaveBeenNthCalledWith( - 3, - 'systemctl', - ['--user', 'is-active', '--quiet', 'ejclaw-codex'], - { stdio: 'ignore' }, - ); - expect(execFileSyncImpl).toHaveBeenNthCalledWith( - 4, - 'systemctl', - ['--user', 'is-active', '--quiet', 'ejclaw-review'], - { stdio: 'ignore' }, - ); + expect(execFileSyncImpl).toHaveBeenCalledTimes(2); }); it('rejects non-systemd environments', () => { @@ -97,11 +82,34 @@ describe('restartStackServices', () => { ).toThrow('restart:stack only supports Linux systemd services in this repo'); }); + it('fails fast when legacy services are still present', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); + tempRoots.push(tempRoot); + + const execFileSyncImpl = vi.fn(); + + expect(() => + restartStackServices(tempRoot, { + direct: true, + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + serviceId: null, + legacyServiceIssues: [ + { + name: 'ejclaw-codex', + status: 'stopped', + sources: ['systemd-system'], + }, + ], + }), + ).toThrow('Legacy EJClaw multi-service install detected'); + expect(execFileSyncImpl).not.toHaveBeenCalled(); + }); + it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); - fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n'); const execFileSyncImpl = vi .fn() @@ -120,7 +128,7 @@ describe('restartStackServices', () => { serviceId: null, }); - expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(services).toEqual(['ejclaw']); expect(execFileSyncImpl).toHaveBeenNthCalledWith( 1, 'systemctl', @@ -130,7 +138,7 @@ describe('restartStackServices', () => { expect(execFileSyncImpl).toHaveBeenNthCalledWith( 2, 'systemctl', - ['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'], + ['--user', 'restart', 'ejclaw'], { stdio: 'ignore' }, ); }); @@ -138,7 +146,6 @@ describe('restartStackServices', () => { it('rejects direct fallback when the unit is missing for a managed service caller', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); const execFileSyncImpl = vi.fn().mockImplementation(() => { const error = new Error('unit missing'); @@ -161,7 +168,6 @@ describe('restartStackServices', () => { it('does not hide a general start failure behind direct fallback', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); const execFileSyncImpl = vi.fn().mockImplementation(() => { const error = new Error('job failed'); diff --git a/setup/restart-stack.ts b/setup/restart-stack.ts index c3fc44d..b7d4ca5 100644 --- a/setup/restart-stack.ts +++ b/setup/restart-stack.ts @@ -1,6 +1,12 @@ import { execFileSync } from 'child_process'; +import os from 'os'; import { pathToFileURL } from 'url'; +import { + detectLegacyServiceIssues, + formatLegacyServiceFailureMessage, + type LegacyServiceIssue, +} from './legacy-service-guard.js'; import { getServiceManager, isRoot } from './platform.js'; import { getConfiguredServiceNames } from './service-defs.js'; @@ -17,6 +23,8 @@ interface RestartStackDeps { serviceManager?: ServiceManager; direct?: boolean; serviceId?: string | null; + legacyServiceIssues?: LegacyServiceIssue[]; + homeDir?: string; } function restartStackServicesDirect( @@ -62,6 +70,24 @@ export function restartStackServices( ); } + const legacyServiceIssues = + deps.legacyServiceIssues ?? + detectLegacyServiceIssues( + projectRoot, + serviceManager, + deps.homeDir ?? os.homedir(), + ); + if (legacyServiceIssues.length > 0) { + throw new Error( + formatLegacyServiceFailureMessage({ + projectRoot, + serviceManager, + services: legacyServiceIssues, + homeDir: deps.homeDir, + }), + ); + } + const services = getConfiguredServiceNames(projectRoot); const execImpl = deps.execFileSyncImpl ?? execFileSync; const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; diff --git a/setup/service-defs.ts b/setup/service-defs.ts index 997e2f1..cf3ecd6 100644 --- a/setup/service-defs.ts +++ b/setup/service-defs.ts @@ -1,7 +1,4 @@ -import fs from 'fs'; -import path from 'path'; - -export type ServiceKind = 'primary' | 'codex' | 'review'; +export type ServiceKind = 'primary' | 'legacy'; export interface ServiceDef { /** Stable topology kind used by setup/verify logic */ @@ -26,11 +23,9 @@ interface ServiceTemplate { launchdLabel: string; description: string; logName: string; - envFileName?: string; - assistantName?: string; } -const SERVICE_TEMPLATES: ServiceTemplate[] = [ +const CURRENT_SERVICE_TEMPLATES: ServiceTemplate[] = [ { kind: 'primary', name: 'ejclaw', @@ -38,37 +33,27 @@ const SERVICE_TEMPLATES: ServiceTemplate[] = [ description: 'EJClaw Personal Assistant (Claude Code)', logName: 'ejclaw', }, +]; + +const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [ { - kind: 'codex', + kind: 'legacy', name: 'ejclaw-codex', launchdLabel: 'com.ejclaw-codex', - description: 'EJClaw Codex Assistant', + description: 'Legacy EJClaw Codex Assistant', logName: 'ejclaw-codex', - envFileName: '.env.codex', - assistantName: 'codex', }, { - kind: 'review', + kind: 'legacy', name: 'ejclaw-review', launchdLabel: 'com.ejclaw-review', - description: 'EJClaw Codex Review Assistant', + description: 'Legacy EJClaw Codex Review Assistant', logName: 'ejclaw-review', - envFileName: '.env.codex-review', - assistantName: 'codex', }, ]; -function materializeServiceDef( - projectRoot: string, - template: ServiceTemplate, -): ServiceDef | null { - const environmentFile = template.envFileName - ? path.join(projectRoot, template.envFileName) - : undefined; - - if (environmentFile && !fs.existsSync(environmentFile)) { - return null; - } +function materializeServiceDef(template: ServiceTemplate): ServiceDef { + const environmentFile = undefined; return { kind: template.kind, @@ -77,21 +62,28 @@ function materializeServiceDef( description: template.description, logName: template.logName, environmentFile, - extraEnv: template.assistantName - ? { - ASSISTANT_NAME: template.assistantName, - } - : undefined, + extraEnv: undefined, }; } export function getServiceDefs(projectRoot: string): ServiceDef[] { - return SERVICE_TEMPLATES.flatMap((template) => { - const def = materializeServiceDef(projectRoot, template); - return def ? [def] : []; - }); + void projectRoot; + return CURRENT_SERVICE_TEMPLATES.map((template) => + materializeServiceDef(template), + ); +} + +export function getLegacyServiceDefs(projectRoot: string): ServiceDef[] { + void projectRoot; + return LEGACY_SERVICE_TEMPLATES.map((template) => + materializeServiceDef(template), + ); } export function getConfiguredServiceNames(projectRoot: string): string[] { return getServiceDefs(projectRoot).map((def) => def.name); } + +export function getLegacyServiceNames(projectRoot: string): string[] { + return getLegacyServiceDefs(projectRoot).map((def) => def.name); +} diff --git a/setup/service.test.ts b/setup/service.test.ts index 89a62d2..cb7f44e 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -9,7 +9,11 @@ import { buildStackRestartSystemdUnit, buildSystemdUnit, } from './service-renderers.js'; -import { getServiceDefs, type ServiceDef } from './service-defs.js'; +import { + getLegacyServiceDefs, + getServiceDefs, + type ServiceDef, +} from './service-defs.js'; /** * Tests for service configuration generation. @@ -127,19 +131,18 @@ describe('systemd unit generation', () => { const unit = buildSystemdUnit( { ...baseServiceDef, - kind: 'codex', - environmentFile: '/srv/ejclaw/.env.codex', + kind: 'primary', + environmentFile: '/srv/ejclaw/.env.extra', extraEnv: { ASSISTANT_NAME: 'codex' }, - logName: 'ejclaw-codex', - name: 'ejclaw-codex', + logName: 'ejclaw', + name: 'ejclaw', }, '/srv/ejclaw', '/usr/bin/bun', '/home/user', false, ); - - expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.codex'); + expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.extra'); expect(unit).toContain('Environment=ASSISTANT_NAME=codex'); }); }); @@ -173,24 +176,27 @@ describe('service definitions', () => { } }); - it('includes the review service when .env.codex-review exists', () => { + it('returns only the unified service definition', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-')); tempRoots.push(tempRoot); - fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n'); - fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n'); const defs = getServiceDefs(tempRoot); + expect(defs.map((def) => def.name)).toEqual(['ejclaw']); + expect(defs.map((def) => def.kind)).toEqual(['primary']); + }); + + it('keeps legacy service identities available for migration guards', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-')); + tempRoots.push(tempRoot); + + const defs = getLegacyServiceDefs(tempRoot); + expect(defs.map((def) => def.name)).toEqual([ - 'ejclaw', 'ejclaw-codex', 'ejclaw-review', ]); - expect(defs.map((def) => def.kind)).toEqual([ - 'primary', - 'codex', - 'review', - ]); + expect(defs.map((def) => def.kind)).toEqual(['legacy', 'legacy']); }); it('generates a oneshot stack restart unit', () => { diff --git a/setup/service.ts b/setup/service.ts index 97bde08..26e82d0 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -2,10 +2,8 @@ * Step: service — Generate and load service manager config. * Replaces 08-setup-service.sh * - * Supports the EJClaw service stack: - * - ejclaw (Claude Code) — always installed - * - ejclaw-codex (Codex) — installed when .env.codex exists - * - ejclaw-review (Codex Review) — installed when .env.codex-review exists + * Supports the unified EJClaw service: + * - ejclaw — always installed */ import { execSync } from 'child_process'; import fs from 'fs'; @@ -13,7 +11,11 @@ import os from 'os'; import path from 'path'; import { logger } from '../src/logger.js'; -import { getPlatform, getNodePath } from './platform.js'; +import { getPlatform, getNodePath, getServiceManager } from './platform.js'; +import { + detectLegacyServiceIssues, + formatLegacyServiceFailureMessage, +} from './legacy-service-guard.js'; import { getServiceDefs } from './service-defs.js'; import { setupLaunchd, setupLinux } from './service-installers.js'; import { emitStatus } from './status.js'; @@ -27,9 +29,41 @@ export async function run(_args: string[]): Promise { const platform = getPlatform(); const nodePath = getNodePath(); const homeDir = os.homedir(); + const serviceManager = getServiceManager(); logger.info({ platform, nodePath, projectRoot }, 'Setting up service'); + const legacyServiceIssues = detectLegacyServiceIssues( + projectRoot, + serviceManager, + homeDir, + ); + if (legacyServiceIssues.length > 0) { + const errorMessage = formatLegacyServiceFailureMessage({ + projectRoot, + serviceManager, + homeDir, + services: legacyServiceIssues, + }); + logger.error( + { legacyServiceIssues, serviceManager }, + 'Legacy multi-service install detected during setup', + ); + emitStatus('SETUP_SERVICE', { + SERVICE_TYPE: serviceManager, + NODE_PATH: nodePath, + PROJECT_PATH: projectRoot, + STATUS: 'failed', + ERROR: 'legacy_services_detected', + LEGACY_SERVICES: legacyServiceIssues + .map((service) => `${service.name}:${service.status}`) + .join(','), + LOG: 'logs/setup.log', + }); + console.error(errorMessage); + process.exit(1); + } + // Build first logger.info('Building TypeScript'); try { @@ -54,14 +88,6 @@ export async function run(_args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); const serviceDefs = getServiceDefs(projectRoot); - for (const def of serviceDefs) { - if (def.kind === 'primary' || !def.environmentFile) { - continue; - } - logger.info( - `Detected ${path.basename(def.environmentFile)} — will also install ${def.name} service`, - ); - } if (platform === 'macos') { for (const def of serviceDefs) { diff --git a/setup/verify-services.test.ts b/setup/verify-services.test.ts index 7688350..cc8d25d 100644 --- a/setup/verify-services.test.ts +++ b/setup/verify-services.test.ts @@ -18,8 +18,11 @@ vi.mock('./platform.js', () => ({ import { checkLaunchdService, + checkLaunchdServiceArtifact, checkNohupService, + checkNohupServiceArtifact, checkSystemdService, + checkSystemdServiceInScope, getServiceChecks, } from './verify-services.js'; import type { ServiceDef } from './service-defs.js'; @@ -54,6 +57,43 @@ describe('verify service checks', () => { }); }); + it('checks systemd services in both explicit scopes', () => { + execSyncMock.mockImplementation((cmd: string) => { + if (cmd === 'systemctl is-active ejclaw-codex') { + return undefined; + } + if (cmd === 'systemctl --user is-active ejclaw-codex') { + throw new Error('inactive'); + } + if (cmd === 'systemctl --user list-unit-files') { + return ''; + } + throw new Error(`unexpected command: ${cmd}`); + }); + + expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running'); + expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found'); + }); + + it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-launchd-')); + const plistPath = path.join( + tempHome, + 'Library', + 'LaunchAgents', + 'com.ejclaw-codex.plist', + ); + fs.mkdirSync(path.dirname(plistPath), { recursive: true }); + fs.writeFileSync(plistPath, ''); + execSyncMock.mockReturnValue(''); + + expect( + checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath), + ).toBe('stopped'); + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + it('treats known but inactive systemd services as stopped', () => { execSyncMock .mockImplementationOnce(() => { @@ -79,6 +119,15 @@ describe('verify service checks', () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); + it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n'); + + expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped'); + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + it('builds per-service status checks from service definitions', () => { const defs: ServiceDef[] = [ { @@ -89,18 +138,18 @@ describe('verify service checks', () => { logName: 'ejclaw', }, { - kind: 'codex', - name: 'ejclaw-codex', - description: 'Codex', - launchdLabel: 'com.ejclaw.codex', - logName: 'ejclaw-codex', + kind: 'primary', + name: 'ejclaw-secondary', + description: 'Secondary', + launchdLabel: 'com.ejclaw.secondary', + logName: 'ejclaw-secondary', }, ]; execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n'); expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([ { name: 'ejclaw', status: 'running' }, - { name: 'ejclaw-codex', status: 'not_found' }, + { name: 'ejclaw-secondary', status: 'not_found' }, ]); }); }); diff --git a/setup/verify-services.ts b/setup/verify-services.ts index c5fabc1..dfc0a67 100644 --- a/setup/verify-services.ts +++ b/setup/verify-services.ts @@ -16,6 +16,13 @@ export interface ServiceCheck { status: ServiceStatus; } +export interface ServiceCheckOptions { + detectArtifacts?: boolean; + homeDir?: string; +} + +export type SystemdScope = 'system' | 'user'; + export function checkLaunchdService(label: string): ServiceStatus { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); @@ -32,8 +39,26 @@ export function checkLaunchdService(label: string): ServiceStatus { return 'not_found'; } -export function checkSystemdService(name: string): ServiceStatus { - const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; +function getLaunchdPlistPath(homeDir: string, launchdLabel: string): string { + return path.join(homeDir, 'Library', 'LaunchAgents', `${launchdLabel}.plist`); +} + +export function checkLaunchdServiceArtifact( + label: string, + plistPath: string, +): ServiceStatus { + const status = checkLaunchdService(label); + if (status !== 'not_found') { + return status; + } + return fs.existsSync(plistPath) ? 'stopped' : 'not_found'; +} + +export function checkSystemdServiceInScope( + name: string, + scope: SystemdScope, +): ServiceStatus { + const prefix = scope === 'system' ? 'systemctl' : 'systemctl --user'; try { execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' }); return 'running'; @@ -52,6 +77,10 @@ export function checkSystemdService(name: string): ServiceStatus { return 'not_found'; } +export function checkSystemdService(name: string): ServiceStatus { + return checkSystemdServiceInScope(name, isRoot() ? 'system' : 'user'); +} + export function checkNohupService( projectRoot: string, serviceName: string, @@ -72,18 +101,40 @@ export function checkNohupService( return 'not_found'; } +export function checkNohupServiceArtifact( + projectRoot: string, + serviceName: string, +): ServiceStatus { + const status = checkNohupService(projectRoot, serviceName); + if (status !== 'not_found') { + return status; + } + const wrapperPath = path.join(projectRoot, `start-${serviceName}.sh`); + return fs.existsSync(wrapperPath) ? 'stopped' : 'not_found'; +} + export function checkService( projectRoot: string, serviceManager: 'launchd' | 'systemd' | 'none', serviceName: string, launchdLabel: string, + options: ServiceCheckOptions = {}, ): ServiceStatus { if (serviceManager === 'launchd') { + if (options.detectArtifacts && options.homeDir) { + return checkLaunchdServiceArtifact( + launchdLabel, + getLaunchdPlistPath(options.homeDir, launchdLabel), + ); + } return checkLaunchdService(launchdLabel); } if (serviceManager === 'systemd') { return checkSystemdService(serviceName); } + if (options.detectArtifacts) { + return checkNohupServiceArtifact(projectRoot, serviceName); + } return checkNohupService(projectRoot, serviceName); } @@ -91,6 +142,7 @@ export function getServiceChecks( serviceDefs: ServiceDef[], projectRoot: string, serviceManager: 'launchd' | 'systemd' | 'none', + options: ServiceCheckOptions = {}, ): ServiceCheck[] { return serviceDefs.map((def) => ({ name: def.name, @@ -99,6 +151,7 @@ export function getServiceChecks( serviceManager, def.name, def.launchdLabel, + options, ), })); } diff --git a/setup/verify-state.test.ts b/setup/verify-state.test.ts index 24a7417..0b59b37 100644 --- a/setup/verify-state.test.ts +++ b/setup/verify-state.test.ts @@ -11,7 +11,9 @@ import { buildVerifySummary, detectChannelAuth, detectCredentials, + detectLegacyDiscordTokenKeys, loadRegisteredGroupsSummary, + loadRoleRoutingRequirementsSummary, } from './verify-state.js'; describe('verify state helpers', () => { @@ -34,14 +36,88 @@ describe('verify state helpers', () => { expect(detectCredentials(tempRoot)).toBe('configured'); }); - it('detects channel auth from either env source', () => { + it('detects canonical role-based channel auth names from process env', () => { expect( detectChannelAuth( - { DISCORD_BOT_TOKEN: '' }, - { DISCORD_BOT_TOKEN: 'discord-token' }, + {}, + { + DISCORD_OWNER_BOT_TOKEN: 'owner-token', + DISCORD_REVIEWER_BOT_TOKEN: 'reviewer-token', + DISCORD_ARBITER_BOT_TOKEN: 'arbiter-token', + }, ), ).toEqual({ discord: 'configured', + 'discord-review': 'configured', + 'discord-arbiter': 'configured', + }); + }); + + it('does not treat legacy service-based channel auth names as configured channels', () => { + expect( + detectChannelAuth( + {}, + { + DISCORD_CLAUDE_BOT_TOKEN: 'legacy-owner-token', + DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token', + DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token', + }, + ), + ).toEqual({}); + }); + + it('detects legacy service-based discord token names from env file and process env', () => { + expect( + detectLegacyDiscordTokenKeys( + { + DISCORD_BOT_TOKEN: 'legacy-owner-token', + }, + { + DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token', + DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token', + }, + ), + ).toEqual([ + 'DISCORD_BOT_TOKEN', + 'DISCORD_CODEX_MAIN_BOT_TOKEN', + 'DISCORD_CODEX_REVIEW_BOT_TOKEN', + ]); + }); + + it('loads paired-room routing requirements from the sqlite store', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + const dbPath = path.join(tempRoot, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + agent_type TEXT + ); + `); + db.exec(` + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + status TEXT NOT NULL + ); + `); + db.exec(` + INSERT INTO registered_groups (jid, agent_type) VALUES + ('group-1', 'claude-code'), + ('group-1', 'codex'), + ('group-2', 'claude-code'); + `); + db.exec(` + INSERT INTO paired_tasks (id, chat_jid, status) VALUES + ('task-1', 'group-1', 'arbiter_requested'), + ('task-2', 'group-2', 'completed'); + `); + db.close(); + + expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({ + tribunalRooms: 1, + activeArbiterTasks: 1, }); }); @@ -83,13 +159,6 @@ describe('verify state helpers', () => { launchdLabel: 'com.ejclaw', logName: 'ejclaw', }, - { - kind: 'codex', - name: 'ejclaw-codex', - description: 'Codex', - launchdLabel: 'com.ejclaw.codex', - logName: 'ejclaw-codex', - }, ]; expect( @@ -97,15 +166,19 @@ describe('verify state helpers', () => { services, serviceDefs, 'configured', - { discord: 'configured' }, + { + discord: 'configured', + 'discord-review': 'configured', + 'discord-arbiter': 'configured', + }, 2, { codex: 1 }, ), ).toMatchObject({ status: 'success', - configuredChannels: ['discord'], + configuredChannels: ['discord', 'discord-review', 'discord-arbiter'], codexConfigured: true, - reviewConfigured: false, + reviewConfigured: true, servicesSummary: { ejclaw: 'running' }, }); }); @@ -128,4 +201,95 @@ describe('verify state helpers', () => { servicesSummary: { ejclaw: 'stopped' }, }); }); + + it('fails verification when only the review channel is configured', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { 'discord-review': 'configured' }, + 1, + {}, + ), + ).toMatchObject({ + status: 'failed', + configuredChannels: ['discord-review'], + codexConfigured: true, + reviewConfigured: false, + servicesSummary: { ejclaw: 'running' }, + }); + }); + + it('fails verification when tribunal rooms exist but the reviewer channel is missing', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { discord: 'configured' }, + 1, + {}, + { tribunalRooms: 1 }, + ), + ).toMatchObject({ + status: 'failed', + configuredChannels: ['discord'], + codexConfigured: false, + reviewConfigured: false, + tribunalRooms: 1, + }); + }); + + it('fails verification when arbiter work is pending but the arbiter channel is missing', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { + discord: 'configured', + 'discord-review': 'configured', + }, + 1, + {}, + { tribunalRooms: 1, activeArbiterTasks: 1 }, + ), + ).toMatchObject({ + status: 'failed', + configuredChannels: ['discord', 'discord-review'], + codexConfigured: true, + reviewConfigured: false, + activeArbiterTasks: 1, + }); + }); + + it('fails verification when legacy discord token names are still configured', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { + discord: 'configured', + 'discord-review': 'configured', + 'discord-arbiter': 'configured', + }, + 1, + {}, + { legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'] }, + ), + ).toMatchObject({ + status: 'failed', + legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'], + }); + }); }); diff --git a/setup/verify-state.ts b/setup/verify-state.ts index 0f3ac6f..349b502 100644 --- a/setup/verify-state.ts +++ b/setup/verify-state.ts @@ -11,16 +11,34 @@ import type { ServiceCheck } from './verify-services.js'; export type CredentialsStatus = 'configured' | 'missing'; export type VerifyStatus = 'success' | 'failed'; +const LEGACY_DISCORD_TOKEN_KEYS = [ + 'DISCORD_BOT_TOKEN', + 'DISCORD_CODEX_BOT_TOKEN', + 'DISCORD_REVIEW_BOT_TOKEN', + 'DISCORD_CLAUDE_BOT_TOKEN', + 'DISCORD_CODEX_MAIN_BOT_TOKEN', + 'DISCORD_CODEX_REVIEW_BOT_TOKEN', +]; + export interface RegisteredGroupsSummary { registeredGroups: number; groupsByAgent: Record; } +export interface RoleRoutingRequirementsSummary { + tribunalRooms: number; + activeArbiterTasks: number; +} + export interface VerifySummary extends RegisteredGroupsSummary { status: VerifyStatus; servicesSummary: Record; configuredChannels: string[]; channelAuth: Record; + legacyDiscordTokenKeys: string[]; + tribunalRooms: number; + activeArbiterTasks: number; + // Legacy status fields kept for backward-compatible setup output. codexConfigured: boolean; reviewConfigured: boolean; } @@ -38,18 +56,39 @@ export function detectCredentials(projectRoot: string): CredentialsStatus { } export function detectChannelAuth( - envVars = readEnvFile(['DISCORD_BOT_TOKEN']), + envVars = readEnvFile([ + 'DISCORD_OWNER_BOT_TOKEN', + 'DISCORD_REVIEWER_BOT_TOKEN', + 'DISCORD_ARBITER_BOT_TOKEN', + ]), processEnv: NodeJS.ProcessEnv = process.env, ): Record { const channelAuth: Record = {}; - if (processEnv.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { + const hasEnv = (key: string): boolean => !!(processEnv[key] || envVars[key]); + + if (hasEnv('DISCORD_OWNER_BOT_TOKEN')) { channelAuth.discord = 'configured'; } + if (hasEnv('DISCORD_REVIEWER_BOT_TOKEN')) { + channelAuth['discord-review'] = 'configured'; + } + if (hasEnv('DISCORD_ARBITER_BOT_TOKEN')) { + channelAuth['discord-arbiter'] = 'configured'; + } return channelAuth; } +export function detectLegacyDiscordTokenKeys( + envVars = readEnvFile(LEGACY_DISCORD_TOKEN_KEYS), + processEnv: NodeJS.ProcessEnv = process.env, +): string[] { + return LEGACY_DISCORD_TOKEN_KEYS.filter( + (key) => !!(processEnv[key] || envVars[key]), + ); +} + export function loadRegisteredGroupsSummary( dbPath = path.join(STORE_DIR, 'messages.db'), ): RegisteredGroupsSummary { @@ -88,6 +127,82 @@ export function loadRegisteredGroupsSummary( return { registeredGroups, groupsByAgent }; } +export function loadRoleRoutingRequirementsSummary( + dbPath = path.join(STORE_DIR, 'messages.db'), +): RoleRoutingRequirementsSummary { + let tribunalRooms = 0; + let activeArbiterTasks = 0; + + if (!fs.existsSync(dbPath)) { + return { tribunalRooms, activeArbiterTasks }; + } + + try { + const db = new Database(dbPath, { readonly: true }); + + try { + const roomModeRow = db + .prepare( + ` + SELECT COUNT(*) as count + FROM ( + SELECT chat_jid AS jid + FROM room_settings + WHERE room_mode = 'tribunal' + UNION + SELECT jid + FROM registered_groups + GROUP BY jid + HAVING COUNT(DISTINCT agent_type) > 1 + ) + `, + ) + .get() as { count: number }; + tribunalRooms = roomModeRow.count; + } catch { + try { + const fallbackRow = db + .prepare( + ` + SELECT COUNT(*) as count + FROM ( + SELECT jid + FROM registered_groups + GROUP BY jid + HAVING COUNT(DISTINCT agent_type) > 1 + ) + `, + ) + .get() as { count: number }; + tribunalRooms = fallbackRow.count; + } catch { + tribunalRooms = 0; + } + } + + try { + const arbiterRow = db + .prepare( + ` + SELECT COUNT(*) as count + FROM paired_tasks + WHERE status IN ('arbiter_requested', 'in_arbitration') + `, + ) + .get() as { count: number }; + activeArbiterTasks = arbiterRow.count; + } catch { + activeArbiterTasks = 0; + } + + db.close(); + } catch { + // Tables might not exist yet + } + + return { tribunalRooms, activeArbiterTasks }; +} + export function buildVerifySummary( services: ServiceCheck[], serviceDefs: ServiceDef[], @@ -95,22 +210,33 @@ export function buildVerifySummary( channelAuth: Record, registeredGroups: number, groupsByAgent: Record, + options: { + legacyDiscordTokenKeys?: string[]; + tribunalRooms?: number; + activeArbiterTasks?: number; + } = {}, ): VerifySummary { + void serviceDefs; const configuredChannels = Object.keys(channelAuth); const allConfiguredServicesRunning = services.every( (service) => service.status === 'running', ); - const codexConfigured = serviceDefs.some( - (service) => service.kind === 'codex', - ); - const reviewConfigured = serviceDefs.some( - (service) => service.kind === 'review', - ); + const hasOwnerCapableChannel = 'discord' in channelAuth; + const codexConfigured = 'discord-review' in channelAuth; + const reviewConfigured = 'discord-arbiter' in channelAuth; + const legacyDiscordTokenKeys = options.legacyDiscordTokenKeys ?? []; + const tribunalRooms = options.tribunalRooms ?? 0; + const activeArbiterTasks = options.activeArbiterTasks ?? 0; + const reviewerConfigured = tribunalRooms === 0 || codexConfigured; + const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured; const status = allConfiguredServicesRunning && credentials === 'configured' && - configuredChannels.length > 0 && + hasOwnerCapableChannel && + legacyDiscordTokenKeys.length === 0 && + reviewerConfigured && + arbiterConfigured && registeredGroups > 0 ? 'success' : 'failed'; @@ -125,6 +251,9 @@ export function buildVerifySummary( servicesSummary, configuredChannels, channelAuth, + legacyDiscordTokenKeys, + tribunalRooms, + activeArbiterTasks, registeredGroups, groupsByAgent, codexConfigured, diff --git a/setup/verify.ts b/setup/verify.ts index d710a1b..52df47c 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -2,14 +2,18 @@ * Step: verify — End-to-end health check of the full installation. * Replaces 09-verify.sh * - * Supports the EJClaw service stack: - * - ejclaw (Claude Code) — always checked - * - ejclaw-codex (Codex) — checked when .env.codex exists - * - ejclaw-review (Codex Review) — checked when .env.codex-review exists + * Supports the unified EJClaw service: + * - ejclaw — always checked * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ +import os from 'os'; + import { logger } from '../src/logger.js'; +import { + detectLegacyServiceIssues, + formatLegacyServiceFailureMessage, +} from './legacy-service-guard.js'; import { getServiceManager } from './platform.js'; import { getServiceDefs } from './service-defs.js'; import { emitStatus } from './status.js'; @@ -17,7 +21,9 @@ import { buildVerifySummary, detectChannelAuth, detectCredentials, + detectLegacyDiscordTokenKeys, loadRegisteredGroupsSummary, + loadRoleRoutingRequirementsSummary, } from './verify-state.js'; import { getServiceChecks } from './verify-services.js'; @@ -34,18 +40,35 @@ export async function run(_args: string[]): Promise { // 1. Check service statuses const serviceDefs = getServiceDefs(projectRoot); const services = getServiceChecks(serviceDefs, projectRoot, mgr); + const legacyServiceIssues = detectLegacyServiceIssues( + projectRoot, + mgr, + os.homedir(), + ); for (const svc of services) { logger.info({ service: svc.name, status: svc.status }, 'Service status'); } + for (const svc of legacyServiceIssues) { + logger.error( + { service: svc.name, status: svc.status }, + 'Legacy service detected during verification', + ); + } const credentials = detectCredentials(projectRoot); const channelAuth = detectChannelAuth(); + const legacyDiscordTokenKeys = detectLegacyDiscordTokenKeys(); const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary(); + const { tribunalRooms, activeArbiterTasks } = + loadRoleRoutingRequirementsSummary(); const { - status, + status: baseStatus, servicesSummary, configuredChannels, + legacyDiscordTokenKeys: legacyDiscordTokens, + tribunalRooms: detectedTribunalRooms, + activeArbiterTasks: detectedActiveArbiterTasks, codexConfigured, reviewConfigured, } = buildVerifySummary( @@ -55,17 +78,64 @@ export async function run(_args: string[]): Promise { channelAuth, registeredGroups, groupsByAgent, + { + legacyDiscordTokenKeys, + tribunalRooms, + activeArbiterTasks, + }, ); + const legacyServicesSummary = Object.fromEntries( + legacyServiceIssues.map((service) => [service.name, service.status]), + ); + const status = legacyServiceIssues.length > 0 ? 'failed' : baseStatus; - logger.info({ status, channelAuth, servicesSummary }, 'Verification complete'); + logger.info( + { + status, + channelAuth, + legacyDiscordTokens, + tribunalRooms: detectedTribunalRooms, + activeArbiterTasks: detectedActiveArbiterTasks, + servicesSummary, + legacyServicesSummary, + }, + 'Verification complete', + ); + if (legacyDiscordTokens.length > 0) { + logger.error( + { + legacyDiscordTokens, + migration: + 'Rename Discord bot tokens to DISCORD_OWNER_BOT_TOKEN / DISCORD_REVIEWER_BOT_TOKEN / DISCORD_ARBITER_BOT_TOKEN', + }, + 'Verification failed due to legacy service-based Discord bot token names', + ); + } + if (legacyServiceIssues.length > 0) { + logger.error( + { + cleanup: formatLegacyServiceFailureMessage({ + projectRoot, + serviceManager: mgr, + homeDir: os.homedir(), + services: legacyServiceIssues, + }), + }, + 'Verification failed due to legacy multi-service install', + ); + } emitStatus('VERIFY', { SERVICES: JSON.stringify(servicesSummary), + LEGACY_SERVICES: JSON.stringify(legacyServicesSummary), // Legacy field (keep for backward compatibility) SERVICE: services[0].status, CREDENTIALS: credentials, CONFIGURED_CHANNELS: configuredChannels.join(','), CHANNEL_AUTH: JSON.stringify(channelAuth), + LEGACY_DISCORD_TOKENS: legacyDiscordTokens.join(','), + TRIBUNAL_ROOMS: detectedTribunalRooms, + ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks, REGISTERED_GROUPS: registeredGroups, GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), CODEX_CONFIGURED: codexConfigured, diff --git a/src/agent-error-detection.ts b/src/agent-error-detection.ts index bbfdbb7..c743cbd 100644 --- a/src/agent-error-detection.ts +++ b/src/agent-error-detection.ts @@ -99,6 +99,7 @@ export type AgentTriggerReason = | 'usage-exhausted' | 'auth-expired' | 'org-access-denied' + | 'session-failure' | 'overloaded' | 'network-error' | 'success-null-result'; diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 4b1a653..a141ff1 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -42,6 +42,7 @@ vi.mock('./service-routing.js', () => ({ owner_service_id: 'claude', reviewer_service_id: 'codex-main', arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -77,7 +78,10 @@ vi.mock('os', async () => { }; }); -import { prepareGroupEnvironment } from './agent-runner-environment.js'; +import { + prepareContainerSessionEnvironment, + prepareGroupEnvironment, +} from './agent-runner-environment.js'; import * as config from './config.js'; import * as serviceRouting from './service-routing.js'; import type { RegisteredGroup } from './types.js'; @@ -202,8 +206,9 @@ describe('prepareGroupEnvironment codex auth handling', () => { vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'dc:test', owner_service_id: 'codex-review', - reviewer_service_id: 'codex-main', + reviewer_service_id: 'claude', arbiter_service_id: null, + owner_failover_active: true, activated_at: '2026-03-28T00:00:00.000Z', reason: 'claude-429', explicit: true, @@ -309,6 +314,7 @@ describe('prepareGroupEnvironment codex auth handling', () => { owner_service_id: 'claude', reviewer_service_id: 'codex-main', arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -334,3 +340,67 @@ describe('prepareGroupEnvironment codex auth handling', () => { expect(segments).toEqual(['platform prompt']); }); }); + +describe('prepareContainerSessionEnvironment codex compatibility', () => { + let tempRoot: string; + let previousCwd: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-container-env-')); + previousCwd = process.cwd(); + process.chdir(tempRoot); + + process.env.EJ_TEST_ROOT = tempRoot; + process.env.EJ_TEST_HOME = path.join(tempRoot, 'home'); + + fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true }); + fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), { + recursive: true, + }); + + mockReadEnvFile.mockReset(); + mockGetActiveCodexAuthPath.mockReset(); + }); + + afterEach(() => { + process.chdir(previousCwd); + delete process.env.EJ_TEST_ROOT; + delete process.env.EJ_TEST_HOME; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('writes matching AGENTS.md and copies host codex auth/config into the container session', () => { + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + + const promptsDir = path.join(tempRoot, 'prompts'); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(process.env.EJ_TEST_HOME!, '.codex', 'auth.json'), + '{"auth_mode":"chatgpt"}\n', + ); + fs.writeFileSync( + path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'), + 'model = "gpt-5.4"\n', + ); + + const sessionDir = path.join(tempRoot, 'container-reviewer-session'); + prepareContainerSessionEnvironment({ + sessionDir, + chatJid: 'dc:test', + isMain: false, + memoryBriefing: 'memory briefing', + role: 'reviewer', + }); + + const claudeMd = fs.readFileSync(path.join(sessionDir, 'CLAUDE.md'), 'utf-8'); + expect(fs.readFileSync(path.join(sessionDir, '.codex', 'AGENTS.md'), 'utf-8')).toBe( + claudeMd, + ); + expect( + fs.readFileSync(path.join(sessionDir, '.codex', 'auth.json'), 'utf-8'), + ).toContain('"auth_mode":"chatgpt"'); + expect( + fs.readFileSync(path.join(sessionDir, '.codex', 'config.toml'), 'utf-8'), + ).toContain('model = "gpt-5.4"'); + }); +}); diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 0cae6cc..edb311c 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -3,7 +3,6 @@ import os from 'os'; import path from 'path'; import { - CODEX_REVIEW_SERVICE_ID, GROUPS_DIR, SERVICE_ID, SERVICE_SESSION_SCOPE, @@ -78,6 +77,31 @@ function ensureClaudeSessionSettings(groupSessionsDir: string): void { ); } +function syncHostCodexSessionFiles(sessionCodexDir: string): void { + const hostCodexDir = path.join(os.homedir(), '.codex'); + fs.mkdirSync(sessionCodexDir, { recursive: true }); + + const authDst = path.join(sessionCodexDir, 'auth.json'); + const rotatedAuthSrc = getActiveCodexAuthPath(); + const authSrc = + rotatedAuthSrc && fs.existsSync(rotatedAuthSrc) + ? rotatedAuthSrc + : path.join(hostCodexDir, 'auth.json'); + if (fs.existsSync(authSrc)) { + fs.copyFileSync(authSrc, authDst); + } else if (fs.existsSync(authDst)) { + fs.unlinkSync(authDst); + } + + for (const file of ['config.toml', 'config.json']) { + const src = path.join(hostCodexDir, file); + const dst = path.join(sessionCodexDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dst); + } + } +} + function buildBaseRunnerEnv(args: { group: RegisteredGroup; chatJid: string; @@ -214,31 +238,8 @@ function prepareCodexSessionEnvironment(args: { process.env.CODEX_EFFORT; if (codexEffort) args.env.CODEX_EFFORT = codexEffort; - const hostCodexDir = path.join(os.homedir(), '.codex'); const sessionCodexDir = path.join(args.sessionRootDir, '.codex'); - fs.mkdirSync(sessionCodexDir, { recursive: true }); - - const authDst = path.join(sessionCodexDir, 'auth.json'); - // Always use OAuth auth from rotated accounts (API key auth removed) - { - const rotatedAuthSrc = getActiveCodexAuthPath(); - const authSrc = - rotatedAuthSrc && fs.existsSync(rotatedAuthSrc) - ? rotatedAuthSrc - : path.join(hostCodexDir, 'auth.json'); - if (fs.existsSync(authSrc)) { - fs.copyFileSync(authSrc, authDst); - } else if (fs.existsSync(authDst)) { - fs.unlinkSync(authDst); - } - } - for (const file of ['config.toml', 'config.json']) { - const src = path.join(hostCodexDir, file); - const dst = path.join(sessionCodexDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, dst); - } - } + syncHostCodexSessionFiles(sessionCodexDir); const overlayPath = path.join(args.groupDir, '.codex', 'config.toml'); const sessionConfigPath = path.join(sessionCodexDir, 'config.toml'); @@ -400,10 +401,10 @@ export function prepareGroupEnvironment( const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); const isPairedRoom = hasReviewerLease(chatJid); const effectiveLease = getEffectiveChannelLease(chatJid); + // Canonical lease state now exposes owner failover directly, so prefer the + // explicit flag over the older CODEX_REVIEW_SERVICE_ID shadow heuristic. const useCodexReviewFailoverPromptPack = - isReviewService(SERVICE_ID) && - effectiveLease.explicit && - effectiveLease.owner_service_id === CODEX_REVIEW_SERVICE_ID; + isReviewService(SERVICE_ID) && effectiveLease.owner_failover_active === true; const ownerCommonPlatformPrompt = readOptionalPromptFile( projectRoot, @@ -553,6 +554,12 @@ export function prepareContainerSessionEnvironment(args: { const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md'); if (sessionClaudeMd) { fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n'); + const sessionCodexDir = path.join(sessionDir, '.codex'); + syncHostCodexSessionFiles(sessionCodexDir); + fs.writeFileSync( + path.join(sessionCodexDir, 'AGENTS.md'), + sessionClaudeMd + '\n', + ); logger.info( { sessionDir, diff --git a/src/arbiter-service-shadow.test.ts b/src/arbiter-service-shadow.test.ts new file mode 100644 index 0000000..f408419 --- /dev/null +++ b/src/arbiter-service-shadow.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + ARBITER_AGENT_TYPE, + ARBITER_SERVICE_ID, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, +} from './config.js'; +import { + _initTestDatabase, + createServiceHandoff, + getChannelOwnerLease, + setChannelOwnerLease, +} from './db.js'; +import { resolveRoleServiceShadow } from './role-service-shadow.js'; +import { + clearGlobalFailover, + getEffectiveChannelLease, + refreshChannelOwnerCache, +} from './service-routing.js'; + +const customArbiterEnabled = + ARBITER_AGENT_TYPE === 'codex' && ARBITER_SERVICE_ID != null; + +describe.skipIf(!customArbiterEnabled)( + 'custom arbiter service shadow', + () => { + beforeEach(() => { + _initTestDatabase(); + clearGlobalFailover(); + refreshChannelOwnerCache(true); + }); + + it('maps codex arbiter shadow to the configured arbiter service id', () => { + expect(resolveRoleServiceShadow('arbiter', 'codex')).toBe( + ARBITER_SERVICE_ID, + ); + }); + + it('preserves the configured arbiter service id in explicit channel leases', () => { + setChannelOwnerLease({ + chat_jid: 'dc:custom-arbiter-lease', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + }); + refreshChannelOwnerCache(true); + + expect(getChannelOwnerLease('dc:custom-arbiter-lease')).toMatchObject({ + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + arbiter_service_id: ARBITER_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + }); + expect(getEffectiveChannelLease('dc:custom-arbiter-lease')).toMatchObject( + { + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + arbiter_service_id: ARBITER_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + }, + ); + }); + + it('uses the configured arbiter service id for derived arbiter handoffs', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:custom-arbiter-handoff', + group_folder: 'custom-arbiter-handoff', + source_role: 'owner', + source_agent_type: 'codex', + target_role: 'arbiter', + target_agent_type: 'codex', + prompt: 'arbiter please decide', + intended_role: 'arbiter', + }); + + expect(handoff).toMatchObject({ + source_service_id: CODEX_MAIN_SERVICE_ID, + target_service_id: ARBITER_SERVICE_ID, + source_role: 'owner', + target_role: 'arbiter', + target_agent_type: 'codex', + }); + }); + }, +); diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index bb7390c..9cba66d 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -825,6 +825,18 @@ describe('DiscordChannel', () => { expect(channel.ownsJid('dc:1234567890123456')).toBe(true); }); + it('can be configured as an outbound-only role bot', () => { + const channel = new DiscordChannel( + 'test-token', + createTestOpts(), + undefined, + 'discord-review', + false, + false, + ); + expect(channel.ownsJid('dc:1234567890123456')).toBe(false); + }); + it('does not own WhatsApp group JIDs', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.ownsJid('12345@g.us')).toBe(false); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index d72a009..d14fdf3 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -24,6 +24,51 @@ import { hasReviewerLease } from '../service-routing.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); +const DISCORD_OWNER_CHANNEL = 'discord'; +const DISCORD_REVIEWER_CHANNEL = 'discord-review'; +const DISCORD_ARBITER_CHANNEL = 'discord-arbiter'; +const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; +const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; +const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; +const DISCORD_OWNER_LEGACY_TOKEN_KEYS = [ + 'DISCORD_BOT_TOKEN', + 'DISCORD_CLAUDE_BOT_TOKEN', +]; +const DISCORD_REVIEWER_LEGACY_TOKEN_KEYS = [ + 'DISCORD_CODEX_BOT_TOKEN', + 'DISCORD_CODEX_MAIN_BOT_TOKEN', +]; +const DISCORD_ARBITER_LEGACY_TOKEN_KEYS = [ + 'DISCORD_REVIEW_BOT_TOKEN', + 'DISCORD_CODEX_REVIEW_BOT_TOKEN', +]; + +function getConfiguredLegacyDiscordTokenKeys(keys: string[]): string[] { + return keys.filter((key) => !!getEnv(key)); +} + +function getRoleTokenOrLogMigrationError( + canonicalKey: string, + legacyKeys: string[], + role: 'owner' | 'reviewer' | 'arbiter', +): string { + const canonicalValue = getEnv(canonicalKey) || ''; + if (canonicalValue) return canonicalValue; + + const configuredLegacyKeys = getConfiguredLegacyDiscordTokenKeys(legacyKeys); + if (configuredLegacyKeys.length > 0) { + logger.error( + { + role, + canonicalKey, + legacyKeys: configuredLegacyKeys, + }, + 'Discord: legacy service-based bot token names are no longer supported; rename them to canonical role-based names', + ); + } + + return ''; +} /** * Download a Discord attachment to local disk. @@ -182,16 +227,22 @@ export class DiscordChannel implements Channel { private typingIntervals = new Map(); private typingGenerations = new Map(); private agentTypeFilter?: AgentType; + private receivesInbound: boolean; + private ownsDiscordJids: boolean; constructor( botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType, channelName?: string, + receivesInbound = true, + ownsDiscordJids = true, ) { this.botToken = botToken; this.opts = opts; this.agentTypeFilter = agentTypeFilter; + this.receivesInbound = receivesInbound; + this.ownsDiscordJids = ownsDiscordJids; if (channelName) { this.name = channelName; } else if (agentTypeFilter) { @@ -212,6 +263,7 @@ export class DiscordChannel implements Channel { this.client.on(Events.MessageCreate, async (message: Message) => { const channelId = message.channelId; const chatJid = `dc:${channelId}`; + if (!this.receivesInbound) return; const isOwnBotMessage = message.author.id === this.client?.user?.id; if (isOwnBotMessage) return; if (message.author.bot && !hasReviewerLease(chatJid)) return; @@ -356,7 +408,8 @@ export class DiscordChannel implements Channel { isGroup, ); - // Only deliver full message for registered groups matching our agent type + // Only deliver full message for registered groups. Secondary role bots + // are configured as outbound-only, while the owner bot receives inbound. const group = this.opts.registeredGroups()[chatJid]; if (!group) { logger.debug( @@ -544,6 +597,7 @@ export class DiscordChannel implements Channel { ownsJid(jid: string): boolean { if (!jid.startsWith('dc:')) return false; + if (!this.ownsDiscordJids) return false; if (!this.agentTypeFilter) return true; const group = this.opts.registeredGroups()[jid]; if (!group) return false; @@ -733,32 +787,66 @@ export class DiscordChannel implements Channel { } } -registerChannel('discord', (opts: ChannelOpts) => { - const token = getEnv('DISCORD_BOT_TOKEN') || ''; +registerChannel(DISCORD_OWNER_CHANNEL, (opts: ChannelOpts) => { + const token = getRoleTokenOrLogMigrationError( + DISCORD_OWNER_TOKEN_KEY, + DISCORD_OWNER_LEGACY_TOKEN_KEYS, + 'owner', + ); if (!token) { - logger.warn('Discord: DISCORD_BOT_TOKEN not set'); + logger.warn( + 'Discord: DISCORD_OWNER_BOT_TOKEN not set', + ); + return null; + } + return new DiscordChannel(token, opts, undefined, DISCORD_OWNER_CHANNEL); +}); + +registerChannel(DISCORD_REVIEWER_CHANNEL, (opts: ChannelOpts) => { + const ownerToken = getEnv(DISCORD_OWNER_TOKEN_KEY) || ''; + const token = getRoleTokenOrLogMigrationError( + DISCORD_REVIEWER_TOKEN_KEY, + DISCORD_REVIEWER_LEGACY_TOKEN_KEYS, + 'reviewer', + ); + if (!token) return null; + if (token === ownerToken) { + logger.warn( + 'Discord: reviewer bot token matches owner bot token; skipping duplicate reviewer bot login', + ); return null; } - // If a second Codex bot token exists, this instance only handles claude-code groups - const hasCodexBot = !!getEnv('DISCORD_CODEX_BOT_TOKEN'); return new DiscordChannel( token, opts, - hasCodexBot ? 'claude-code' : undefined, - 'discord', + undefined, + DISCORD_REVIEWER_CHANNEL, + false, + false, ); }); -// Register the secondary Codex bot channel. -registerChannel('discord-codex', (opts: ChannelOpts) => { - const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || ''; +registerChannel(DISCORD_ARBITER_CHANNEL, (opts: ChannelOpts) => { + const ownerToken = getEnv(DISCORD_OWNER_TOKEN_KEY) || ''; + const reviewerToken = getEnv(DISCORD_REVIEWER_TOKEN_KEY) || ''; + const token = getRoleTokenOrLogMigrationError( + DISCORD_ARBITER_TOKEN_KEY, + DISCORD_ARBITER_LEGACY_TOKEN_KEYS, + 'arbiter', + ); if (!token) return null; - return new DiscordChannel(token, opts, 'codex', 'discord-codex'); -}); - -// Register the review bot channel (codex agent type, separate token). -registerChannel('discord-review', (opts: ChannelOpts) => { - const token = getEnv('DISCORD_REVIEW_BOT_TOKEN') || ''; - if (!token) return null; - return new DiscordChannel(token, opts, 'codex', 'discord-review'); + if (token === ownerToken || token === reviewerToken) { + logger.warn( + 'Discord: arbiter bot token matches another role token; skipping duplicate arbiter bot login', + ); + return null; + } + return new DiscordChannel( + token, + opts, + undefined, + DISCORD_ARBITER_CHANNEL, + false, + false, + ); }); diff --git a/src/config.ts b/src/config.ts index cc4493a..23af224 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,7 +110,7 @@ export const ARBITER_AGENT_TYPE: AgentType | undefined = ? rawArbiterAgentType : undefined; -/** Service ID for the arbiter. Re-uses codex-review bot by default when arbiter is enabled. */ +/** Service ID for the arbiter. Defaults to codex-review for internal routing when arbiter is enabled. */ export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE ? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID : null; diff --git a/src/db.test.ts b/src/db.test.ts index c8c0ef8..fbdcff3 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -20,6 +20,7 @@ import { deleteSession, deleteTask, getAllChats, + getChannelOwnerLease, getAllRegisteredGroups, getDueTasks, getEffectiveRoomMode, @@ -38,7 +39,7 @@ import { getPairedProject, getPairedTaskById, getPairedWorkspace, - getRouterStateForService, + getRouterState, getSession, getStoredRoomSettings, getTaskById, @@ -46,7 +47,7 @@ import { markWorkItemDelivered, markWorkItemDeliveryRetry, setSession, - setRouterStateForService, + setRouterState, setExplicitRoomMode, rememberMemory, storeChatMetadata, @@ -57,6 +58,12 @@ import { upsertPairedWorkspace, updateTask, } from './db.js'; +import { + ARBITER_AGENT_TYPE, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, +} from './config.js'; import { resolveTaskRuntimeIpcPath, resolveTaskSessionsPath, @@ -352,6 +359,48 @@ describe('session accessors', () => { deleteSession('group-a'); expect(getSession('group-a')).toBeUndefined(); }); + + it('backfills legacy service-scoped sessions into canonical agent sessions during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-session-backfill-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE sessions ( + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) + ); + CREATE TABLE service_sessions ( + group_folder TEXT NOT NULL, + service_id TEXT NOT NULL, + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, service_id) + ); + `); + legacyDb + .prepare( + `INSERT INTO service_sessions (group_folder, service_id, session_id) + VALUES (?, ?, ?)`, + ) + .run('group-legacy', CLAUDE_SERVICE_ID, 'legacy-session-123'); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getSession('group-legacy', 'claude-code')).toBe( + 'legacy-session-123', + ); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + migratedDb + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`) + .get(), + ).toBeUndefined(); + migratedDb.close(); + }); }); // --- storeChatMetadata --- @@ -659,6 +708,649 @@ describe('paired task state', () => { ), ).toEqual(['/tmp/reviewer-v2']); }); + + it('normalizes paired task service shadow from persisted role agent types during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-shadow-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'paired-legacy-1', + 'dc:paired', + 'paired-room', + CODEX_REVIEW_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + 'codex', + 'claude-code', + 'codex', + null, + 'HEAD', + null, + null, + 0, + 'active', + null, + null, + null, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPairedTaskById('paired-legacy-1')).toMatchObject({ + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + }); + }); + + it('backfills paired task role metadata from stable room metadata for pre-column legacy rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-legacy-shadow-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')) + ); + `); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:legacy-failover', + 'Legacy Failover Room', + 'legacy-failover-room', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:legacy-failover', + 'Legacy Failover Room', + 'legacy-failover-room', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'paired-legacy-failover', + 'dc:legacy-failover', + 'legacy-failover-room', + CODEX_REVIEW_SERVICE_ID, + CLAUDE_SERVICE_ID, + null, + 'HEAD', + null, + null, + 0, + 'active', + null, + null, + null, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:legacy-failover')).toMatchObject({ + ownerAgentType: 'codex', + }); + expect(getPairedTaskById('paired-legacy-failover')).toMatchObject({ + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, + }); + }); + + it('preserves task-level role metadata even when current room settings differ', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-ssot-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + owner_agent_type, + updated_at + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run( + 'dc:task-ssot', + 'tribunal', + 'explicit', + 'codex', + '2026-03-28T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'paired-task-ssot', + 'dc:task-ssot', + 'task-ssot-room', + CODEX_REVIEW_SERVICE_ID, + CLAUDE_SERVICE_ID, + 'claude-code', + 'codex', + 'codex', + null, + 'HEAD', + null, + null, + 0, + 'active', + null, + null, + null, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:task-ssot')).toMatchObject({ + ownerAgentType: 'codex', + }); + expect(getPairedTaskById('paired-task-ssot')).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: 'codex', + }); + }); + + it('preserves explicit room owner trigger and agent type during init and uses them for null task fallback', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-ssot-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:explicit-owner', + 'tribunal', + 'explicit', + 'Explicit Owner Room', + 'explicit-owner-room', + '@Custom', + 1, + 0, + 'claude-code', + null, + '2026-03-28T00:00:00.000Z', + ); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:explicit-owner', + 'Explicit Owner Room', + 'explicit-owner-room', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:explicit-owner', + 'Explicit Owner Room', + 'explicit-owner-room', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'paired-explicit-owner', + 'dc:explicit-owner', + 'explicit-owner-room', + CODEX_REVIEW_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + null, + null, + null, + null, + 'HEAD', + null, + null, + 0, + 'active', + null, + null, + null, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:explicit-owner')).toMatchObject({ + roomMode: 'tribunal', + modeSource: 'explicit', + ownerAgentType: 'claude-code', + trigger: '@Custom', + }); + expect(getPairedTaskById('paired-explicit-owner')).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, + }); + }); + + it('preserves explicit room trigger during init even when legacy explicit rows lack owner agent type', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-trigger-ssot-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:explicit-trigger-only', + 'tribunal', + 'explicit', + 'Explicit Trigger Room', + 'explicit-trigger-room', + '@Custom', + 1, + 0, + null, + null, + '2026-03-28T00:00:00.000Z', + ); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:explicit-trigger-only', + 'Explicit Trigger Room', + 'explicit-trigger-room', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:explicit-trigger-only', + 'Explicit Trigger Room', + 'explicit-trigger-room', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:explicit-trigger-only')).toMatchObject({ + roomMode: 'tribunal', + modeSource: 'explicit', + trigger: '@Custom', + }); + }); }); // --- LIMIT behavior --- @@ -1176,29 +1868,20 @@ describe('service handoff completion', () => { const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({ id: handoff.id, - target_service_id: 'codex-review', chat_jid: 'dc:handoff', end_seq: 1, }); expect(appliedCursor).toBe('1'); expect(getPendingServiceHandoffs('codex-review')).toEqual([]); - expect( - JSON.parse( - getRouterStateForService('last_agent_seq', 'codex-review') || '{}', - ), - ).toMatchObject({ + expect(JSON.parse(getRouterState('last_agent_seq') || '{}')).toMatchObject({ 'dc:handoff': '1', }); }); it('does not move the target cursor backwards when a newer cursor already exists', () => { storeChatMetadata('dc:handoff', '2024-01-01T00:00:00.000Z'); - setRouterStateForService( - 'last_agent_seq', - JSON.stringify({ 'dc:handoff': '5' }), - 'codex-review', - ); + setRouterState('last_agent_seq', JSON.stringify({ 'dc:handoff': '5' })); const handoff = createServiceHandoff({ chat_jid: 'dc:handoff', group_folder: 'test-group', @@ -1213,17 +1896,12 @@ describe('service handoff completion', () => { const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({ id: handoff.id, - target_service_id: 'codex-review', chat_jid: 'dc:handoff', end_seq: 3, }); expect(appliedCursor).toBe('5'); - expect( - JSON.parse( - getRouterStateForService('last_agent_seq', 'codex-review') || '{}', - ), - ).toMatchObject({ + expect(JSON.parse(getRouterState('last_agent_seq') || '{}')).toMatchObject({ 'dc:handoff': '5', }); }); @@ -1234,6 +1912,8 @@ describe('service handoff completion', () => { group_folder: 'test-group', source_service_id: 'claude', target_service_id: 'codex-review', + source_role: 'owner', + target_role: 'reviewer', target_agent_type: 'codex', prompt: 'please review', reason: 'reviewer-claude-429', @@ -1241,14 +1921,465 @@ describe('service handoff completion', () => { }); expect(handoff.intended_role).toBe('reviewer'); + expect(handoff.source_role).toBe('owner'); + expect(handoff.target_role).toBe('reviewer'); expect(getPendingServiceHandoffs('codex-review')).toEqual([ expect.objectContaining({ id: handoff.id, + source_role: 'owner', + target_role: 'reviewer', intended_role: 'reviewer', reason: 'reviewer-claude-429', }), ]); }); + + it('derives handoff service shadows from role and agent metadata when raw service ids are omitted', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-derived-shadow', + group_folder: 'handoff-derived-shadow', + source_role: 'owner', + source_agent_type: 'codex', + target_role: 'reviewer', + target_agent_type: 'claude-code', + prompt: 'review this', + intended_role: 'reviewer', + }); + + expect(handoff).toMatchObject({ + source_service_id: CODEX_MAIN_SERVICE_ID, + target_service_id: CLAUDE_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + target_agent_type: 'claude-code', + }); + }); + + it('stores handoff cursors under the provided role-scoped cursor key', () => { + storeChatMetadata('dc:handoff-role-cursor', '2024-01-01T00:00:00.000Z'); + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-role-cursor', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'owner', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'hello reviewer', + end_seq: 7, + intended_role: 'reviewer', + }); + + expect(claimServiceHandoff(handoff.id)).toBe(true); + + const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({ + id: handoff.id, + chat_jid: 'dc:handoff-role-cursor', + cursor_key: 'dc:handoff-role-cursor:reviewer', + end_seq: 7, + }); + + expect(appliedCursor).toBe('7'); + expect(JSON.parse(getRouterState('last_agent_seq') || '{}')).toMatchObject({ + 'dc:handoff-role-cursor:reviewer': '7', + }); + }); + + it('stores owner handoff service ids as stable role-slot shadows', () => { + assignRoom('dc:handoff-owner-shadow', { + name: 'Owner Handoff Shadow', + roomMode: 'tribunal', + ownerAgentType: 'codex', + folder: 'owner-handoff-shadow', + trigger: '@Owner', + requiresTrigger: true, + }); + + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-owner-shadow', + group_folder: 'owner-handoff-shadow', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'owner', + target_agent_type: 'codex', + prompt: 'owner fallback', + reason: 'claude-usage-exhausted', + intended_role: 'owner', + }); + + expect(handoff.source_service_id).toBe(CODEX_MAIN_SERVICE_ID); + expect(handoff.target_service_id).toBe(CODEX_MAIN_SERVICE_ID); + expect(getPendingServiceHandoffs(CODEX_MAIN_SERVICE_ID)).toEqual([ + expect.objectContaining({ + id: handoff.id, + source_service_id: CODEX_MAIN_SERVICE_ID, + target_service_id: CODEX_MAIN_SERVICE_ID, + source_role: 'owner', + target_role: 'owner', + }), + ]); + }); + + it('normalizes legacy owner handoff service ids to stable role-slot shadows during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-handoff-shadow-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + CREATE TABLE service_handoffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + source_service_id TEXT NOT NULL, + target_service_id TEXT NOT NULL, + source_role TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + 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 + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:handoff-owner-shadow', + 'tribunal', + 'explicit', + 'Owner Handoff Shadow', + 'owner-handoff-shadow', + '@Owner', + 1, + 0, + 'codex', + null, + '2026-03-28T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO service_handoffs ( + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + target_role, + target_agent_type, + prompt, + status, + reason, + intended_role, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:handoff-owner-shadow', + 'owner-handoff-shadow', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'owner', + 'codex', + 'owner fallback', + 'pending', + 'claude-usage-exhausted', + 'owner', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPendingServiceHandoffs(CODEX_MAIN_SERVICE_ID)).toEqual([ + expect.objectContaining({ + chat_jid: 'dc:handoff-owner-shadow', + source_service_id: CODEX_MAIN_SERVICE_ID, + target_service_id: CODEX_MAIN_SERVICE_ID, + source_role: 'owner', + target_role: 'owner', + }), + ]); + }); + + it('rebuilds legacy service-shadow tables into canonical schemas while preserving derived runtime shadows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-canonical-schema-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE work_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_folder TEXT NOT NULL, + chat_jid TEXT NOT NULL, + agent_type TEXT NOT NULL, + service_id TEXT NOT NULL DEFAULT '', + delivery_role TEXT, + status TEXT NOT NULL DEFAULT 'produced', + start_seq INTEGER, + end_seq INTEGER, + result_payload TEXT NOT NULL, + delivery_attempts INTEGER NOT NULL DEFAULT 0, + delivery_message_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT + ); + CREATE TABLE channel_owner ( + chat_jid TEXT PRIMARY KEY, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT, + arbiter_service_id TEXT, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + activated_at TEXT, + reason TEXT + ); + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE service_handoffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + source_service_id TEXT NOT NULL, + target_service_id TEXT NOT NULL, + source_role TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + start_seq INTEGER, + end_seq INTEGER, + reason TEXT, + intended_role TEXT, + created_at TEXT NOT NULL, + claimed_at TEXT, + completed_at TEXT, + last_error TEXT + ); + `); + + legacyDb + .prepare( + `INSERT INTO work_items ( + group_folder, chat_jid, agent_type, service_id, delivery_role, status, + start_seq, end_seq, result_payload, delivery_attempts, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'canonical-schema', + 'dc:canonical-schema', + 'codex', + CODEX_REVIEW_SERVICE_ID, + 'reviewer', + 'produced', + 1, + 2, + 'payload', + 0, + '2026-04-03T00:00:00.000Z', + '2026-04-03T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO channel_owner ( + chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, + owner_agent_type, reviewer_agent_type, arbiter_agent_type, activated_at, reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:canonical-schema', + CODEX_MAIN_SERVICE_ID, + CLAUDE_SERVICE_ID, + null, + 'codex', + 'claude-code', + null, + '2026-04-03T00:00:00.000Z', + 'explicit', + ); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, chat_jid, group_folder, owner_service_id, reviewer_service_id, + owner_agent_type, reviewer_agent_type, arbiter_agent_type, title, + source_ref, plan_notes, review_requested_at, round_trip_count, + status, arbiter_verdict, arbiter_requested_at, completion_reason, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'task-canonical-schema', + 'dc:canonical-schema', + 'canonical-schema', + CODEX_MAIN_SERVICE_ID, + CLAUDE_SERVICE_ID, + 'codex', + 'claude-code', + null, + 'canonical task', + null, + null, + null, + 0, + 'active', + null, + null, + null, + '2026-04-03T00:00:00.000Z', + '2026-04-03T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO service_handoffs ( + chat_jid, group_folder, source_service_id, target_service_id, + source_role, target_role, target_agent_type, prompt, status, + reason, intended_role, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:canonical-schema', + 'canonical-schema', + CODEX_MAIN_SERVICE_ID, + CLAUDE_SERVICE_ID, + 'owner', + 'reviewer', + 'claude-code', + 'review please', + 'pending', + 'reviewer-codex-manual', + 'reviewer', + '2026-04-03T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getOpenWorkItem('dc:canonical-schema', 'codex', CODEX_REVIEW_SERVICE_ID), + ).toMatchObject({ + delivery_role: 'reviewer', + service_id: CODEX_REVIEW_SERVICE_ID, + }); + expect(getChannelOwnerLease('dc:canonical-schema')).toMatchObject({ + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + }); + expect(getLatestPairedTaskForChat('dc:canonical-schema')).toMatchObject({ + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + }); + expect(getPendingServiceHandoffs(CLAUDE_SERVICE_ID)).toEqual([ + expect.objectContaining({ + chat_jid: 'dc:canonical-schema', + source_service_id: CODEX_MAIN_SERVICE_ID, + target_service_id: CLAUDE_SERVICE_ID, + source_agent_type: 'codex', + }), + ]); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + (migratedDb + .prepare(`PRAGMA table_info(work_items)`) + .all() as Array<{ name: string }>).map((row) => row.name), + ).not.toContain('service_id'); + expect( + (migratedDb + .prepare(`PRAGMA table_info(channel_owner)`) + .all() as Array<{ name: string }>).map((row) => row.name), + ).not.toContain('owner_service_id'); + expect( + (migratedDb + .prepare(`PRAGMA table_info(paired_tasks)`) + .all() as Array<{ name: string }>).map((row) => row.name), + ).not.toContain('owner_service_id'); + expect( + (migratedDb + .prepare(`PRAGMA table_info(service_handoffs)`) + .all() as Array<{ name: string }>).map((row) => row.name), + ).not.toContain('source_service_id'); + expect( + (migratedDb + .prepare(`PRAGMA table_info(service_handoffs)`) + .all() as Array<{ name: string }>).map((row) => row.name), + ).toContain('source_agent_type'); + migratedDb.close(); + }); }); describe('message seq cursors', () => { @@ -1433,11 +2564,13 @@ describe('work items', () => { group_folder: 'discord_test', chat_jid: 'dc:123', agent_type: 'claude-code', + delivery_role: 'reviewer', start_seq: 10, end_seq: 12, result_payload: 'hello', }); + expect(item.delivery_role).toBe('reviewer'); expect(getOpenWorkItem('dc:123', 'claude-code')?.id).toBe(item.id); markWorkItemDeliveryRetry(item.id, 'send failed'); @@ -1449,4 +2582,28 @@ describe('work items', () => { markWorkItemDelivered(item.id, 'msg-1'); expect(getOpenWorkItem('dc:123', 'claude-code')).toBeUndefined(); }); + + it('stores service shadow as a derived compatibility field from role and agent type', () => { + const reviewerItem = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:shadow-reviewer', + agent_type: 'codex', + delivery_role: 'reviewer', + start_seq: 1, + end_seq: 2, + result_payload: 'reviewer output', + }); + const ownerItem = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:shadow-owner', + agent_type: 'codex', + delivery_role: 'owner', + start_seq: 3, + end_seq: 4, + result_payload: 'owner output', + }); + + expect(reviewerItem.service_id).toBe(CODEX_REVIEW_SERVICE_ID); + expect(ownerItem.service_id).toBe(CODEX_MAIN_SERVICE_ID); + }); }); diff --git a/src/db.ts b/src/db.ts index 0360656..0777550 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,6 +4,7 @@ import path from 'path'; import { ASSISTANT_NAME, + ARBITER_AGENT_TYPE, CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, @@ -22,6 +23,11 @@ import { resolveTaskSessionsPath as resolveTaskSessionsPathFromGroup, } from './group-folder.js'; import { logger } from './logger.js'; +import { + inferAgentTypeFromServiceShadow, + inferRoleFromServiceShadow, + resolveRoleServiceShadow, +} from './role-service-shadow.js'; import { getTaskRuntimeTaskId } from './task-watch-status.js'; import { NewMessage, @@ -126,6 +132,7 @@ export interface WorkItem { chat_jid: string; agent_type: AgentType; service_id: string; + delivery_role?: PairedRoomRole | null; status: 'produced' | 'delivery_retry' | 'delivered'; start_seq: number | null; end_seq: number | null; @@ -143,6 +150,9 @@ export interface ChannelOwnerLeaseRow { owner_service_id: string; reviewer_service_id: string | null; arbiter_service_id: string | null; + owner_agent_type?: AgentType | null; + reviewer_agent_type?: AgentType | null; + arbiter_agent_type?: AgentType | null; activated_at: string | null; reason: string | null; } @@ -153,6 +163,9 @@ export interface ServiceHandoff { group_folder: string; source_service_id: string; target_service_id: string; + source_role: PairedRoomRole | null; + source_agent_type?: AgentType | null; + target_role: PairedRoomRole | null; target_agent_type: AgentType; prompt: string; status: 'pending' | 'claimed' | 'completed' | 'failed'; @@ -166,6 +179,58 @@ export interface ServiceHandoff { last_error: string | null; } +interface StoredWorkItemRow extends Omit { + agent_type: string; +} + +interface StoredChannelOwnerLeaseRow { + chat_jid: string; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; + activated_at: string | null; + reason: string | null; +} + +interface LegacyChannelOwnerLeaseServiceRow extends StoredChannelOwnerLeaseRow { + owner_service_id?: string | null; + reviewer_service_id?: string | null; + arbiter_service_id?: string | null; +} + +interface StoredServiceHandoffRow + extends Omit< + ServiceHandoff, + 'source_service_id' | 'target_service_id' | 'source_agent_type' | 'target_agent_type' + > { + source_agent_type?: string | null; + target_agent_type: string; +} + +interface LegacyServiceHandoffServiceRow extends StoredServiceHandoffRow { + source_service_id?: string | null; + target_service_id?: string | null; +} + +interface StoredPairedTaskRow + extends Omit< + PairedTask, + | 'owner_service_id' + | 'reviewer_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > { + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +} + +interface LegacyPairedTaskServiceRow extends StoredPairedTaskRow { + owner_service_id?: string | null; + reviewer_service_id?: string | null; +} + function backfillMessageSeq(database: Database): void { const rows = database .prepare( @@ -204,6 +269,129 @@ function backfillMessageSeq(database: Database): void { } } +function hydrateWorkItem(row: StoredWorkItemRow): WorkItem { + const agentType = normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; + return { + ...row, + agent_type: agentType, + service_id: resolveWorkItemServiceShadow(agentType, row.delivery_role), + }; +} + +function hydrateChannelOwnerLeaseRow( + row: StoredChannelOwnerLeaseRow, +): ChannelOwnerLeaseRow { + const ownerAgentType = + normalizeStoredAgentType(row.owner_agent_type) ?? OWNER_AGENT_TYPE; + const reviewerAgentType = + row.reviewer_agent_type == null + ? null + : normalizeStoredAgentType(row.reviewer_agent_type) ?? + resolveStableReviewerAgentType(ownerAgentType, null); + const arbiterAgentType = + row.arbiter_agent_type == null + ? null + : normalizeStoredAgentType(row.arbiter_agent_type) ?? + ARBITER_AGENT_TYPE ?? + null; + + return { + chat_jid: row.chat_jid, + owner_service_id: + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CLAUDE_SERVICE_ID, + reviewer_service_id: + reviewerAgentType == null + ? null + : resolveRoleServiceShadow('reviewer', reviewerAgentType), + arbiter_service_id: + arbiterAgentType == null + ? null + : resolveRoleServiceShadow('arbiter', arbiterAgentType), + owner_agent_type: ownerAgentType, + reviewer_agent_type: reviewerAgentType, + arbiter_agent_type: arbiterAgentType, + activated_at: row.activated_at, + reason: row.reason, + }; +} + +function hydratePairedTaskRow(row: StoredPairedTaskRow): PairedTask { + const ownerAgentType = resolveStablePairedTaskOwnerAgentType(db, row); + const reviewerAgentType = resolveStableReviewerAgentType( + ownerAgentType, + row.reviewer_agent_type ?? null, + ); + const arbiterAgentType = + normalizeStoredAgentType(row.arbiter_agent_type) ?? ARBITER_AGENT_TYPE ?? null; + + return { + ...row, + owner_service_id: + resolveRoleServiceShadow('owner', ownerAgentType) ?? CODEX_MAIN_SERVICE_ID, + reviewer_service_id: + resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + CODEX_REVIEW_SERVICE_ID, + owner_agent_type: ownerAgentType ?? null, + reviewer_agent_type: reviewerAgentType ?? null, + arbiter_agent_type: arbiterAgentType, + }; +} + +function hydrateServiceHandoffRow(row: StoredServiceHandoffRow): ServiceHandoff { + const sourceAgentType = + normalizeStoredAgentType(row.source_agent_type) ?? + (row.source_role + ? resolveStableRoomRoleAgentType(db, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: row.source_role, + }) + : null); + const targetAgentType = + normalizeStoredAgentType(row.target_agent_type) ?? + (row.target_role + ? resolveStableRoomRoleAgentType(db, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: row.target_role, + }) + : null) ?? + 'claude-code'; + + return { + ...row, + source_agent_type: sourceAgentType ?? null, + target_agent_type: targetAgentType, + source_service_id: + row.source_role != null + ? resolveRoleServiceShadow(row.source_role, sourceAgentType) ?? + SERVICE_SESSION_SCOPE + : SERVICE_SESSION_SCOPE, + target_service_id: + row.target_role != null + ? resolveRoleServiceShadow(row.target_role, targetAgentType) ?? + SERVICE_SESSION_SCOPE + : SERVICE_SESSION_SCOPE, + }; +} + +function getTableColumns(database: Database, tableName: string): string[] { + return ( + database + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string }> + ).map((column) => column.name); +} + +function tableHasColumn( + database: Database, + tableName: string, + columnName: string, +): boolean { + return getTableColumns(database, tableName).includes(columnName); +} + function createSchema(database: Database): void { database.exec(` CREATE TABLE IF NOT EXISTS chats ( @@ -236,7 +424,7 @@ function createSchema(database: Database): void { group_folder TEXT NOT NULL, chat_jid TEXT NOT NULL, agent_type TEXT NOT NULL, - service_id TEXT NOT NULL DEFAULT '', + delivery_role TEXT, status TEXT NOT NULL DEFAULT 'produced', start_seq INTEGER, end_seq INTEGER, @@ -247,12 +435,13 @@ function createSchema(database: Database): void { created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), delivered_at TEXT, - CHECK (status IN ('produced', 'delivery_retry', 'delivered')) + CHECK (status IN ('produced', 'delivery_retry', 'delivered')), + CHECK (delivery_role IN ('owner', 'reviewer', 'arbiter') OR delivery_role IS NULL) ); CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status, updated_at); - CREATE INDEX IF NOT EXISTS idx_work_items_group_agent ON work_items(chat_jid, agent_type, service_id, status); + CREATE INDEX IF NOT EXISTS idx_work_items_group_agent ON work_items(chat_jid, agent_type, delivery_role, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open - ON work_items(chat_jid, agent_type, service_id) + ON work_items(chat_jid, agent_type, IFNULL(delivery_role, '')) WHERE status IN ('produced', 'delivery_retry'); CREATE TABLE IF NOT EXISTS scheduled_tasks ( @@ -299,12 +488,6 @@ function createSchema(database: Database): void { session_id TEXT NOT NULL, PRIMARY KEY (group_folder, agent_type) ); - CREATE TABLE IF NOT EXISTS service_sessions ( - group_folder TEXT NOT NULL, - service_id TEXT NOT NULL, - session_id TEXT NOT NULL, - PRIMARY KEY (group_folder, service_id) - ); CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT NOT NULL, name TEXT NOT NULL, @@ -330,8 +513,9 @@ function createSchema(database: Database): void { id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, title TEXT, source_ref TEXT, plan_notes TEXT, @@ -343,7 +527,10 @@ function createSchema(database: Database): void { completion_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')) + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) ); CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status ON paired_tasks(chat_jid, status, updated_at); @@ -376,11 +563,14 @@ function createSchema(database: Database): void { ON paired_turn_outputs(task_id, turn_number); CREATE TABLE IF NOT EXISTS channel_owner ( chat_jid TEXT PRIMARY KEY, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT, - arbiter_service_id TEXT, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, activated_at TEXT, - reason TEXT + reason TEXT, + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) ); CREATE TABLE IF NOT EXISTS room_settings ( chat_jid TEXT PRIMARY KEY, @@ -401,8 +591,9 @@ function createSchema(database: Database): void { id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, - source_service_id TEXT NOT NULL, - target_service_id TEXT NOT NULL, + source_role TEXT, + source_agent_type TEXT, + target_role TEXT, target_agent_type TEXT NOT NULL, prompt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', @@ -415,10 +606,12 @@ function createSchema(database: Database): void { completed_at TEXT, last_error TEXT, CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), - CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL) + CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL), + CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL), + CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL) ); CREATE INDEX IF NOT EXISTS idx_service_handoffs_target - ON service_handoffs(target_service_id, status, created_at); + ON service_handoffs(status, target_role, target_agent_type, created_at); CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY, scope_kind TEXT NOT NULL, @@ -577,6 +770,69 @@ function createSchema(database: Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE service_handoffs ADD COLUMN source_role TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE service_handoffs ADD COLUMN target_role TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE paired_tasks ADD COLUMN owner_agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE paired_tasks ADD COLUMN reviewer_agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`); + } catch { + /* column already exists */ + } + + database.exec( + `UPDATE service_handoffs + SET target_role = COALESCE( + target_role, + intended_role, + CASE + WHEN reason LIKE 'reviewer-%' THEN 'reviewer' + WHEN reason LIKE 'arbiter-%' THEN 'arbiter' + WHEN reason IS NOT NULL THEN 'owner' + ELSE NULL + END + ) + WHERE target_role IS NULL`, + ); + + database.exec( + `UPDATE service_handoffs + SET source_role = COALESCE(source_role, target_role, intended_role) + WHERE source_role IS NULL`, + ); + database.exec( `UPDATE room_settings SET mode_source = 'explicit' @@ -625,28 +881,6 @@ function createSchema(database: Database): void { /* column already exists */ } - try { - database.exec( - `ALTER TABLE work_items ADD COLUMN service_id TEXT DEFAULT ''`, - ); - } catch { - /* column already exists */ - } - try { - database - .prepare( - `UPDATE work_items - SET service_id = CASE - WHEN agent_type = 'codex' THEN ? - ELSE ? - END - WHERE COALESCE(service_id, '') = ''`, - ) - .run(CODEX_MAIN_SERVICE_ID, CLAUDE_SERVICE_ID); - } catch { - /* best effort */ - } - backfillMessageSeq(database); database.exec(` @@ -657,9 +891,9 @@ function createSchema(database: Database): void { database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`); database.exec(` CREATE INDEX IF NOT EXISTS idx_work_items_group_agent - ON work_items(chat_jid, agent_type, service_id, status); + ON work_items(chat_jid, agent_type, delivery_role, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open - ON work_items(chat_jid, agent_type, service_id) + ON work_items(chat_jid, agent_type, IFNULL(delivery_role, '')) WHERE status IN ('produced', 'delivery_retry'); `); @@ -793,8 +1027,9 @@ function createSchema(database: Database): void { id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, title TEXT, source_ref TEXT, plan_notes TEXT, @@ -803,9 +1038,13 @@ function createSchema(database: Database): void { status TEXT NOT NULL DEFAULT 'active', arbiter_verdict TEXT, arbiter_requested_at TEXT, + completion_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')) + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) ); CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status ON paired_tasks(chat_jid, status, updated_at); @@ -828,8 +1067,9 @@ function createSchema(database: Database): void { id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, title TEXT, source_ref TEXT, plan_notes TEXT, @@ -841,16 +1081,19 @@ function createSchema(database: Database): void { completion_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')) + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) ); INSERT INTO paired_tasks_new ( - id, chat_jid, group_folder, owner_service_id, reviewer_service_id, - title, source_ref, plan_notes, review_requested_at, + id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, round_trip_count, status, created_at, updated_at ) SELECT - id, chat_jid, group_folder, owner_service_id, reviewer_service_id, - title, source_ref, plan_notes, review_requested_at, + id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, round_trip_count, status, created_at, updated_at FROM paired_tasks; DROP TABLE paired_tasks; @@ -861,13 +1104,16 @@ function createSchema(database: Database): void { } } - // Migration: add arbiter_service_id column to channel_owner if it doesn't exist - try { - database.exec( - `ALTER TABLE channel_owner ADD COLUMN arbiter_service_id TEXT`, - ); - } catch { - /* column already exists */ + for (const column of [ + 'owner_agent_type', + 'reviewer_agent_type', + 'arbiter_agent_type', + ]) { + try { + database.exec(`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`); + } catch { + /* column already exists */ + } } // Migration: add completion_reason column to paired_tasks if it doesn't exist @@ -983,6 +1229,34 @@ function createSchema(database: Database): void { } backfillStoredRoomSettings(database); + if (tableHasColumn(database, 'channel_owner', 'owner_service_id')) { + backfillChannelOwnerRoleMetadata(database); + } + const hasLegacyServiceSessions = Boolean( + database + .prepare( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`, + ) + .get(), + ); + if (hasLegacyServiceSessions) { + backfillLegacyServiceSessions(database); + } + if (tableHasColumn(database, 'work_items', 'service_id')) { + backfillWorkItemServiceShadows(database); + } + if (tableHasColumn(database, 'service_handoffs', 'source_service_id')) { + backfillServiceHandoffServiceShadows(database); + } + if (tableHasColumn(database, 'paired_tasks', 'owner_service_id')) { + backfillPairedTaskRoleMetadata(database); + } + + rebuildWorkItemsCanonicalSchema(database); + dropLegacyServiceSessionsTable(database); + rebuildChannelOwnerCanonicalSchema(database); + rebuildPairedTasksCanonicalSchema(database); + rebuildServiceHandoffsCanonicalSchema(database); } export function initDatabase(): void { @@ -1726,36 +2000,48 @@ export function getOpenWorkItem( agentType: AgentType = 'claude-code', serviceId: string = SERVICE_SESSION_SCOPE, ): WorkItem | undefined { - return db + const preferredRole = inferRoleFromServiceShadow(agentType, serviceId); + const row = db .prepare( `SELECT * FROM work_items - WHERE chat_jid = ? AND agent_type = ? AND service_id = ? + WHERE chat_jid = ? AND agent_type = ? AND status IN ('produced', 'delivery_retry') - ORDER BY id ASC + ORDER BY + CASE + WHEN ? IS NOT NULL AND delivery_role = ? THEN 0 + WHEN delivery_role IS NULL THEN 1 + ELSE 2 + END, + id ASC LIMIT 1`, ) - .get(chatJid, agentType, serviceId) as WorkItem | undefined; + .get( + chatJid, + agentType, + preferredRole, + preferredRole, + ) as StoredWorkItemRow | undefined; + return row ? hydrateWorkItem(row) : undefined; } export function createProducedWorkItem(input: { group_folder: string; chat_jid: string; agent_type?: AgentType; - service_id?: string; + delivery_role?: PairedRoomRole | null; start_seq: number | null; end_seq: number | null; result_payload: string; }): WorkItem { const now = new Date().toISOString(); const agentType = input.agent_type || 'claude-code'; - const serviceId = input.service_id || SERVICE_SESSION_SCOPE; db.prepare( `INSERT INTO work_items ( group_folder, chat_jid, agent_type, - service_id, + delivery_role, status, start_seq, end_seq, @@ -1768,7 +2054,7 @@ export function createProducedWorkItem(input: { input.group_folder, input.chat_jid, agentType, - serviceId, + input.delivery_role ?? null, input.start_seq, input.end_seq, input.result_payload, @@ -1779,9 +2065,11 @@ export function createProducedWorkItem(input: { const lastId = ( db.prepare('SELECT last_insert_rowid() as id').get() as { id: number } ).id; - return db - .prepare('SELECT * FROM work_items WHERE id = ?') - .get(lastId) as WorkItem; + return hydrateWorkItem( + db.prepare('SELECT * FROM work_items WHERE id = ?').get( + lastId, + ) as StoredWorkItemRow, + ); } export function markWorkItemDelivered( @@ -2125,7 +2413,22 @@ export function getRecentConsecutiveErrors( // --- Router state accessors --- export function getRouterState(key: string): string | undefined { - return getRouterStateForService(key, SERVICE_ID); + const row = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(key) as { value: string } | undefined; + if (row) return row.value; + + const prefixedKey = `${normalizeServiceId(SERVICE_ID)}:${key}`; + const prefixedRow = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(prefixedKey) as { value: string } | undefined; + if (!prefixedRow) return undefined; + + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(key, prefixedRow.value); + db.prepare('DELETE FROM router_state WHERE key = ?').run(prefixedKey); + return prefixedRow.value; } export function getRouterStateForService( @@ -2138,22 +2441,16 @@ export function getRouterStateForService( .get(prefixedKey) as { value: string } | undefined; if (row) return row.value; - // Lazy migration: read unprefixed key and migrate to prefixed - const old = db + const canonical = db .prepare('SELECT value FROM router_state WHERE key = ?') .get(key) as { value: string } | undefined; - if (old) { - db.prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(prefixedKey, old.value); - db.prepare('DELETE FROM router_state WHERE key = ?').run(key); - return old.value; - } - return undefined; + return canonical?.value; } export function setRouterState(key: string, value: string): void { - setRouterStateForService(key, value, SERVICE_ID); + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(key, value); } export function setRouterStateForService( @@ -2173,22 +2470,13 @@ export function getSession( groupFolder: string, agentType: AgentType = 'claude-code', ): string | undefined { - const serviceScopedRow = db - .prepare( - 'SELECT session_id FROM service_sessions WHERE group_folder = ? AND service_id = ?', - ) - .get(groupFolder, SERVICE_SESSION_SCOPE) as - | { session_id: string } - | undefined; - if (serviceScopedRow?.session_id) { - return serviceScopedRow.session_id; - } - const row = db .prepare( 'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?', ) - .get(groupFolder, agentType) as { session_id: string } | undefined; + .get(groupFolder, agentType) as + | { session_id: string } + | undefined; return row?.session_id; } @@ -2197,9 +2485,6 @@ export function setSession( sessionId: string, agentType: AgentType = 'claude-code', ): void { - db.prepare( - 'INSERT OR REPLACE INTO service_sessions (group_folder, service_id, session_id) VALUES (?, ?, ?)', - ).run(groupFolder, SERVICE_SESSION_SCOPE, sessionId); db.prepare( 'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)', ).run(groupFolder, agentType, sessionId); @@ -2209,51 +2494,31 @@ export function deleteSession( groupFolder: string, agentType: AgentType = 'claude-code', ): void { - db.prepare( - 'DELETE FROM service_sessions WHERE group_folder = ? AND service_id = ?', - ).run(groupFolder, SERVICE_SESSION_SCOPE); db.prepare( 'DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?', ).run(groupFolder, agentType); } export function deleteAllSessionsForGroup(groupFolder: string): void { - db.prepare('DELETE FROM service_sessions WHERE group_folder = ?').run( - groupFolder, - ); db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder); } export function getAllSessions(): Record { - const serviceRows = db - .prepare( - 'SELECT group_folder, session_id FROM service_sessions WHERE service_id = ?', - ) - .all(SERVICE_SESSION_SCOPE) as Array<{ - group_folder: string; - session_id: string; - }>; - const result: Record = {}; - for (const row of serviceRows) { - result[row.group_folder] = row.session_id; - } - if (serviceRows.length > 0) { - return result; - } - + const currentAgentType = + inferAgentTypeFromServiceShadow(SERVICE_SESSION_SCOPE) ?? 'claude-code'; const rows = db .prepare( 'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?', ) - .all('claude-code') as Array<{ + .all(currentAgentType) as Array<{ group_folder: string; session_id: string; }>; - const legacyResult: Record = {}; + const result: Record = {}; for (const row of rows) { - legacyResult[row.group_folder] = row.session_id; + result[row.group_folder] = row.session_id; } - return legacyResult; + return result; } /** @@ -2948,6 +3213,10 @@ function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined { function collectRoomRegistrationSnapshot( database: Database, jid: string, + existingStored?: Pick< + StoredRoomSettings, + 'modeSource' | 'ownerAgentType' | 'trigger' + >, ): RoomRegistrationSnapshot | undefined { const rows = database .prepare( @@ -2993,16 +3262,42 @@ function collectRoomRegistrationSnapshot( } const agentTypes = collectRegisteredAgentTypes(database, jid); - const ownerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes); - const ownerRow = + const inferredOwnerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes( + agentTypes, + ); + const preferExplicitTrigger = + existingStored?.modeSource === 'explicit' && existingStored.trigger; + const preferExplicitOwner = + existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType; + const preferredOwnerAgentType = preferExplicitOwner + ? existingStored.ownerAgentType + : undefined; + const preferredOwnerRow = preferredOwnerAgentType + ? rows.find( + (row) => + normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType, + ) + : undefined; + const inferredOwnerRow = rows.find( - (row) => normalizeStoredAgentType(row.agent_type) === ownerAgentType, + (row) => normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType, ) ?? rows[0]; + const ownerAgentType = preferredOwnerAgentType + ? preferredOwnerRow + ? preferredOwnerAgentType + : preferredOwnerAgentType + : inferredOwnerAgentType; + const ownerRow = preferredOwnerRow ?? inferredOwnerRow; return { name: first.name, folder: first.folder, - triggerPattern: ownerRow.trigger_pattern, + triggerPattern: + preferExplicitTrigger + ? existingStored.trigger! + : preferredOwnerRow != null + ? preferredOwnerRow.trigger_pattern + : ownerRow.trigger_pattern, requiresTrigger: (first.requires_trigger ?? 1) === 1, isMain: (first.is_main ?? 0) === 1, ownerAgentType, @@ -3080,11 +3375,11 @@ function updateStoredRoomMetadata( } function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void { - const snapshot = collectRoomRegistrationSnapshot(db, chatJid); + const existingSettings = getStoredRoomSettingsRowFromDatabase(db, chatJid); + const snapshot = collectRoomRegistrationSnapshot(db, chatJid, existingSettings); if (!snapshot) return; - const existing = getStoredRoomModeRow(chatJid); - if (!existing) { + if (!existingSettings) { insertStoredRoomSettings( db, chatJid, @@ -3179,10 +3474,14 @@ function backfillStoredRoomSettings(database: Database): void { const tx = database.transaction((registeredRows: Array<{ jid: string }>) => { for (const row of registeredRows) { - const snapshot = collectRoomRegistrationSnapshot(database, row.jid); + const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid); + const snapshot = collectRoomRegistrationSnapshot( + database, + row.jid, + existing, + ); if (!snapshot) continue; - const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid); if (existing) { updateStoredRoomMetadata(database, row.jid, snapshot); continue; @@ -3202,6 +3501,810 @@ function backfillStoredRoomSettings(database: Database): void { tx(rows); } +function collectRegisteredAgentTypesForFolder( + database: Database, + folder: string, +): AgentType[] { + const rows = database + .prepare('SELECT agent_type FROM registered_groups WHERE folder = ?') + .all(folder) as Array<{ agent_type: string | null }>; + + const types = new Set(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (agentType) { + types.add(agentType); + } + } + return [...types]; +} + +function resolveStablePairedTaskOwnerAgentType( + database: Database, + task: Pick, +): AgentType | undefined { + const persistedOwnerAgentType = normalizeStoredAgentType(task.owner_agent_type); + if (persistedOwnerAgentType) { + return persistedOwnerAgentType; + } + + const stored = getStoredRoomSettingsRowFromDatabase(database, task.chat_jid); + if (stored?.ownerAgentType) { + return stored.ownerAgentType; + } + + const jidAgentTypes = collectRegisteredAgentTypes(database, task.chat_jid); + if (jidAgentTypes.length > 0) { + return inferOwnerAgentTypeFromRegisteredAgentTypes(jidAgentTypes); + } + + const folderAgentTypes = collectRegisteredAgentTypesForFolder( + database, + task.group_folder, + ); + if (folderAgentTypes.length > 0) { + return inferOwnerAgentTypeFromRegisteredAgentTypes(folderAgentTypes); + } + + return undefined; +} + +function resolveStableReviewerAgentType( + ownerAgentType: AgentType | undefined, + fallbackReviewerAgentType?: string | null, +): AgentType | null { + const persistedReviewerAgentType = normalizeStoredAgentType( + fallbackReviewerAgentType, + ); + if (persistedReviewerAgentType) { + return persistedReviewerAgentType; + } + + if (ownerAgentType) { + return REVIEWER_AGENT_TYPE !== ownerAgentType + ? REVIEWER_AGENT_TYPE + : ownerAgentType; + } + return null; +} + +function resolveStableRoomRoleAgentType( + database: Database, + input: { + chatJid: string; + groupFolder: string; + role: PairedRoomRole; + }, +): AgentType | null | undefined { + if (input.role === 'owner') { + return resolveStablePairedTaskOwnerAgentType(database, { + chat_jid: input.chatJid, + group_folder: input.groupFolder, + owner_agent_type: null, + }); + } + + if (input.role === 'reviewer') { + const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, { + chat_jid: input.chatJid, + group_folder: input.groupFolder, + owner_agent_type: null, + }); + return resolveStableReviewerAgentType(ownerAgentType, null); + } + + return ARBITER_AGENT_TYPE ?? null; +} + +function resolveStableLeaseOwnerAgentType( + database: Database, + row: Pick< + ChannelOwnerLeaseRow, + 'chat_jid' | 'owner_service_id' | 'owner_agent_type' + >, +): AgentType | undefined { + const persisted = normalizeStoredAgentType(row.owner_agent_type); + if (persisted) { + return persisted; + } + const stored = getStoredRoomSettingsRowFromDatabase(database, row.chat_jid); + if (stored?.ownerAgentType) { + return stored.ownerAgentType; + } + return inferAgentTypeFromServiceShadow(row.owner_service_id); +} + +function resolveStableLeaseRoleAgentType( + database: Database, + row: Pick< + ChannelOwnerLeaseRow, + | 'chat_jid' + | 'owner_service_id' + | 'reviewer_service_id' + | 'arbiter_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + >, + role: PairedRoomRole, +): AgentType | null | undefined { + if (role === 'owner') { + return resolveStableLeaseOwnerAgentType(database, row); + } + if (role === 'reviewer') { + if (row.reviewer_service_id == null) { + return null; + } + return ( + normalizeStoredAgentType(row.reviewer_agent_type) ?? + inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? + resolveStableReviewerAgentType( + resolveStableLeaseOwnerAgentType(database, row), + null, + ) + ); + } + return ( + normalizeStoredAgentType(row.arbiter_agent_type) ?? + (row.arbiter_service_id + ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) + : undefined) ?? + ARBITER_AGENT_TYPE ?? + null + ); +} + +function backfillChannelOwnerRoleMetadata(database: Database): void { + const rows = database + .prepare( + `SELECT + chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type + FROM channel_owner`, + ) + .all() as Array< + Pick< + ChannelOwnerLeaseRow, + | 'chat_jid' + | 'owner_service_id' + | 'reviewer_service_id' + | 'arbiter_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > + >; + + const update = database.prepare( + `UPDATE channel_owner + SET owner_service_id = ?, + reviewer_service_id = ?, + arbiter_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? + WHERE chat_jid = ?`, + ); + + const tx = database.transaction( + ( + leaseRows: Array< + Pick< + ChannelOwnerLeaseRow, + | 'chat_jid' + | 'owner_service_id' + | 'reviewer_service_id' + | 'arbiter_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > + >, + ) => { + for (const row of leaseRows) { + const ownerAgentType = resolveStableLeaseRoleAgentType( + database, + row, + 'owner', + ); + const reviewerAgentType = resolveStableLeaseRoleAgentType( + database, + row, + 'reviewer', + ); + const arbiterAgentType = resolveStableLeaseRoleAgentType( + database, + row, + 'arbiter', + ); + + const ownerServiceId = + resolveRoleServiceShadow('owner', ownerAgentType) ?? + row.owner_service_id; + const reviewerServiceId = + row.reviewer_service_id == null + ? null + : resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + row.reviewer_service_id; + const arbiterServiceId = + row.arbiter_service_id == null + ? null + : resolveRoleServiceShadow('arbiter', arbiterAgentType) ?? + row.arbiter_service_id; + + if ( + ownerServiceId === row.owner_service_id && + reviewerServiceId === row.reviewer_service_id && + arbiterServiceId === row.arbiter_service_id && + (ownerAgentType ?? null) === (row.owner_agent_type ?? null) && + (reviewerAgentType ?? null) === (row.reviewer_agent_type ?? null) && + (arbiterAgentType ?? null) === (row.arbiter_agent_type ?? null) + ) { + continue; + } + + update.run( + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + ownerAgentType ?? null, + reviewerAgentType ?? null, + arbiterAgentType ?? null, + row.chat_jid, + ); + } + }, + ); + + tx(rows); +} + +function resolveWorkItemServiceShadow( + agentType: AgentType, + deliveryRole?: PairedRoomRole | null, +): string { + return ( + resolveRoleServiceShadow(deliveryRole ?? 'owner', agentType) ?? + SERVICE_SESSION_SCOPE + ); +} + +function backfillLegacyServiceSessions(database: Database): void { + const rows = database + .prepare( + `SELECT group_folder, service_id, session_id + FROM service_sessions`, + ) + .all() as Array<{ + group_folder: string; + service_id: string; + session_id: string; + }>; + + const upsert = database.prepare( + `INSERT OR IGNORE INTO sessions (group_folder, agent_type, session_id) + VALUES (?, ?, ?)`, + ); + + const tx = database.transaction( + ( + sessionRows: Array<{ + group_folder: string; + service_id: string; + session_id: string; + }>, + ) => { + for (const row of sessionRows) { + const agentType = inferAgentTypeFromServiceShadow(row.service_id); + if (!agentType) { + continue; + } + upsert.run(row.group_folder, agentType, row.session_id); + } + }, + ); + + tx(rows); +} + +function backfillWorkItemServiceShadows(database: Database): void { + const rows = database + .prepare( + `SELECT id, agent_type, service_id, delivery_role + FROM work_items`, + ) + .all() as Array<{ + id: number; + agent_type: string; + service_id: string; + delivery_role: PairedRoomRole | null; + }>; + + const update = database.prepare( + `UPDATE work_items + SET service_id = ? + WHERE id = ?`, + ); + + const tx = database.transaction( + ( + workItemRows: Array<{ + id: number; + agent_type: string; + service_id: string; + delivery_role: PairedRoomRole | null; + }>, + ) => { + for (const row of workItemRows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (!agentType) { + continue; + } + const normalizedServiceId = resolveWorkItemServiceShadow( + agentType, + row.delivery_role, + ); + if (normalizedServiceId === row.service_id) { + continue; + } + update.run(normalizedServiceId, row.id); + } + }, + ); + + tx(rows); +} + +function backfillServiceHandoffServiceShadows(database: Database): void { + const rows = database + .prepare( + `SELECT + id, + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type + FROM service_handoffs`, + ) + .all() as Array; + + const update = database.prepare( + `UPDATE service_handoffs + SET source_service_id = ?, + target_service_id = ?, + source_agent_type = ?, + target_agent_type = ? + WHERE id = ?`, + ); + + const tx = database.transaction( + (handoffRows: Array) => { + for (const row of handoffRows) { + const sourceAgentType = + normalizeStoredAgentType(row.source_agent_type) ?? + (row.source_role + ? resolveStableRoomRoleAgentType(database, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: row.source_role, + }) + : null) ?? + inferAgentTypeFromServiceShadow(row.source_service_id); + const targetAgentType = + normalizeStoredAgentType(row.target_agent_type) ?? + (row.target_role + ? resolveStableRoomRoleAgentType(database, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: row.target_role, + }) + : null); + + const normalizedSourceServiceId = + row.source_role != null + ? resolveRoleServiceShadow(row.source_role, sourceAgentType) ?? + row.source_service_id + : row.source_service_id; + const normalizedTargetServiceId = + row.target_role != null + ? resolveRoleServiceShadow(row.target_role, targetAgentType) ?? + row.target_service_id + : row.target_service_id; + + if ( + normalizedSourceServiceId === row.source_service_id && + normalizedTargetServiceId === row.target_service_id && + (sourceAgentType ?? null) === (row.source_agent_type ?? null) && + (targetAgentType ?? null) === (row.target_agent_type ?? null) + ) { + continue; + } + + update.run( + normalizedSourceServiceId ?? SERVICE_SESSION_SCOPE, + normalizedTargetServiceId ?? SERVICE_SESSION_SCOPE, + sourceAgentType ?? null, + targetAgentType ?? null, + row.id, + ); + } + }, + ); + + tx(rows); +} + +function backfillPairedTaskRoleMetadata(database: Database): void { + const rows = database + .prepare( + `SELECT + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type + FROM paired_tasks`, + ) + .all() as Array< + Pick< + PairedTask, + | 'id' + | 'chat_jid' + | 'group_folder' + | 'owner_service_id' + | 'reviewer_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > + >; + + const tx = database.transaction( + ( + taskRows: Array< + Pick< + PairedTask, + | 'id' + | 'chat_jid' + | 'group_folder' + | 'owner_service_id' + | 'reviewer_service_id' + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + > + >, + ) => { + const update = database.prepare( + `UPDATE paired_tasks + SET owner_service_id = ?, + reviewer_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? + WHERE id = ?`, + ); + + for (const row of taskRows) { + const ownerAgentType = resolveStablePairedTaskOwnerAgentType( + database, + row, + ); + const reviewerAgentType = resolveStableReviewerAgentType( + ownerAgentType, + row.reviewer_agent_type ?? null, + ); + const arbiterAgentType = + normalizeStoredAgentType(row.arbiter_agent_type) ?? + ARBITER_AGENT_TYPE ?? + null; + + const ownerServiceId = + resolveRoleServiceShadow('owner', ownerAgentType) ?? + row.owner_service_id; + const reviewerServiceId = + resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + row.reviewer_service_id; + + update.run( + ownerServiceId, + reviewerServiceId, + ownerAgentType ?? null, + reviewerAgentType ?? null, + arbiterAgentType, + row.id, + ); + } + }, + ); + + tx(rows); +} + +function rebuildWorkItemsCanonicalSchema(database: Database): void { + if (!tableHasColumn(database, 'work_items', 'service_id')) { + return; + } + + database.exec(` + CREATE TABLE work_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_folder TEXT NOT NULL, + chat_jid TEXT NOT NULL, + agent_type TEXT NOT NULL, + delivery_role TEXT, + status TEXT NOT NULL DEFAULT 'produced', + start_seq INTEGER, + end_seq INTEGER, + result_payload TEXT NOT NULL, + delivery_attempts INTEGER NOT NULL DEFAULT 0, + delivery_message_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT, + CHECK (status IN ('produced', 'delivery_retry', 'delivered')), + CHECK (delivery_role IN ('owner', 'reviewer', 'arbiter') OR delivery_role IS NULL) + ); + INSERT INTO work_items_new ( + id, + group_folder, + chat_jid, + agent_type, + delivery_role, + status, + start_seq, + end_seq, + result_payload, + delivery_attempts, + delivery_message_id, + last_error, + created_at, + updated_at, + delivered_at + ) + SELECT + id, + group_folder, + chat_jid, + agent_type, + delivery_role, + status, + start_seq, + end_seq, + result_payload, + delivery_attempts, + delivery_message_id, + last_error, + created_at, + updated_at, + delivered_at + FROM work_items; + DROP TABLE work_items; + ALTER TABLE work_items_new RENAME TO work_items; + CREATE INDEX IF NOT EXISTS idx_work_items_status + ON work_items(status, updated_at); + CREATE INDEX IF NOT EXISTS idx_work_items_group_agent + ON work_items(chat_jid, agent_type, delivery_role, status); + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open + ON work_items(chat_jid, agent_type, IFNULL(delivery_role, '')) + WHERE status IN ('produced', 'delivery_retry'); + `); +} + +function dropLegacyServiceSessionsTable(database: Database): void { + const serviceSessionsExists = Boolean( + database + .prepare( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`, + ) + .get(), + ); + if (!serviceSessionsExists) { + return; + } + database.exec(`DROP TABLE service_sessions`); +} + +function rebuildChannelOwnerCanonicalSchema(database: Database): void { + if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) { + return; + } + + database.exec(` + CREATE TABLE channel_owner_new ( + chat_jid TEXT PRIMARY KEY, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + activated_at TEXT, + reason TEXT, + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) + ); + INSERT INTO channel_owner_new ( + chat_jid, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason + ) + SELECT + chat_jid, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason + FROM channel_owner; + DROP TABLE channel_owner; + ALTER TABLE channel_owner_new RENAME TO channel_owner; + `); +} + +function rebuildPairedTasksCanonicalSchema(database: Database): void { + if (!tableHasColumn(database, 'paired_tasks', 'owner_service_id')) { + return; + } + + database.exec(` + CREATE TABLE paired_tasks_new ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) + ); + INSERT INTO paired_tasks_new ( + id, + chat_jid, + group_folder, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) + SELECT + id, + chat_jid, + group_folder, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + FROM paired_tasks; + DROP TABLE paired_tasks; + ALTER TABLE paired_tasks_new RENAME TO paired_tasks; + CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status + ON paired_tasks(chat_jid, status, updated_at); + `); +} + +function rebuildServiceHandoffsCanonicalSchema(database: Database): void { + if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) { + return; + } + + database.exec(` + CREATE TABLE service_handoffs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + source_role TEXT, + source_agent_type TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + 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 (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL), + CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL), + CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL), + CHECK (source_agent_type IN ('claude-code', 'codex') OR source_agent_type IS NULL) + ); + INSERT INTO service_handoffs_new ( + id, + chat_jid, + group_folder, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + start_seq, + end_seq, + reason, + intended_role, + created_at, + claimed_at, + completed_at, + last_error + ) + SELECT + id, + chat_jid, + group_folder, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + start_seq, + end_seq, + reason, + intended_role, + created_at, + claimed_at, + completed_at, + last_error + FROM service_handoffs; + DROP TABLE service_handoffs; + ALTER TABLE service_handoffs_new RENAME TO service_handoffs; + CREATE INDEX IF NOT EXISTS idx_service_handoffs_target + ON service_handoffs(status, target_role, target_agent_type, created_at); + `); +} + // --- Paired task/project/workspace state --- export function upsertPairedProject(project: PairedProject): void { @@ -3242,8 +4345,9 @@ export function createPairedTask(task: PairedTask): void { id, chat_jid, group_folder, - owner_service_id, - reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, @@ -3255,14 +4359,15 @@ export function createPairedTask(task: PairedTask): void { created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.chat_jid, task.group_folder, - task.owner_service_id, - task.reviewer_service_id, + task.owner_agent_type ?? null, + task.reviewer_agent_type ?? null, + task.arbiter_agent_type ?? null, task.title, task.source_ref, task.plan_notes, @@ -3277,15 +4382,16 @@ export function createPairedTask(task: PairedTask): void { } export function getPairedTaskById(id: string): PairedTask | undefined { - return db.prepare('SELECT * FROM paired_tasks WHERE id = ?').get(id) as - | PairedTask + const row = db.prepare('SELECT * FROM paired_tasks WHERE id = ?').get(id) as + | StoredPairedTaskRow | undefined; + return row ? hydratePairedTaskRow(row) : undefined; } export function getLatestPairedTaskForChat( chatJid: string, ): PairedTask | undefined { - return db + const row = db .prepare( ` SELECT * @@ -3295,13 +4401,14 @@ export function getLatestPairedTaskForChat( LIMIT 1 `, ) - .get(chatJid) as PairedTask | undefined; + .get(chatJid) as StoredPairedTaskRow | undefined; + return row ? hydratePairedTaskRow(row) : undefined; } export function getLatestOpenPairedTaskForChat( chatJid: string, ): PairedTask | undefined { - return db + const row = db .prepare( ` SELECT * @@ -3312,7 +4419,8 @@ export function getLatestOpenPairedTaskForChat( LIMIT 1 `, ) - .get(chatJid) as PairedTask | undefined; + .get(chatJid) as StoredPairedTaskRow | undefined; + return row ? hydratePairedTaskRow(row) : undefined; } export function updatePairedTask( @@ -3466,46 +4574,81 @@ export function getLastBotFinalMessage( export function getChannelOwnerLease( chatJid: string, ): ChannelOwnerLeaseRow | undefined { - return db + const row = db .prepare( - `SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason + `SELECT + chat_jid, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason FROM channel_owner WHERE chat_jid = ?`, ) - .get(chatJid) as ChannelOwnerLeaseRow | undefined; + .get(chatJid) as StoredChannelOwnerLeaseRow | undefined; + return row ? hydrateChannelOwnerLeaseRow(row) : undefined; } export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] { - return db + const rows = db .prepare( - `SELECT chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, activated_at, reason + `SELECT + chat_jid, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason FROM channel_owner`, ) - .all() as ChannelOwnerLeaseRow[]; + .all() as StoredChannelOwnerLeaseRow[]; + return rows.map(hydrateChannelOwnerLeaseRow); } export function setChannelOwnerLease(input: { chat_jid: string; - owner_service_id: string; + owner_service_id?: string; reviewer_service_id?: string | null; arbiter_service_id?: string | null; + owner_agent_type?: AgentType | null; + reviewer_agent_type?: AgentType | null; + arbiter_agent_type?: AgentType | null; activated_at?: string | null; reason?: string | null; }): void { + const ownerAgentType = + normalizeStoredAgentType(input.owner_agent_type) ?? + inferAgentTypeFromServiceShadow(input.owner_service_id) ?? + OWNER_AGENT_TYPE; + const reviewerAgentType = + input.reviewer_service_id == null && input.reviewer_agent_type == null + ? null + : normalizeStoredAgentType(input.reviewer_agent_type) ?? + inferAgentTypeFromServiceShadow(input.reviewer_service_id ?? null) ?? + resolveStableReviewerAgentType(ownerAgentType, null); + const arbiterAgentType = + input.arbiter_service_id == null && input.arbiter_agent_type == null + ? null + : normalizeStoredAgentType(input.arbiter_agent_type) ?? + inferAgentTypeFromServiceShadow(input.arbiter_service_id ?? null) ?? + ARBITER_AGENT_TYPE ?? + null; + db.prepare( `INSERT OR REPLACE INTO channel_owner ( chat_jid, - owner_service_id, - reviewer_service_id, - arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, activated_at, reason ) VALUES (?, ?, ?, ?, ?, ?)`, ).run( input.chat_jid, - input.owner_service_id, - input.reviewer_service_id ?? null, - input.arbiter_service_id ?? null, + ownerAgentType ?? null, + reviewerAgentType ?? null, + arbiterAgentType ?? null, input.activated_at ?? new Date().toISOString(), input.reason ?? null, ); @@ -3520,8 +4663,11 @@ export function clearChannelOwnerLease(chatJid: string): void { export function createServiceHandoff(input: { chat_jid: string; group_folder: string; - source_service_id: string; - target_service_id: string; + source_service_id?: string; + target_service_id?: string; + source_role?: PairedRoomRole | null; + target_role?: PairedRoomRole | null; + source_agent_type?: AgentType | null; target_agent_type: AgentType; prompt: string; start_seq?: number | null; @@ -3529,24 +4675,38 @@ export function createServiceHandoff(input: { reason?: string | null; intended_role?: PairedRoomRole | null; }): ServiceHandoff { + const sourceRole = input.source_role ?? input.intended_role ?? null; + const targetRole = input.target_role ?? input.intended_role ?? null; + const sourceAgentType = + normalizeStoredAgentType(input.source_agent_type) ?? + (sourceRole + ? resolveStableRoomRoleAgentType(db, { + chatJid: input.chat_jid, + groupFolder: input.group_folder, + role: sourceRole, + }) + : null); + db.prepare( `INSERT INTO service_handoffs ( chat_jid, group_folder, - source_service_id, - target_service_id, + source_role, + source_agent_type, + target_role, target_agent_type, prompt, start_seq, end_seq, reason, intended_role - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.chat_jid, input.group_folder, - input.source_service_id, - input.target_service_id, + sourceRole, + sourceAgentType ?? null, + targetRole, input.target_agent_type, input.prompt, input.start_seq ?? null, @@ -3558,23 +4718,44 @@ export function createServiceHandoff(input: { const lastId = ( db.prepare('SELECT last_insert_rowid() as id').get() as { id: number } ).id; - return db - .prepare('SELECT * FROM service_handoffs WHERE id = ?') - .get(lastId) as ServiceHandoff; + return hydrateServiceHandoffRow( + db.prepare('SELECT * FROM service_handoffs WHERE id = ?').get( + lastId, + ) as StoredServiceHandoffRow, + ); } export function getPendingServiceHandoffs( targetServiceId: string = SERVICE_SESSION_SCOPE, ): ServiceHandoff[] { - return db + const handoffs = db .prepare( `SELECT * FROM service_handoffs - WHERE target_service_id = ? - AND status = 'pending' + WHERE status = 'pending' ORDER BY created_at ASC, id ASC`, ) - .all(targetServiceId) as ServiceHandoff[]; + .all() as StoredServiceHandoffRow[]; + return handoffs + .map(hydrateServiceHandoffRow) + .filter( + (handoff) => + normalizeServiceId(handoff.target_service_id) === + normalizeServiceId(targetServiceId), + ); +} + +export function getAllPendingServiceHandoffs(): ServiceHandoff[] { + return ( + db + .prepare( + `SELECT * + FROM service_handoffs + WHERE status = 'pending' + ORDER BY created_at ASC, id ASC`, + ) + .all() as StoredServiceHandoffRow[] + ).map(hydrateServiceHandoffRow); } export function claimServiceHandoff(id: number): boolean { @@ -3657,31 +4838,26 @@ function parseLastAgentSeqState( export function completeServiceHandoffAndAdvanceTargetCursor(input: { id: number; - target_service_id: string; chat_jid: string; + cursor_key?: string; end_seq?: number | null; }): string | null { return db.transaction(() => { let appliedCursor: string | null = null; if (input.end_seq != null) { + const cursorKey = input.cursor_key ?? input.chat_jid; const currentState = parseLastAgentSeqState( - getRouterStateForService('last_agent_seq', input.target_service_id), - input.target_service_id, + getRouterState('last_agent_seq'), + 'last_agent_seq', ); const existingSeq = normalizeStoredLastAgentSeqCursor( - currentState[input.chat_jid], + currentState[cursorKey], input.chat_jid, ); - currentState[input.chat_jid] = String( - Math.max(existingSeq, input.end_seq), - ); - setRouterStateForService( - 'last_agent_seq', - JSON.stringify(currentState), - input.target_service_id, - ); - appliedCursor = currentState[input.chat_jid]; + currentState[cursorKey] = String(Math.max(existingSeq, input.end_seq)); + setRouterState('last_agent_seq', JSON.stringify(currentState)); + appliedCursor = currentState[cursorKey]; } db.prepare( diff --git a/src/index.ts b/src/index.ts index 4f5fd25..7f6e61a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ import { DATA_DIR, IDLE_TIMEOUT, POLL_INTERVAL, - REVIEWER_AGENT_TYPE, SERVICE_ID, isSessionCommandSenderAllowed, STATUS_CHANNEL_ID, @@ -445,10 +444,8 @@ async function main(): Promise { } // Start subsystems (independently of connection handler) - // Resolve the reviewer channel so cron output in paired rooms is posted - // via the reviewer bot — the owner then treats it as a peer request. - const reviewerChannelName = - REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; + // Paired-room scheduler output goes through the reviewer bot slot. + const reviewerChannelName = 'discord-review'; const reviewerChannelForCron = findChannelByName( channels, reviewerChannelName, diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 89ec15f..9972ea7 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -11,6 +11,8 @@ vi.mock('./available-groups.js', () => ({ })); vi.mock('./config.js', () => ({ + ARBITER_SERVICE_ID: null, + CLAUDE_SERVICE_ID: 'claude', CODEX_MAIN_SERVICE_ID: 'codex-main', CODEX_REVIEW_SERVICE_ID: 'codex-review', DATA_DIR: '/tmp/ejclaw-test-data', @@ -61,13 +63,48 @@ vi.mock('./service-routing.js', () => ({ clearGlobalFailover: vi.fn(), getEffectiveChannelLease: vi.fn(() => ({ chat_jid: 'group@test', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, owner_service_id: 'claude', - reviewer_service_id: 'codex-main', + reviewer_service_id: 'codex-review', arbiter_service_id: null, activated_at: null, reason: null, explicit: false, })), + resolveLeaseServiceId: vi.fn( + ( + lease: { + owner_service_id: string; + reviewer_service_id: string | null; + arbiter_service_id: string | null; + owner_agent_type?: 'claude-code' | 'codex'; + reviewer_agent_type?: 'claude-code' | 'codex' | null; + arbiter_agent_type?: 'claude-code' | 'codex' | null; + owner_failover_active?: boolean; + }, + role: 'owner' | 'reviewer' | 'arbiter', + ) => { + if (role === 'owner') { + return lease.owner_failover_active + ? lease.owner_service_id + : lease.owner_agent_type === 'codex' + ? 'codex-main' + : 'claude'; + } + if (role === 'reviewer') { + if (lease.reviewer_agent_type === 'codex') { + return 'codex-review'; + } + return lease.reviewer_service_id; + } + if (lease.arbiter_agent_type === 'codex') { + return lease.arbiter_service_id ?? 'codex-review'; + } + return lease.arbiter_service_id; + }, + ), })); vi.mock('./logger.js', () => { @@ -155,6 +192,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 { logger } from './logger.js'; import { buildRoomMemoryBriefing } from './sqlite-memory-store.js'; import { runAgentForGroup } from './message-agent-executor.js'; import * as pairedExecutionContext from './paired-execution-context.js'; @@ -299,13 +337,13 @@ describe('runAgentForGroup room memory', () => { expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( group, expect.objectContaining({ - roomRoleContext: { + roomRoleContext: expect.objectContaining({ serviceId: 'claude', role: 'owner', ownerServiceId: 'claude', - reviewerServiceId: 'codex-main', + reviewerServiceId: 'codex-review', failoverOwner: false, - }, + }), }), expect.any(Function), undefined, @@ -317,6 +355,9 @@ describe('runAgentForGroup room memory', () => { const group = { ...makeGroup(), folder: 'test-group' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'claude', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -347,6 +388,9 @@ describe('runAgentForGroup room memory', () => { const group = { ...makeGroup(), folder: 'test-group' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'claude', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -383,13 +427,13 @@ describe('runAgentForGroup room memory', () => { expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( group, expect.objectContaining({ - roomRoleContext: { + roomRoleContext: expect.objectContaining({ serviceId: 'claude', role: 'reviewer', ownerServiceId: 'claude', reviewerServiceId: 'claude', failoverOwner: false, - }, + }), }), expect.any(Function), undefined, @@ -401,6 +445,9 @@ describe('runAgentForGroup room memory', () => { const group = { ...makeGroup(), folder: 'test-group' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -438,13 +485,13 @@ describe('runAgentForGroup room memory', () => { expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( group, expect.objectContaining({ - roomRoleContext: { + roomRoleContext: expect.objectContaining({ serviceId: 'claude', role: 'reviewer', ownerServiceId: 'codex-main', reviewerServiceId: 'claude', failoverOwner: false, - }, + }), }), expect.any(Function), undefined, @@ -460,6 +507,9 @@ describe('runAgentForGroup room memory', () => { }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -514,10 +564,70 @@ describe('runAgentForGroup room memory', () => { ); }); + it('does not inject reviewer model overrides into a forced codex fallback run', async () => { + const group: RegisteredGroup = { + ...makeGroup(), + agentType: 'codex', + folder: 'test-group', + }; + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-reviewer-failover-model', + 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', + }, + workspace: null, + envOverrides: {}, + }); + const config = await import('./config.js'); + vi.mocked(config.getRoleModelConfig).mockReturnValue({ + model: 'claude-opus-4-6', + effort: 'high', + fallbackEnabled: true, + }); + + await runAgentForGroup(makeDeps(), { + group, + prompt: 'please retry review with codex', + chatJid: 'group@test', + runId: 'run-forced-reviewer-codex-model', + forcedRole: 'reviewer', + forcedAgentType: 'codex', + }); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'codex', + }), + expect.any(Object), + expect.any(Function), + undefined, + {}, + ); + }); + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -663,6 +773,114 @@ describe('runAgentForGroup room memory', () => { }); }); + it('logs streamed activity with resolved execution attribution', async () => { + const group = { + ...makeGroup(), + folder: 'test-group', + workDir: '/repo/canonical', + }; + + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-reviewer-log', + 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: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }, + workspace: { + id: 'paired-task-reviewer-log:reviewer', + task_id: 'paired-task-reviewer-log', + role: 'reviewer', + workspace_dir: '/tmp/paired/reviewer', + snapshot_source_dir: '/repo/canonical', + snapshot_ref: 'HEAD', + status: 'ready', + snapshot_refreshed_at: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }, + envOverrides: { + EJCLAW_WORK_DIR: '/tmp/paired/reviewer', + EJCLAW_PAIRED_TASK_ID: 'paired-task-reviewer-log', + EJCLAW_PAIRED_ROLE: 'reviewer', + }, + }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: 'apply_patch setup/service.ts', + phase: 'progress', + newSessionId: 'session-progress-1', + }); + await onOutput?.({ + status: 'success', + result: 'DONE\nreview complete', + output: { visibility: 'public', text: 'DONE\nreview complete' }, + phase: 'final', + newSessionId: 'session-final-1', + }); + return { + status: 'success', + result: 'DONE\nreview complete', + newSessionId: 'session-final-1', + }; + }, + ); + + await runAgentForGroup(makeDeps(), { + group, + prompt: 'please review this change', + chatJid: 'group@test', + runId: 'run-attribution-log', + forcedRole: 'reviewer', + onOutput: async () => {}, + }); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'claude', + outputPhase: 'progress', + outputStatus: 'success', + activeRole: 'reviewer', + effectiveAgentType: 'claude-code', + roomRoleServiceId: 'claude', + roomRole: 'reviewer', + pairedTaskId: 'paired-task-reviewer-log', + workspaceDir: '/tmp/paired/reviewer', + preview: 'apply_patch setup/service.ts', + streamedSessionId: 'session-progress-1', + }), + 'Observed streamed agent activity', + ); + }); + it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => { const group = { ...makeGroup(), @@ -673,6 +891,9 @@ describe('runAgentForGroup room memory', () => { vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -759,6 +980,9 @@ describe('runAgentForGroup room memory', () => { vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: null, @@ -1011,7 +1235,7 @@ describe('runAgentForGroup Claude rotation', () => { expect(outputs).toEqual(['fresh Claude retry success']); }); - it('returns error when the fresh Claude retry also hits the same retryable thinking 400', async () => { + it('hands off to codex when the fresh Claude retry also hits the same retryable thinking 400', async () => { const outputs: string[] = []; const deps = makeDeps(); @@ -1055,10 +1279,25 @@ describe('runAgentForGroup Claude rotation', () => { }, }); - expect(result).toBe('error'); + expect(result).toBe('success'); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(deps.clearSession).toHaveBeenCalledTimes(2); expect(outputs).toEqual([]); + expect(serviceRouting.activateCodexFailover).toHaveBeenCalledWith( + 'group@test', + 'claude-session-failure', + ); + expect(db.createServiceHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + chat_jid: 'group@test', + source_role: 'owner', + target_role: 'owner', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + reason: 'claude-session-failure', + intended_role: 'owner', + }), + ); }); it('returns error after all Claude accounts are usage-exhausted', async () => { @@ -1117,7 +1356,9 @@ describe('runAgentForGroup Claude rotation', () => { expect(db.createServiceHandoff).toHaveBeenCalledWith( expect.objectContaining({ chat_jid: 'group@test', - target_service_id: 'codex-review', + source_role: 'owner', + source_agent_type: 'claude-code', + target_role: 'owner', target_agent_type: 'codex', start_seq: 10, end_seq: 12, @@ -1127,6 +1368,57 @@ describe('runAgentForGroup Claude rotation', () => { ); }); + it('hands off to codex after repeated retryable Claude session failures', async () => { + vi.mocked( + sessionRecovery.shouldRetryFreshSessionOnAgentFailure, + ).mockReturnValue(true); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: null, + }); + return { + status: 'success', + result: null, + }; + }, + ); + + const deps = makeDeps(); + const result = await runAgentForGroup(deps, { + group: makeGroup(), + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-session-failure-handoff', + startSeq: 3, + endSeq: 7, + onOutput: async () => {}, + }); + + expect(result).toBe('success'); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); + expect(deps.clearSession).toHaveBeenCalledTimes(2); + expect(serviceRouting.activateCodexFailover).toHaveBeenCalledWith( + 'group@test', + 'claude-session-failure', + ); + expect(db.createServiceHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + chat_jid: 'group@test', + source_role: 'owner', + target_role: 'owner', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + start_seq: 3, + end_seq: 7, + reason: 'claude-session-failure', + intended_role: 'owner', + }), + ); + }); + it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => { const outputs: string[] = []; @@ -1322,7 +1614,9 @@ describe('runAgentForGroup Claude rotation', () => { expect(db.createServiceHandoff).toHaveBeenCalledWith( expect.objectContaining({ chat_jid: 'group@test', - target_service_id: 'codex-review', + source_role: 'owner', + source_agent_type: 'claude-code', + target_role: 'owner', 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 d7ba990..9f54611 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -58,6 +58,7 @@ import { readArbiterPrompt } from './platform-prompts.js'; import { activateCodexFailover, getEffectiveChannelLease, + resolveLeaseServiceId, } from './service-routing.js'; import { evaluateStreamedOutput, @@ -108,26 +109,26 @@ export async function runAgentForGroup( // In unified mode, determine role from the lease directly. // Default to owner; the auto-review trigger in completePairedExecutionContext // will switch to reviewer when the task is in review_ready state. - const pairedTask = currentLease.reviewer_service_id + const pairedTask = currentLease.reviewer_agent_type ? getLatestOpenPairedTaskForChat(chatJid) : null; 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_agent_type) || + (args.forcedRole === 'arbiter' && currentLease.arbiter_agent_type), ); const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; - const effectiveServiceId = - activeRole === 'arbiter' - ? currentLease.arbiter_service_id! - : activeRole === 'reviewer' - ? currentLease.reviewer_service_id! - : currentLease.owner_service_id; + const effectiveServiceId = resolveLeaseServiceId(currentLease, activeRole); + if (!effectiveServiceId) { + throw new Error(`Missing runtime service id for ${activeRole} lease`); + } const reviewerMode = activeRole === 'reviewer'; const arbiterMode = activeRole === 'arbiter'; + const reviewerServiceId = resolveLeaseServiceId(currentLease, 'reviewer'); + const arbiterServiceId = resolveLeaseServiceId(currentLease, 'arbiter'); const roleAgentPlan = resolveConfiguredRoleAgentPlan( - currentLease.reviewer_service_id != null, + currentLease.reviewer_agent_type != null, group.agentType, ); @@ -194,8 +195,10 @@ export async function runAgentForGroup( roomRoleContext, hasHumanMessage: args.hasHumanMessage, }); - // Inject role-specific model overrides into envOverrides - if (pairedExecutionContext) { + // Forced fallbacks run under a different agent runtime, so keep the + // fallback session on its default model/effort unless explicitly configured + // for that runtime elsewhere. + if (pairedExecutionContext && !args.forcedAgentType) { const roleConfig = getRoleModelConfig(activeRole); if (roleConfig.model) { const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL'; @@ -230,8 +233,10 @@ export async function runAgentForGroup( groupAgentType: group.agentType, configuredReviewerAgentType: REVIEWER_AGENT_TYPE, configuredArbiterAgentType: ARBITER_AGENT_TYPE, - reviewerServiceId: currentLease.reviewer_service_id, - arbiterServiceId: currentLease.arbiter_service_id, + reviewerServiceId, + arbiterServiceId, + reviewerAgentType: currentLease.reviewer_agent_type, + arbiterAgentType: currentLease.arbiter_agent_type, reviewerMode, arbiterMode, sessionFolder, @@ -295,7 +300,8 @@ export async function runAgentForGroup( reason === '429' || reason === 'usage-exhausted' || reason === 'auth-expired' || - reason === 'org-access-denied' + reason === 'org-access-denied' || + reason === 'session-failure' ); }; @@ -307,7 +313,7 @@ export async function runAgentForGroup( if (!shouldHandoffToCodex(reason, sawVisibleOutput)) { return false; } - if (currentLease.reviewer_service_id === null) { + if (currentLease.reviewer_agent_type === null) { return false; } // Per-role fallback toggle @@ -322,8 +328,9 @@ export async function runAgentForGroup( createServiceHandoff({ chat_jid: chatJid, group_folder: group.folder, - source_service_id: SERVICE_SESSION_SCOPE, - target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: activeRole, + target_role: 'arbiter', + source_agent_type: effectiveAgentType, target_agent_type: 'codex', prompt, start_seq: startSeq ?? null, @@ -344,8 +351,9 @@ export async function runAgentForGroup( createServiceHandoff({ chat_jid: chatJid, group_folder: group.folder, - source_service_id: SERVICE_SESSION_SCOPE, - target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: activeRole, + target_role: 'reviewer', + source_agent_type: effectiveAgentType, target_agent_type: 'codex', prompt, start_seq: startSeq ?? null, @@ -364,8 +372,9 @@ export async function runAgentForGroup( createServiceHandoff({ chat_jid: chatJid, group_folder: group.folder, - source_service_id: SERVICE_SESSION_SCOPE, - target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: activeRole, + target_role: activeRole, + source_agent_type: effectiveAgentType, target_agent_type: 'codex', prompt, start_seq: startSeq ?? null, @@ -375,7 +384,7 @@ export async function runAgentForGroup( }); log.warn( { reason }, - 'Claude unavailable, handed off current turn to codex-review', + 'Claude unavailable, handed off current owner turn to codex fallback', ); return true; }; @@ -442,6 +451,41 @@ export async function runAgentForGroup( const wrappedOnOutput = onOutput ? async (output: AgentOutput) => { + const outputPhase = output.phase ?? 'final'; + const outputText = getAgentOutputText(output); + const structuredOutput = getStructuredAgentOutput(output); + if (outputPhase !== 'final') { + log.info( + { + provider, + outputPhase, + outputStatus: output.status, + visibility: structuredOutput?.visibility ?? null, + preview: + typeof outputText === 'string' && outputText.length > 0 + ? outputText.slice(0, 160) + : null, + errorPreview: + typeof output.error === 'string' && output.error.length > 0 + ? output.error.slice(0, 160) + : null, + activeRole, + effectiveServiceId, + effectiveAgentType, + sessionFolder, + resumedSession: sessionId ?? null, + streamedSessionId: output.newSessionId ?? null, + roomRoleServiceId: roomRoleContext?.serviceId ?? null, + roomRole: roomRoleContext?.role ?? null, + pairedTaskId: pairedExecutionContext?.task.id ?? null, + workspaceDir: + pairedExecutionContext?.workspace?.workspace_dir ?? + group.workDir ?? + null, + }, + 'Observed streamed agent activity', + ); + } if ( isClaudeCodeAgent && provider === 'claude' && @@ -468,7 +512,6 @@ export async function runAgentForGroup( }); streamedState = evaluation.state; - const outputText = getAgentOutputText(output); if (typeof outputText === 'string' && outputText.length > 0) { pairedExecutionSummary = outputText.slice(0, 500); pairedFullOutput = outputText; @@ -772,7 +815,7 @@ export async function runAgentForGroup( log.error( 'Retryable Claude session failure persisted after fresh retry', ); - return 'error'; + return maybeHandoffAfterError('session-failure', primaryAttempt); } } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index fb3188f..9f8032e 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -66,6 +66,7 @@ vi.mock('./db.js', () => { failServiceHandoff: vi.fn(), getAllChats: vi.fn(() => []), getAllTasks: vi.fn(() => []), + getAllPendingServiceHandoffs: vi.fn(() => []), getLastHumanMessageTimestamp: vi.fn(() => null), getLastHumanMessageContent: vi.fn(() => null), getMessagesSince, @@ -114,7 +115,6 @@ vi.mock('./db.js', () => { }, ), getOpenWorkItem: vi.fn(() => undefined), - getPendingServiceHandoffs: vi.fn(() => []), getLatestOpenPairedTaskForChat: vi.fn(() => undefined), getPairedTurnOutputs: vi.fn(() => []), getRecentChatMessages: vi.fn(() => []), @@ -124,6 +124,7 @@ vi.mock('./db.js', () => { chat_jid: input.chat_jid, agent_type: input.agent_type || 'claude-code', service_id: 'claude', + delivery_role: input.delivery_role ?? null, status: 'produced', start_seq: input.start_seq, end_seq: input.end_seq, @@ -151,6 +152,20 @@ vi.mock('./service-routing.js', () => ({ reason: null, explicit: false, })), + resolveLeaseServiceId: vi.fn( + ( + lease: { + owner_service_id: string; + reviewer_service_id: string | null; + arbiter_service_id?: string | null; + }, + role: 'owner' | 'reviewer' | 'arbiter', + ) => { + if (role === 'owner') return lease.owner_service_id; + if (role === 'reviewer') return lease.reviewer_service_id; + return lease.arbiter_service_id ?? null; + }, + ), shouldServiceProcessChat: vi.fn(() => true), })); @@ -185,6 +200,7 @@ import * as db from './db.js'; import { resolveGroupIpcPath } from './group-folder.js'; import { createMessageRuntime, + resolveHandoffCursorKey, resolveHandoffRoleOverride, } from './message-runtime.js'; import * as config from './config.js'; @@ -202,14 +218,18 @@ function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup { }; } -function makeChannel(chatJid: string): Channel { +function makeChannel( + chatJid: string, + name = 'discord', + ownsJid = true, +): Channel { return { - name: 'discord', + name, connect: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined), sendAndTrack: vi.fn().mockResolvedValue('progress-1'), isConnected: vi.fn(() => true), - ownsJid: vi.fn((jid: string) => jid === chatJid), + ownsJid: vi.fn((jid: string) => ownsJid && jid === chatJid), disconnect: vi.fn().mockResolvedValue(undefined), setTyping: vi.fn().mockResolvedValue(undefined), editMessage: vi.fn().mockResolvedValue(undefined), @@ -229,30 +249,52 @@ describe('createMessageRuntime', () => { it('prefers intended_role over reason prefixes for handoff role resolution', () => { expect( resolveHandoffRoleOverride({ + target_role: 'arbiter', + intended_role: 'reviewer', + reason: 'reviewer-claude-429', + }), + ).toBe('arbiter'); + expect( + resolveHandoffRoleOverride({ + target_role: null, intended_role: 'reviewer', reason: 'claude-429', }), ).toBe('reviewer'); expect( resolveHandoffRoleOverride({ + target_role: null, intended_role: null, reason: 'arbiter-claude-429', }), ).toBe('arbiter'); expect( resolveHandoffRoleOverride({ + target_role: null, intended_role: null, reason: 'reviewer-claude-usage-exhausted', }), ).toBe('reviewer'); expect( resolveHandoffRoleOverride({ + target_role: null, intended_role: null, reason: 'claude-usage-exhausted', }), ).toBeUndefined(); }); + it('uses role-scoped cursor keys for reviewer and arbiter handoffs', () => { + expect(resolveHandoffCursorKey('group@test')).toBe('group@test'); + expect(resolveHandoffCursorKey('group@test', 'owner')).toBe('group@test'); + expect(resolveHandoffCursorKey('group@test', 'reviewer')).toBe( + 'group@test:reviewer', + ); + expect(resolveHandoffCursorKey('group@test', 'arbiter')).toBe( + 'group@test:arbiter', + ); + }); + it('ignores generic failure bot messages in paired rooms', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); @@ -576,15 +618,180 @@ describe('createMessageRuntime', () => { expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); }); + it('retries a stale reviewer work item when the reviewer channel is missing', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const enqueueMessageCheck = vi.fn(); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getOpenWorkItem).mockReturnValue({ + id: 100, + group_folder: group.folder, + chat_jid: chatJid, + agent_type: 'codex', + service_id: 'claude', + delivery_role: 'reviewer', + status: 'delivery_retry', + start_seq: 10, + end_seq: 11, + result_payload: 'reviewer final', + delivery_attempts: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + delivered_at: null, + delivery_message_id: null, + last_error: 'missing role channel', + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-review-delivery', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'in_review', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + }); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-open-review-work-item-missing-channel', + reason: 'messages', + }); + + expect(result).toBe(false); + expect(db.markWorkItemDeliveryRetry).toHaveBeenCalledWith( + 100, + expect.stringContaining('discord-review'), + ); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).not.toHaveBeenCalled(); + }); + + it('retries a stale reviewer work item via the persisted delivery role even after task status changed', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const reviewerChannel = makeChannel(chatJid, 'discord-review', false); + const enqueueMessageCheck = vi.fn(); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getOpenWorkItem).mockReturnValue({ + id: 101, + group_folder: group.folder, + chat_jid: chatJid, + agent_type: 'codex', + service_id: 'claude', + delivery_role: 'reviewer', + status: 'delivery_retry', + start_seq: 10, + end_seq: 11, + result_payload: 'reviewer final retry', + delivery_attempts: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + delivered_at: null, + delivery_message_id: null, + last_error: 'discord send failed', + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-review-delivery-role', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'merge_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + }); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel, reviewerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-open-review-work-item-persisted-role', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(reviewerChannel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'reviewer final retry', + ); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); + }); + it('does not inject filtered raw bot finals into workspace-based review prompts', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); const saveState = vi.fn(); const lastAgentTimestamps: Record = {}; - const channel: Channel = { + const ownerChannel: Channel = { ...makeChannel(chatJid), isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'), }; + const reviewerChannel = makeChannel(chatJid, 'discord-review', false); vi.mocked(config.isClaudeService).mockReturnValue(false); vi.mocked(config.isReviewService).mockReturnValue(false); @@ -653,7 +860,7 @@ describe('createMessageRuntime', () => { pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, - channels: [channel], + channels: [ownerChannel, reviewerChannel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), @@ -678,7 +885,10 @@ describe('createMessageRuntime', () => { expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBe('41'); expect(saveState).toHaveBeenCalled(); - expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '리뷰 확인 완료'); + expect(reviewerChannel.sendMessage).toHaveBeenCalledWith( + chatJid, + '리뷰 확인 완료', + ); }); it('includes the latest reviewer summary in merge_ready finalize prompts and truncates it', async () => { @@ -789,7 +999,8 @@ describe('createMessageRuntime', () => { it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); - const channel = makeChannel(chatJid); + const ownerChannel = makeChannel(chatJid); + const arbiterChannel = makeChannel(chatJid, 'discord-arbiter', false); vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ @@ -876,7 +1087,7 @@ describe('createMessageRuntime', () => { pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, - channels: [channel], + channels: [ownerChannel, arbiterChannel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), @@ -899,19 +1110,234 @@ describe('createMessageRuntime', () => { expect(result).toBe(true); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); - expect(channel.sendMessage).toHaveBeenCalledWith( + expect(arbiterChannel.sendMessage).toHaveBeenCalledWith( chatJid, 'arbiter 확인 완료', ); }); - it('does not fabricate owner labels from same-service raw bot history in paired turn prompts', async () => { + it('fails closed for pending reviewer turns when the reviewer channel is missing', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-review-pending-missing-channel', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'human-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '리뷰해줘', + timestamp: '2026-03-30T00:00:11.000Z', + seq: 12, + is_bot_message: false, + }, + ] as any); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-pending-review-missing-channel', + reason: 'messages', + }); + + expect(result).toBe(false); + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBeUndefined(); + expect(saveState).not.toHaveBeenCalled(); + }); + + it('fails closed for pending arbiter turns when the arbiter channel is missing', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-arbiter-pending-missing-channel', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 3, + status: 'arbiter_requested', + arbiter_verdict: null, + arbiter_requested_at: '2026-03-30T00:00:10.000Z', + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'human-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '판정해줘', + timestamp: '2026-03-30T00:00:11.000Z', + seq: 22, + is_bot_message: false, + }, + ] as any); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-pending-arbiter-missing-channel', + reason: 'messages', + }); + + expect(result).toBe(false); + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + expect(lastAgentTimestamps[`${chatJid}:arbiter`]).toBeUndefined(); + expect(saveState).not.toHaveBeenCalled(); + }); + + it('fails closed for normal arbiter turns when the arbiter channel is missing', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-arbiter-normal-missing-channel', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 3, + status: 'in_arbitration', + arbiter_verdict: null, + arbiter_requested_at: '2026-03-30T00:00:10.000Z', + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'human-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '판정해줘', + timestamp: '2026-03-30T00:00:11.000Z', + is_bot_message: false, + }, + ] as any); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-normal-arbiter-missing-channel', + reason: 'messages', + }); + + expect(result).toBe(false); + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + }); + + it('labels raw reviewer bot history by fixed role in paired turn prompts', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const saveState = vi.fn(); const lastAgentTimestamps: Record = {}; - const channel: Channel = { - ...makeChannel(chatJid), + const ownerChannel = makeChannel(chatJid); + const reviewerChannel: Channel = { + ...makeChannel(chatJid, 'discord-review', false), isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'), }; @@ -967,7 +1393,7 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, input, _onProcess, onOutput) => { expect(input.prompt).toContain( - 'reviewer-like reply', + 'reviewer-like reply', ); expect(input.prompt).not.toContain( 'reviewer-like reply', @@ -992,7 +1418,7 @@ describe('createMessageRuntime', () => { pollInterval: 1_000, timezone: 'Asia/Seoul', triggerPattern: /^@Andy\b/i, - channels: [channel], + channels: [ownerChannel, reviewerChannel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), @@ -1009,7 +1435,7 @@ describe('createMessageRuntime', () => { }); const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-same-service-raw-history', + runId: 'run-fixed-reviewer-bot-history', reason: 'messages', }); @@ -1017,11 +1443,12 @@ describe('createMessageRuntime', () => { expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); }); - it('does not fabricate owner labels from same-service raw bot history in arbiter prompts', async () => { + it('labels raw arbiter bot history by fixed role in arbiter prompts', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); - const channel: Channel = { - ...makeChannel(chatJid), + const ownerChannel = makeChannel(chatJid); + const arbiterChannel: Channel = { + ...makeChannel(chatJid, 'discord-arbiter', false), isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'), }; @@ -1077,7 +1504,7 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, input, _onProcess, onOutput) => { expect(input.prompt).toContain( - 'reviewer-like reply', + 'reviewer-like reply', ); expect(input.prompt).not.toContain( 'reviewer-like reply', @@ -1102,7 +1529,7 @@ describe('createMessageRuntime', () => { pollInterval: 1_000, timezone: 'Asia/Seoul', triggerPattern: /^@Andy\b/i, - channels: [channel], + channels: [ownerChannel, arbiterChannel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), @@ -1119,7 +1546,7 @@ describe('createMessageRuntime', () => { }); const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-same-service-arbiter-fallback', + runId: 'run-fixed-arbiter-bot-history', reason: 'messages', }); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 69aab4a..a049522 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -5,8 +5,8 @@ import { completeServiceHandoffAndAdvanceTargetCursor, createProducedWorkItem, failServiceHandoff, + getAllPendingServiceHandoffs, getOpenWorkItem, - getPendingServiceHandoffs, getMessagesSinceSeq, getNewMessagesBySeq, markWorkItemDelivered, @@ -21,12 +21,7 @@ import { type WorkItem, } from './db.js'; import { - CLAUDE_SERVICE_ID, - CODEX_MAIN_SERVICE_ID, - CODEX_REVIEW_SERVICE_ID, isSessionCommandSenderAllowed, - REVIEWER_AGENT_TYPE, - SERVICE_ID, } from './config.js'; import { GroupQueue, GroupRunContext } from './group-queue.js'; import { @@ -67,7 +62,6 @@ import { import { createScopedLogger, logger } from './logger.js'; import { resolveGroupIpcPath } from './group-folder.js'; import { - getEffectiveChannelLease, hasReviewerLease, } from './service-routing.js'; @@ -97,8 +91,11 @@ export function isDuplicateOfLastBotFinal( } export function resolveHandoffRoleOverride( - handoff: Pick, + handoff: Pick, ): PairedRoomRole | undefined { + if (handoff.target_role) { + return handoff.target_role; + } if (handoff.intended_role) { return handoff.intended_role; } @@ -111,6 +108,16 @@ export function resolveHandoffRoleOverride( return undefined; } +export function resolveHandoffCursorKey( + chatJid: string, + role?: PairedRoomRole, +): string { + if (!role || role === 'owner') { + return chatJid; + } + return `${chatJid}:${role}`; +} + export interface MessageRuntimeDeps { assistantName: string; idleTimeout: number; @@ -129,6 +136,14 @@ export interface MessageRuntimeDeps { clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; } +function getFixedRoleChannelName(role: 'reviewer' | 'arbiter'): string { + return role === 'reviewer' ? 'discord-review' : 'discord-arbiter'; +} + +function getMissingRoleChannelMessage(role: 'reviewer' | 'arbiter'): string { + return `Missing configured ${role} Discord bot channel (${getFixedRoleChannelName(role)}) for role-fixed delivery`; +} + export function createMessageRuntime(deps: MessageRuntimeDeps): { processGroupMessages: ( chatJid: string, @@ -149,10 +164,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { messages: NewMessage[], ): NewMessage[] => { if (!hasReviewerLease(chatJid)) return messages; - const lease = getEffectiveChannelLease(chatJid); - const sharedOwnerReviewerService = - lease.reviewer_service_id !== null && - lease.owner_service_id === lease.reviewer_service_id; // Build bot-user-id → channel-name mapping from connected channels const botIdToChannelName = new Map(); for (const ch of deps.channels) { @@ -164,36 +175,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } } } - // Map channel name → service ID - const channelToService: Record = { - discord: 'claude', - 'discord-codex': 'codex-main', - 'discord-review': 'codex-review', + const channelToRole: Record = { + discord: 'owner', + 'discord-review': 'reviewer', + 'discord-arbiter': 'arbiter', }; return messages.map((msg) => { if (!msg.is_bot_message) return msg; const channelName = botIdToChannelName.get(msg.sender); if (!channelName) return msg; - const serviceId = channelToService[channelName]; - if (!serviceId) return msg; - // Raw channel history cannot tell owner/reviewer apart when both roles - // are delivered by the same service, so avoid fabricating a role label. - if ( - sharedOwnerReviewerService && - serviceId === lease.owner_service_id && - serviceId === lease.reviewer_service_id - ) { - return msg; - } - const role = - serviceId === lease.owner_service_id - ? 'owner' - : serviceId === lease.reviewer_service_id - ? 'reviewer' - : serviceId === lease.arbiter_service_id - ? 'arbiter' - : msg.sender_name; - return { ...msg, sender_name: role }; + const role = channelToRole[channelName]; + return role ? { ...msg, sender_name: role } : msg; }); }; @@ -467,6 +459,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel: Channel; startSeq: number | null; endSeq: number | null; + deliveryRole?: PairedRoomRole; hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; forcedAgentType?: AgentType; @@ -504,6 +497,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chat_jid: chatJid, agent_type: args.forcedAgentType ?? group.agentType ?? 'claude-code', + delivery_role: args.deliveryRole ?? args.forcedRole ?? null, start_seq: startSeq, end_seq: endSeq, result_payload: text, @@ -563,17 +557,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }; const enqueuePendingHandoffs = (): void => { - // Unified service handles all three bots — collect handoffs for all service IDs. - const allServiceIds = new Set([ - SERVICE_ID, - CLAUDE_SERVICE_ID, - CODEX_MAIN_SERVICE_ID, - CODEX_REVIEW_SERVICE_ID, - ]); - const allHandoffs = [...allServiceIds].flatMap((id) => - getPendingServiceHandoffs(id), - ); - for (const handoff of allHandoffs) { + // Unified runtime claims pending handoffs once and resolves delivery from role context. + for (const handoff of getAllPendingServiceHandoffs()) { if (!claimServiceHandoff(handoff.id)) { continue; } @@ -608,14 +593,29 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const handoffRole = resolveHandoffRoleOverride(handoff); let handoffChannel = channel; if (handoffRole === 'reviewer') { - const revChName = - handoff.target_agent_type === 'claude-code' - ? 'discord' - : 'discord-review'; - handoffChannel = findChannelByName(deps.channels, revChName) || channel; + // Role-fixed delivery intentionally does not follow fallback agent type. + const reviewerChannel = findChannelByName( + deps.channels, + getFixedRoleChannelName('reviewer'), + ); + if (!reviewerChannel) { + failServiceHandoff( + handoff.id, + getMissingRoleChannelMessage('reviewer'), + ); + return; + } + handoffChannel = reviewerChannel; } else if (handoffRole === 'arbiter') { - handoffChannel = - findChannelByName(deps.channels, 'discord-review') || channel; + const arbiterChannel = findChannelByName( + deps.channels, + getFixedRoleChannelName('arbiter'), + ); + if (!arbiterChannel) { + failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter')); + return; + } + handoffChannel = arbiterChannel; } const runId = `handoff-${handoff.id}`; @@ -626,6 +626,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { handoffId: handoff.id, runId, handoffRole, + targetRole: handoff.target_role ?? null, targetServiceId: handoff.target_service_id, targetAgentType: handoff.target_agent_type, reason: handoff.reason, @@ -651,14 +652,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { return; } + const cursorKey = resolveHandoffCursorKey(handoff.chat_jid, handoffRole); const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({ id: handoff.id, - target_service_id: handoff.target_service_id, chat_jid: handoff.chat_jid, + cursor_key: cursorKey, end_seq: handoff.end_seq, }); if (appliedCursor) { - deps.getLastAgentTimestamps()[handoff.chat_jid] = appliedCursor; + deps.getLastAgentTimestamps()[cursorKey] = appliedCursor; + deps.saveState(); } logger.info( { @@ -668,6 +671,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { outputStatus: result.outputStatus, visiblePhase: result.visiblePhase, appliedCursor, + cursorKey: + appliedCursor != null + ? resolveHandoffCursorKey(handoff.chat_jid, handoffRole) + : null, }, 'Completed claimed service handoff', ); @@ -701,44 +708,39 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { return true; } - // For paired rooms, determine the reviewer channel for correct bot routing. - // This is used whenever the reviewer needs to send output. - const reviewerChannelName = - hasReviewerLease(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code' - ? 'discord' - : 'discord-review'; + // For paired rooms, reviewer/arbiter outputs use their fixed role bots. + const reviewerChannelName = 'discord-review'; const foundReviewerChannel = findChannelByName( deps.channels, reviewerChannelName, ); - const reviewerChannel = foundReviewerChannel || channel; - // Arbiter always uses discord-review bot regardless of model — - // the arbiter role is tied to the 3rd bot, not the model behind it. - const arbiterChannelName = 'discord-review'; + const arbiterChannelName = 'discord-arbiter'; const foundArbiterChannel = findChannelByName( deps.channels, arbiterChannelName, ); - const arbiterChannel = foundArbiterChannel || channel; // Resolve the correct Discord channel for a given task status. - const roleToChannel: Record = { + const roleToChannel: Record = { owner: channel, - reviewer: reviewerChannel, - arbiter: arbiterChannel, + reviewer: foundReviewerChannel || null, + arbiter: foundArbiterChannel || null, + }; + const resolveChannel = (taskStatus?: string | null): Channel | null => { + const role = resolveActiveRole(taskStatus); + return role === 'owner' ? channel : roleToChannel[role]; }; - const resolveChannel = (taskStatus?: string | null): Channel => - roleToChannel[resolveActiveRole(taskStatus)] ?? channel; const buildPendingPairedTurn = ( task: PairedTask, rawMissedMessages: NewMessage[], ): { prompt: string; - channel: Channel; + channel: Channel | null; cursor: string | number | null; cursorKey?: string; + role?: 'reviewer' | 'arbiter'; } | null => { const lastRaw = rawMissedMessages[rawMissedMessages.length - 1]; const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null; @@ -751,6 +753,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel: resolveChannel(taskStatus), cursor, cursorKey: resolveCursorKey(chatJid, taskStatus), + role: 'reviewer', }; } @@ -760,6 +763,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel: resolveChannel(taskStatus), cursor, cursorKey: resolveCursorKey(chatJid, taskStatus), + role: 'arbiter', }; } @@ -776,10 +780,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const executePendingPairedTurn = async (args: { prompt: string; - channel: Channel; + channel: Channel | null; cursor: string | number | null; cursorKey?: string; + role?: 'reviewer' | 'arbiter'; }): Promise => { + if (!args.channel) { + const missingRole = args.role ?? 'reviewer'; + log.error( + { + role: missingRole, + requiredChannel: getFixedRoleChannelName(missingRole), + }, + 'Skipping paired turn because the dedicated Discord role channel is not configured', + ); + return false; + } + if (args.cursor != null) { advanceLastAgentCursor( deps.getLastAgentTimestamps(), @@ -796,6 +813,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chatJid, runId, channel: args.channel, + deliveryRole: args.role, startSeq: null, endSeq: null, }); @@ -808,10 +826,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { { reviewerChannelName, foundChannel: foundReviewerChannel?.name ?? null, - usingChannel: reviewerChannel.name, arbiterChannelName, foundArbiterChannel: foundArbiterChannel?.name ?? null, - usingArbiterChannel: arbiterChannel.name, availableChannels: deps.channels.map((c) => c.name), }, 'Paired room reviewer/arbiter channel resolution', @@ -828,7 +844,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const pendingTask = hasReviewerLease(chatJid) ? getLatestOpenPairedTaskForChat(chatJid) : null; - const deliveryChannel = resolveChannel(pendingTask?.status); + const deliveryRole = + openWorkItem.delivery_role ?? + (pendingTask ? resolveActiveRole(pendingTask.status) : 'owner'); + const deliveryChannel = + deliveryRole === 'owner' ? channel : roleToChannel[deliveryRole]; + if (!deliveryChannel) { + const missingRole = deliveryRole === 'arbiter' ? 'arbiter' : 'reviewer'; + const errorMessage = getMissingRoleChannelMessage(missingRole); + markWorkItemDeliveryRetry(openWorkItem.id, errorMessage); + log.error( + { + workItemId: openWorkItem.id, + role: deliveryRole, + requiredChannel: getFixedRoleChannelName(missingRole), + }, + 'Unable to deliver paired-room work item because the dedicated Discord role channel is not configured', + ); + return false; + } const delivered = await deliverOpenWorkItem( deliveryChannel, openWorkItem, @@ -999,6 +1033,28 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } const startSeq = missedMessages[0].seq ?? null; const endSeq = missedMessages[missedMessages.length - 1].seq ?? null; + log.info( + { + messageCount: missedMessages.length, + messageSeqStart: startSeq, + messageSeqEnd: endSeq, + }, + 'Dispatching queued messages to agent', + ); + + if (!turnChannel) { + const missingRole = turnRole === 'arbiter' ? 'arbiter' : 'reviewer'; + log.error( + { + taskStatus, + role: turnRole, + requiredChannel: getFixedRoleChannelName(missingRole), + }, + 'Skipping paired-room run because the dedicated Discord role channel is not configured', + ); + return false; + } + if (endSeq !== null) { advanceLastAgentCursor( deps.getLastAgentTimestamps(), @@ -1009,15 +1065,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); } - log.info( - { - messageCount: missedMessages.length, - messageSeqStart: startSeq, - messageSeqEnd: endSeq, - }, - 'Dispatching queued messages to agent', - ); - const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages); const { deliverySucceeded, visiblePhase } = await executeTurn({ group, @@ -1025,6 +1072,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chatJid, runId, channel: turnChannel, + deliveryRole: pendingTaskForChannel ? turnRole : undefined, startSeq, endSeq, hasHumanMessage: hasHumanMsg, diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 004090d..4a7b77b 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -55,21 +55,29 @@ const group: RegisteredGroup = { }; const ownerContext: RoomRoleContext = { - serviceId: 'codex-main', + serviceId: config.CODEX_MAIN_SERVICE_ID, role: 'owner', - ownerServiceId: 'codex-main', - reviewerServiceId: 'codex-review', + ownerServiceId: config.CODEX_MAIN_SERVICE_ID, + reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE, failoverOwner: false, }; const reviewerContext: RoomRoleContext = { - serviceId: 'codex-review', + serviceId: config.REVIEWER_SERVICE_ID_FOR_TYPE, role: 'reviewer', - ownerServiceId: 'codex-main', - reviewerServiceId: 'codex-review', + ownerServiceId: config.CODEX_MAIN_SERVICE_ID, + reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE, failoverOwner: false, }; +const failoverOwnerContext: RoomRoleContext = { + serviceId: config.CODEX_REVIEW_SERVICE_ID, + role: 'owner', + ownerServiceId: config.CODEX_REVIEW_SERVICE_ID, + reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE, + failoverOwner: true, +}; + function createCanonicalRepoWithCommit(commitMessage: string): string { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-')); execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); @@ -103,8 +111,8 @@ function buildPairedTask(overrides: Partial = {}): PairedTask { id: 'task-1', chat_jid: 'dc:test', group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', + owner_service_id: config.CODEX_MAIN_SERVICE_ID, + reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE, title: null, source_ref: 'HEAD', plan_notes: null, @@ -181,7 +189,12 @@ describe('paired execution context', () => { expect(db.createPairedTask).toHaveBeenCalledTimes(1); expect(db.createPairedTask).toHaveBeenCalledWith( expect.objectContaining({ + owner_service_id: config.CODEX_MAIN_SERVICE_ID, + reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE, status: 'active', + owner_agent_type: 'codex', + reviewer_agent_type: config.REVIEWER_AGENT_TYPE, + arbiter_agent_type: config.ARBITER_AGENT_TYPE ?? null, }), ); expect(result?.envOverrides).toMatchObject({ @@ -190,6 +203,25 @@ describe('paired execution context', () => { }); }); + it('persists stable role-slot service shadow instead of the transient failover owner lease', () => { + preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-failover-owner', + roomRoleContext: failoverOwnerContext, + hasHumanMessage: true, + }); + + expect(db.createPairedTask).toHaveBeenCalledWith( + expect.objectContaining({ + owner_service_id: config.CODEX_MAIN_SERVICE_ID, + reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE, + owner_agent_type: 'codex', + reviewer_agent_type: config.REVIEWER_AGENT_TYPE, + }), + ); + }); + it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => { vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ id: 'task-1', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 5bcc6d1..251b2cf 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -5,8 +5,13 @@ import path from 'path'; import { ARBITER_DEADLOCK_THRESHOLD, + ARBITER_AGENT_TYPE, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, DATA_DIR, PAIRED_MAX_ROUND_TRIPS, + REVIEWER_AGENT_TYPE, isArbiterEnabled, } from './config.js'; import { @@ -26,12 +31,14 @@ import { provisionOwnerWorkspaceForPairedTask, } from './paired-workspace-manager.js'; import type { + AgentType, PairedRoomRole, PairedTask, PairedWorkspace, RegisteredGroup, RoomRoleContext, } from './types.js'; +import { resolveRoleAgentPlan } from './role-agent-plan.js'; // --------------------------------------------------------------------------- // Reviewer verdict detection @@ -158,6 +165,21 @@ function ensurePairedProject( return group.workDir; } +function resolvePairedTaskServiceShadow( + role: 'owner' | 'reviewer', + agentType: AgentType | null | undefined, +): string | null { + if (!agentType) { + return null; + } + + if (agentType === 'claude-code') { + return CLAUDE_SERVICE_ID; + } + + return role === 'owner' ? CODEX_MAIN_SERVICE_ID : CODEX_REVIEW_SERVICE_ID; +} + // --------------------------------------------------------------------------- // ensureActiveTask // --------------------------------------------------------------------------- @@ -185,12 +207,29 @@ function ensureActiveTask( } const now = new Date().toISOString(); + const rolePlan = resolveRoleAgentPlan({ + paired: true, + groupAgentType: group.agentType, + configuredReviewer: REVIEWER_AGENT_TYPE, + configuredArbiter: ARBITER_AGENT_TYPE, + }); + const ownerServiceShadow = resolvePairedTaskServiceShadow( + 'owner', + rolePlan.ownerAgentType, + )!; + const reviewerServiceShadow = resolvePairedTaskServiceShadow( + 'reviewer', + rolePlan.reviewerAgentType, + )!; const task: PairedTask = { id: crypto.randomUUID(), chat_jid: chatJid, group_folder: group.folder, - owner_service_id: roomRoleContext.ownerServiceId, - reviewer_service_id: roomRoleContext.reviewerServiceId, + owner_service_id: ownerServiceShadow, + reviewer_service_id: reviewerServiceShadow, + owner_agent_type: rolePlan.ownerAgentType, + reviewer_agent_type: rolePlan.reviewerAgentType, + arbiter_agent_type: rolePlan.arbiterAgentType, title: null, source_ref: resolveCanonicalSourceRef(canonicalWorkDir), plan_notes: null, diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index feaa1cc..d54d333 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -18,6 +18,79 @@ async function loadModules() { return { db, manager }; } +const FIXTURE_NOW = '2026-03-28T00:00:00.000Z'; + +function initCanonicalRepo( + repoDir: string, + files: Record = { 'README.md': 'original\n' }, +): void { + fs.mkdirSync(repoDir, { recursive: true }); + runGit(['init'], repoDir); + runGit(['config', 'user.email', 'test@example.com'], repoDir); + runGit(['config', 'user.name', 'EJClaw Test'], repoDir); + for (const [relativePath, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(repoDir, relativePath)), { + recursive: true, + }); + fs.writeFileSync(path.join(repoDir, relativePath), contents); + } + runGit(['add', '.'], repoDir); + runGit(['commit', '-m', 'initial'], repoDir); +} + +function seedPairedTask( + db: Awaited>['db'], + canonicalDir: string, + args: { + taskId: string; + groupFolder?: string; + chatJid?: string; + sourceRef?: string; + status?: 'active' | 'review_ready' | 'in_review' | 'merge_ready'; + reviewRequestedAt?: string | null; + updatedAt?: string; + }, +): void { + const groupFolder = args.groupFolder ?? 'paired-room'; + const chatJid = args.chatJid ?? 'dc:test'; + const reviewRequestedAt = args.reviewRequestedAt ?? null; + const updatedAt = args.updatedAt ?? FIXTURE_NOW; + + db.upsertPairedProject({ + chat_jid: chatJid, + group_folder: groupFolder, + canonical_work_dir: canonicalDir, + created_at: FIXTURE_NOW, + updated_at: FIXTURE_NOW, + }); + db.createPairedTask({ + id: args.taskId, + chat_jid: chatJid, + group_folder: groupFolder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: args.sourceRef ?? 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: reviewRequestedAt, + status: args.status ?? 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: FIXTURE_NOW, + updated_at: updatedAt, + }); +} + +function ownerBranchName(groupFolder: string): string { + return `codex/owner/${groupFolder}`; +} + +function ownerWorkspacePath(groupFolder: string): string { + return path.join(process.env.EJCLAW_DATA_DIR!, 'workspaces', groupFolder, 'owner'); +} + describe('paired workspace manager', () => { let tempRoot: string; let previousDataDir: string | undefined; @@ -90,6 +163,9 @@ describe('paired workspace manager', () => { expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe( true, ); + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('paired-room')); fs.writeFileSync( path.join(ownerWorkspace.workspace_dir, 'README.md'), @@ -647,4 +723,204 @@ describe('paired workspace manager', () => { ), ).toBe('owner changed again\n'); }); + + it('bases a new owner branch on the canonical repo HEAD without requiring a remote', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + runGit(['checkout', '-b', 'feature/base'], canonicalDir); + fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'feature base\n'); + runGit(['commit', '-am', 'feature base'], canonicalDir); + + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-head-base', + groupFolder: 'head-base-room', + }); + + const ownerWorkspace = + manager.provisionOwnerWorkspaceForPairedTask('paired-task-head-base'); + + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('head-base-room')); + expect( + fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'), + ).toBe('feature base\n'); + expect(runGit(['rev-parse', 'HEAD'], ownerWorkspace.workspace_dir)).toBe( + runGit(['rev-parse', 'HEAD'], canonicalDir), + ); + }); + + it('provisions and reprovisions owner workspaces when source_ref is a tree hash', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'tree source\n'); + runGit(['commit', '-am', 'tree source'], canonicalDir); + const treeSourceRef = runGit(['rev-parse', 'HEAD^{tree}'], canonicalDir); + + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-tree-source-ref', + groupFolder: 'tree-source-room', + sourceRef: treeSourceRef, + }); + + const firstProvision = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-tree-source-ref', + ); + const secondProvision = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-tree-source-ref', + ); + + expect(firstProvision.workspace_dir).toBe(secondProvision.workspace_dir); + expect( + runGit(['branch', '--show-current'], secondProvision.workspace_dir), + ).toBe(ownerBranchName('tree-source-room')); + expect( + fs.readFileSync(path.join(secondProvision.workspace_dir, 'README.md'), 'utf-8'), + ).toBe('tree source\n'); + expect(runGit(['rev-parse', 'HEAD'], secondProvision.workspace_dir)).toBe( + runGit(['rev-parse', 'HEAD'], canonicalDir), + ); + }); + + it('repairs a missing but registered owner worktree path before reprovisioning', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-repair', + groupFolder: 'repair-room', + }); + + const ownerWorkspace = + manager.provisionOwnerWorkspaceForPairedTask('paired-task-repair'); + fs.rmSync(ownerWorkspace.workspace_dir, { recursive: true, force: true }); + + expect(runGit(['worktree', 'list', '--porcelain'], canonicalDir)).toContain( + ownerWorkspace.workspace_dir, + ); + + const reprovisioned = + manager.provisionOwnerWorkspaceForPairedTask('paired-task-repair'); + + expect(reprovisioned.workspace_dir).toBe(ownerWorkspace.workspace_dir); + expect( + runGit(['branch', '--show-current'], reprovisioned.workspace_dir), + ).toBe(ownerBranchName('repair-room')); + expect( + runGit(['worktree', 'list', '--porcelain'], canonicalDir), + ).toContain(reprovisioned.workspace_dir); + }); + + it('lazy-migrates a detached dirty owner workspace to a new channel branch', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-migrate-new-branch', + groupFolder: 'migrate-room', + }); + + const workspaceDir = ownerWorkspacePath('migrate-room'); + fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); + runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalDir); + fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'detached dirty\n'); + fs.writeFileSync(path.join(workspaceDir, 'NEW_FILE.txt'), 'new file\n'); + + const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-migrate-new-branch', + ); + + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('migrate-room')); + expect( + fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'), + ).toBe('detached dirty\n'); + expect( + fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'NEW_FILE.txt'), 'utf-8'), + ).toBe('new file\n'); + expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toContain( + 'M README.md', + ); + expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toContain( + '?? NEW_FILE.txt', + ); + }); + + it('lazy-migrates a detached dirty owner workspace onto an existing matching channel branch', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-migrate-existing-branch', + groupFolder: 'migrate-existing-room', + }); + + runGit( + ['branch', ownerBranchName('migrate-existing-room'), 'HEAD'], + canonicalDir, + ); + const workspaceDir = ownerWorkspacePath('migrate-existing-room'); + fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); + runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalDir); + fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'detached existing\n'); + fs.writeFileSync(path.join(workspaceDir, 'NOTES.md'), 'keep me\n'); + + const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-migrate-existing-branch', + ); + + expect( + runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir), + ).toBe(ownerBranchName('migrate-existing-room')); + expect( + fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'), + ).toBe('detached existing\n'); + expect( + fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'NOTES.md'), 'utf-8'), + ).toBe('keep me\n'); + }); + + it('blocks provisioning when the channel branch is already checked out in another worktree', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-branch-collision', + groupFolder: 'collision-room', + }); + + const conflictingDir = path.join(tempRoot, 'conflicting-owner'); + runGit( + [ + 'worktree', + 'add', + '-b', + ownerBranchName('collision-room'), + conflictingDir, + 'HEAD', + ], + canonicalDir, + ); + + expect(() => + manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-branch-collision', + ), + ).toThrow(/already checked out/i); + }); }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index dd75cd8..2a26200 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -121,6 +121,144 @@ function ensureCleanDirectory(targetDir: string): void { fs.mkdirSync(targetDir, { recursive: true }); } +type GitWorktreeEntry = { + worktreePath: string; + head: string | null; + branchRef: string | null; + detached: boolean; + prunableReason: string | null; +}; + +function tryRunGit(args: string[], cwd?: string): string | null { + try { + return runGit(args, cwd); + } catch { + return null; + } +} + +function listGitWorktrees(repoDir: string): GitWorktreeEntry[] { + const output = execFileSync('git', ['worktree', 'list', '--porcelain'], { + cwd: repoDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const entries: GitWorktreeEntry[] = []; + let current: GitWorktreeEntry | null = null; + + const pushCurrent = () => { + if (current) { + entries.push(current); + current = null; + } + }; + + for (const rawLine of output.split('\n')) { + const line = rawLine.trimEnd(); + if (line.length === 0) { + pushCurrent(); + continue; + } + if (line.startsWith('worktree ')) { + pushCurrent(); + current = { + worktreePath: path.resolve(line.slice('worktree '.length).trim()), + head: null, + branchRef: null, + detached: false, + prunableReason: null, + }; + continue; + } + if (!current) { + continue; + } + if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length).trim(); + continue; + } + if (line.startsWith('branch ')) { + current.branchRef = line.slice('branch '.length).trim(); + continue; + } + if (line === 'detached') { + current.detached = true; + continue; + } + if (line.startsWith('prunable')) { + current.prunableReason = line.slice('prunable'.length).trim() || null; + } + } + pushCurrent(); + return entries; +} + +function branchRefName(branchName: string): string { + return `refs/heads/${branchName}`; +} + +function buildOwnerBranchName(groupFolder: string): string { + return `codex/owner/${groupFolder}`; +} + +function resolveBranchName(repoDir: string): string | null { + return tryRunGit(['symbolic-ref', '--short', '-q', 'HEAD'], repoDir); +} + +function resolveCommit(repoDir: string, ref: string): string | null { + return tryRunGit(['rev-parse', '--verify', `${ref}^{commit}`], repoDir); +} + +function findWorktreeEntry( + repoDir: string, + workspaceDir: string, +): GitWorktreeEntry | null { + const resolvedWorkspaceDir = path.resolve(workspaceDir); + return ( + listGitWorktrees(repoDir).find( + (entry) => path.resolve(entry.worktreePath) === resolvedWorkspaceDir, + ) ?? null + ); +} + +function ensureBranchNotCheckedOutElsewhere( + canonicalWorkDir: string, + workspaceDir: string, + branchName: string, +): void { + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const targetBranchRef = branchRefName(branchName); + const conflictingWorktree = listGitWorktrees(canonicalWorkDir).find( + (entry) => + entry.branchRef === targetBranchRef && + path.resolve(entry.worktreePath) !== resolvedWorkspaceDir, + ); + if (conflictingWorktree) { + throw new Error( + `Owner branch ${branchName} is already checked out at ${conflictingWorktree.worktreePath}.`, + ); + } +} + +function repairOwnerWorktreeRegistration( + workspaceDir: string, + canonicalWorkDir: string, +): void { + runGit(['worktree', 'prune', '--expire', 'now'], canonicalWorkDir); + + const entry = findWorktreeEntry(canonicalWorkDir, workspaceDir); + if (!entry) { + return; + } + + const workspaceExists = fs.existsSync(workspaceDir); + if (!workspaceExists || entry.prunableReason) { + throw new Error( + `Owner workspace registration for ${workspaceDir} is stale after repair: ${entry.prunableReason ?? 'missing worktree path'}.`, + ); + } +} + function listGitPaths(repoDir: string, args: string[]): string[] { const output = execFileSync('git', args, { cwd: repoDir, @@ -415,6 +553,13 @@ export function provisionOwnerWorkspaceForPairedTask( ): PairedWorkspace { const { task, canonicalWorkDir } = getTaskAndProject(taskId); ensureGitRepository(canonicalWorkDir); + const targetBranch = buildOwnerBranchName(task.group_folder); + const canonicalHeadCommit = resolveCommit(canonicalWorkDir, 'HEAD'); + if (!canonicalHeadCommit) { + throw new Error( + `Unable to resolve canonical HEAD for owner workspace task ${taskId}.`, + ); + } // Use a stable per-channel path (not per-task) so the Claude SDK // recognizes it as the same project across tasks → session persists. @@ -425,19 +570,70 @@ export function provisionOwnerWorkspaceForPairedTask( 'owner', ); fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); + repairOwnerWorktreeRegistration(workspaceDir, canonicalWorkDir); - if (!fs.existsSync(path.join(workspaceDir, '.git'))) { - runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalWorkDir); + const workspaceGitPath = path.join(workspaceDir, '.git'); + const targetBranchCommit = resolveCommit( + canonicalWorkDir, + branchRefName(targetBranch), + ); + + if (!fs.existsSync(workspaceGitPath)) { + if (targetBranchCommit) { + ensureBranchNotCheckedOutElsewhere( + canonicalWorkDir, + workspaceDir, + targetBranch, + ); + runGit(['worktree', 'add', workspaceDir, targetBranch], canonicalWorkDir); + } else { + runGit( + ['worktree', 'add', '-b', targetBranch, workspaceDir, canonicalHeadCommit], + canonicalWorkDir, + ); + } logger.info( - { taskId, workspaceDir }, - 'Provisioned stable owner workspace for channel', + { + taskId, + workspaceDir, + targetBranch, + baseRef: canonicalHeadCommit, + sourceRef: task.source_ref, + }, + 'Provisioned stable owner workspace branch for channel', ); } else { - // Worktree exists — pull latest changes from canonical repo - try { - runGit(['checkout', '--detach', 'HEAD'], workspaceDir); - } catch { - // Already at HEAD or detached — fine + ensureGitRepository(workspaceDir); + + const currentBranch = resolveBranchName(workspaceDir); + if (currentBranch === targetBranch) { + // Stable owner workspace is already attached to the channel branch. + } else if (currentBranch) { + throw new Error( + `Owner workspace ${workspaceDir} is on unexpected branch ${currentBranch}; expected ${targetBranch}.`, + ); + } else { + const currentHeadCommit = resolveCommit(workspaceDir, 'HEAD'); + if (!currentHeadCommit) { + throw new Error( + `Unable to resolve detached owner workspace HEAD for ${workspaceDir}.`, + ); + } + + if (!targetBranchCommit) { + runGit(['switch', '-c', targetBranch], workspaceDir); + } else if (targetBranchCommit === currentHeadCommit) { + ensureBranchNotCheckedOutElsewhere( + canonicalWorkDir, + workspaceDir, + targetBranch, + ); + runGit(['switch', targetBranch], workspaceDir); + } else { + throw new Error( + `Owner workspace ${workspaceDir} is detached at ${currentHeadCommit}, but ${targetBranch} points to ${targetBranchCommit}.`, + ); + } } } diff --git a/src/role-service-shadow.ts b/src/role-service-shadow.ts new file mode 100644 index 0000000..5b7772e --- /dev/null +++ b/src/role-service-shadow.ts @@ -0,0 +1,68 @@ +import { + ARBITER_SERVICE_ID, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + normalizeServiceId, +} from './config.js'; +import type { AgentType, PairedRoomRole } from './types.js'; + +export function resolveRoleServiceShadow( + role: PairedRoomRole, + agentType: AgentType | null | undefined, +): string | null { + if (!agentType) { + return null; + } + if (agentType === 'claude-code') { + return CLAUDE_SERVICE_ID; + } + if (role === 'owner') { + return CODEX_MAIN_SERVICE_ID; + } + if (role === 'arbiter') { + return ARBITER_SERVICE_ID ?? CODEX_REVIEW_SERVICE_ID; + } + return CODEX_REVIEW_SERVICE_ID; +} + +export function inferAgentTypeFromServiceShadow( + serviceId: string | null | undefined, +): AgentType | undefined { + if (!serviceId) { + return undefined; + } + + const normalized = normalizeServiceId(serviceId); + if (normalized === CLAUDE_SERVICE_ID) { + return 'claude-code'; + } + if ( + normalized === CODEX_MAIN_SERVICE_ID || + normalized === CODEX_REVIEW_SERVICE_ID || + (ARBITER_SERVICE_ID != null && normalized === ARBITER_SERVICE_ID) + ) { + return 'codex'; + } + return undefined; +} + +export function inferRoleFromServiceShadow( + agentType: AgentType | null | undefined, + serviceId: string | null | undefined, +): PairedRoomRole | null { + if (!agentType || !serviceId) { + return null; + } + + const normalized = normalizeServiceId(serviceId); + const matches = (['owner', 'reviewer', 'arbiter'] as const).filter( + (role) => resolveRoleServiceShadow(role, agentType) === normalized, + ); + + if (matches.length !== 1) { + return null; + } + + return matches[0]; +} diff --git a/src/room-role-context.test.ts b/src/room-role-context.test.ts index 3e5be1d..77615cb 100644 --- a/src/room-role-context.test.ts +++ b/src/room-role-context.test.ts @@ -11,6 +11,7 @@ describe('buildRoomRoleContext', () => { owner_service_id: 'claude', reviewer_service_id: 'codex-main', arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -27,14 +28,46 @@ describe('buildRoomRoleContext', () => { }); }); - it('returns owner failover context for a codex-review failover turn', () => { + it('prefers the canonical reviewer shadow over a stale compatibility field', () => { expect( buildRoomRoleContext( { chat_jid: 'group@test', - owner_service_id: 'codex-review', - reviewer_service_id: 'codex-main', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + owner_service_id: 'claude', + reviewer_service_id: 'stale-reviewer-shadow', arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: true, + }, + 'codex-review', + ), + ).toEqual({ + serviceId: 'codex-review', + role: 'reviewer', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-review', + ownerAgentType: 'claude-code', + reviewerAgentType: 'codex', + failoverOwner: false, + arbiterServiceId: undefined, + }); + }); + + it('returns owner failover context from the explicit owner failover flag', () => { + expect( + buildRoomRoleContext( + { + chat_jid: 'group@test', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + owner_service_id: 'codex-review', + reviewer_service_id: 'claude', + arbiter_service_id: null, + owner_failover_active: true, activated_at: '2026-03-28T10:00:00.000Z', reason: 'claude-429', explicit: true, @@ -45,7 +78,9 @@ describe('buildRoomRoleContext', () => { serviceId: 'codex-review', role: 'owner', ownerServiceId: 'codex-review', - reviewerServiceId: 'codex-main', + reviewerServiceId: 'claude', + ownerAgentType: 'claude-code', + reviewerAgentType: 'claude-code', failoverOwner: true, arbiterServiceId: undefined, }); @@ -59,6 +94,7 @@ describe('buildRoomRoleContext', () => { owner_service_id: 'codex-main', reviewer_service_id: 'claude', arbiter_service_id: 'codex-review', + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -83,6 +119,7 @@ describe('buildRoomRoleContext', () => { owner_service_id: 'claude', reviewer_service_id: 'claude', arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -108,6 +145,7 @@ describe('buildRoomRoleContext', () => { owner_service_id: 'codex-main', reviewer_service_id: null, arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, diff --git a/src/room-role-context.ts b/src/room-role-context.ts index 6755399..49581d5 100644 --- a/src/room-role-context.ts +++ b/src/room-role-context.ts @@ -1,8 +1,5 @@ -import { - CODEX_MAIN_SERVICE_ID, - CODEX_REVIEW_SERVICE_ID, - normalizeServiceId, -} from './config.js'; +import { normalizeServiceId } from './config.js'; +import { resolveLeaseServiceId } from './service-routing.js'; import type { PairedRoomRole, RoomRoleContext } from './types.js'; import type { EffectiveChannelLease } from './service-routing.js'; @@ -12,18 +9,16 @@ export function buildRoomRoleContext( preferredRole?: PairedRoomRole, ): RoomRoleContext | undefined { const normalizedServiceId = normalizeServiceId(serviceId); - const reviewerServiceId = lease.reviewer_service_id - ? normalizeServiceId(lease.reviewer_service_id) - : null; + const reviewerServiceId = resolveLeaseServiceId(lease, 'reviewer'); if (!reviewerServiceId) { return undefined; } - const ownerServiceId = normalizeServiceId(lease.owner_service_id); - const arbiterServiceId = lease.arbiter_service_id - ? normalizeServiceId(lease.arbiter_service_id) - : undefined; + const ownerServiceId = + resolveLeaseServiceId(lease, 'owner') ?? + normalizeServiceId(lease.owner_service_id); + const arbiterServiceId = resolveLeaseServiceId(lease, 'arbiter') ?? undefined; const matches = { owner: ownerServiceId === normalizedServiceId, @@ -51,9 +46,16 @@ export function buildRoomRoleContext( role, ownerServiceId, reviewerServiceId, - failoverOwner: - ownerServiceId === CODEX_REVIEW_SERVICE_ID && - reviewerServiceId === CODEX_MAIN_SERVICE_ID, + ...(lease.owner_agent_type + ? { ownerAgentType: lease.owner_agent_type } + : {}), + ...(lease.reviewer_agent_type !== undefined + ? { reviewerAgentType: lease.reviewer_agent_type } + : {}), + failoverOwner: Boolean(lease.owner_failover_active), arbiterServiceId, + ...(lease.arbiter_agent_type !== undefined + ? { arbiterAgentType: lease.arbiter_agent_type } + : {}), }; } diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index a2ce005..ed0d8c2 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -12,6 +12,7 @@ import { getEffectiveChannelLease, getGlobalFailoverInfo, refreshChannelOwnerCache, + resolveLeaseServiceId, } from './service-routing.js'; beforeEach(() => { @@ -21,7 +22,7 @@ beforeEach(() => { }); describe('service-routing global failover', () => { - it('uses codex-review as owner during global failover', () => { + it('uses codex-review as owner during global failover without rewriting the reviewer lease', () => { _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', @@ -44,14 +45,47 @@ describe('service-routing global failover', () => { expect(getEffectiveChannelLease('dc:paired')).toMatchObject({ chat_jid: 'dc:paired', owner_service_id: 'codex-review', - reviewer_service_id: 'codex-main', + reviewer_service_id: 'claude', + owner_failover_active: true, reason: 'claude-429', explicit: true, }); // Any other channel is also affected expect(getEffectiveChannelLease('dc:other')).toMatchObject({ owner_service_id: 'codex-review', - reviewer_service_id: 'codex-main', + reviewer_service_id: null, + owner_failover_active: true, + explicit: true, + }); + }); + + it('preserves reviewer and arbiter leases for tribunal rooms during global failover', () => { + _setRegisteredGroupForTests('dc:tribunal', { + name: 'Tribunal Room', + folder: 'tribunal-room', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setRegisteredGroupForTests('dc:tribunal', { + name: 'Tribunal Room', + folder: 'tribunal-room', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + setExplicitRoomMode('dc:tribunal', 'tribunal'); + const baseLease = getEffectiveChannelLease('dc:tribunal'); + + activateCodexFailover('dc:tribunal', 'claude-429'); + + expect(getEffectiveChannelLease('dc:tribunal')).toMatchObject({ + chat_jid: 'dc:tribunal', + owner_service_id: 'codex-review', + reviewer_service_id: baseLease.reviewer_service_id, + arbiter_service_id: baseLease.arbiter_service_id, + owner_failover_active: true, + reason: 'claude-429', explicit: true, }); }); @@ -80,6 +114,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:paired', owner_service_id: 'codex-main', reviewer_service_id: 'claude', + owner_failover_active: false, explicit: false, }); }); @@ -105,6 +140,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:explicit-single', owner_service_id: 'codex-main', reviewer_service_id: null, + owner_failover_active: false, explicit: false, }); }); @@ -134,6 +170,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:stored-owner-claude', owner_service_id: 'claude', reviewer_service_id: null, + owner_failover_active: false, explicit: false, }); }); @@ -152,6 +189,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:stored-owner-fallback', owner_service_id: 'codex-main', reviewer_service_id: null, + owner_failover_active: false, explicit: false, }); }); @@ -170,6 +208,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:explicit-tribunal', owner_service_id: 'claude', reviewer_service_id: 'claude', + owner_failover_active: false, explicit: false, }); }); @@ -190,6 +229,7 @@ describe('service-routing global failover', () => { chat_jid: 'dc:explicit-tribunal-codex', owner_service_id: 'codex-main', reviewer_service_id: 'claude', + owner_failover_active: false, explicit: false, }); }); @@ -199,7 +239,44 @@ describe('service-routing global failover', () => { chat_jid: 'dc:unregistered', owner_service_id: 'claude', reviewer_service_id: null, + owner_failover_active: false, explicit: false, }); }); }); + +describe('resolveLeaseServiceId', () => { + it('prefers canonical reviewer shadow over a stale compatibility field', () => { + expect( + resolveLeaseServiceId( + { + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + owner_service_id: 'claude', + reviewer_service_id: 'stale-reviewer-shadow', + arbiter_service_id: null, + owner_failover_active: false, + }, + 'reviewer', + ), + ).toBe('codex-review'); + }); + + it('keeps the explicit owner failover service instead of recomputing the stable shadow', () => { + expect( + resolveLeaseServiceId( + { + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + owner_service_id: 'codex-review', + reviewer_service_id: 'claude', + arbiter_service_id: null, + owner_failover_active: true, + }, + 'owner', + ), + ).toBe('codex-review'); + }); +}); diff --git a/src/service-routing.ts b/src/service-routing.ts index f20bbd4..0df7b2a 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -1,10 +1,9 @@ import { + ARBITER_AGENT_TYPE, ARBITER_SERVICE_ID, - CLAUDE_SERVICE_ID, - CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, OWNER_AGENT_TYPE, - REVIEWER_SERVICE_ID_FOR_TYPE, + REVIEWER_AGENT_TYPE, SERVICE_ID, isArbiterEnabled, normalizeServiceId, @@ -15,17 +14,25 @@ import { getEffectiveRuntimeRoomMode, getRegisteredAgentTypesForJid, getStoredRoomSettings, - setChannelOwnerLease, type ChannelOwnerLeaseRow, } from './db.js'; import { logger } from './logger.js'; -import type { AgentType } from './types.js'; +import { + inferAgentTypeFromServiceShadow, + resolveRoleServiceShadow, +} from './role-service-shadow.js'; +import { resolveRoleAgentPlan } from './role-agent-plan.js'; +import type { AgentType, PairedRoomRole } from './types.js'; export interface EffectiveChannelLease { chat_jid: string; + owner_agent_type?: AgentType; + reviewer_agent_type?: AgentType | null; + arbiter_agent_type?: AgentType | null; owner_service_id: string; reviewer_service_id: string | null; arbiter_service_id: string | null; + owner_failover_active?: boolean; activated_at: string | null; reason: string | null; explicit: boolean; @@ -40,27 +47,56 @@ function normalizeLeaseRow( row: ChannelOwnerLeaseRow, explicit: boolean, ): EffectiveChannelLease { + const ownerAgentType = + row.owner_agent_type ?? + getStoredRoomSettings(row.chat_jid)?.ownerAgentType ?? + inferAgentTypeFromServiceShadow(row.owner_service_id) ?? + 'claude-code'; + const reviewerAgentType = + row.reviewer_service_id == null + ? null + : row.reviewer_agent_type ?? + inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? + resolveRoleAgentPlan({ + paired: true, + groupAgentType: ownerAgentType, + configuredReviewer: REVIEWER_AGENT_TYPE, + configuredArbiter: ARBITER_AGENT_TYPE, + }).reviewerAgentType; + const arbiterAgentType = + row.arbiter_agent_type ?? + (row.arbiter_service_id + ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) + : undefined) ?? + null; + return { chat_jid: row.chat_jid, - owner_service_id: normalizeServiceId(row.owner_service_id), - reviewer_service_id: row.reviewer_service_id - ? normalizeServiceId(row.reviewer_service_id) + owner_agent_type: ownerAgentType, + reviewer_agent_type: reviewerAgentType, + arbiter_agent_type: arbiterAgentType, + owner_service_id: + resolveRoleServiceShadow('owner', ownerAgentType) ?? + normalizeServiceId(row.owner_service_id), + reviewer_service_id: reviewerAgentType + ? resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + normalizeServiceId(row.reviewer_service_id!) : null, - arbiter_service_id: row.arbiter_service_id - ? normalizeServiceId(row.arbiter_service_id) - : isArbiterEnabled() - ? ARBITER_SERVICE_ID - : null, + arbiter_service_id: arbiterAgentType + ? resolveRoleServiceShadow('arbiter', arbiterAgentType) ?? + (row.arbiter_service_id ? normalizeServiceId(row.arbiter_service_id) : null) + : row.arbiter_service_id + ? normalizeServiceId(row.arbiter_service_id) + : isArbiterEnabled() + ? ARBITER_SERVICE_ID + : null, + owner_failover_active: false, activated_at: row.activated_at, reason: row.reason, explicit, }; } -function getServiceIdForAgentType(agentType: AgentType): string { - return agentType === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID; -} - function inferFallbackOwnerAgentType( hasClaude: boolean, hasCodex: boolean, @@ -86,34 +122,46 @@ function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { function getDefaultLease(chatJid: string): EffectiveChannelLease { const roomMode = getEffectiveRuntimeRoomMode(chatJid); - const ownerAgentType = resolveDefaultOwnerAgentType(chatJid); - const ownerServiceId = ownerAgentType - ? getServiceIdForAgentType(ownerAgentType) - : CLAUDE_SERVICE_ID; - - if (roomMode === 'tribunal') { - return { - chat_jid: chatJid, - owner_service_id: ownerServiceId, - reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE, - arbiter_service_id: isArbiterEnabled() ? ARBITER_SERVICE_ID : null, - activated_at: null, - reason: null, - explicit: false, - }; - } + const ownerAgentType = resolveDefaultOwnerAgentType(chatJid) ?? 'claude-code'; + const rolePlan = resolveRoleAgentPlan({ + paired: roomMode === 'tribunal', + groupAgentType: ownerAgentType, + configuredReviewer: REVIEWER_AGENT_TYPE, + configuredArbiter: ARBITER_AGENT_TYPE, + }); return { chat_jid: chatJid, - owner_service_id: ownerServiceId, - reviewer_service_id: null, - arbiter_service_id: null, + owner_agent_type: rolePlan.ownerAgentType, + reviewer_agent_type: rolePlan.reviewerAgentType, + arbiter_agent_type: rolePlan.arbiterAgentType, + owner_service_id: + resolveRoleServiceShadow('owner', rolePlan.ownerAgentType) ?? + normalizeServiceId(SERVICE_ID), + reviewer_service_id: resolveRoleServiceShadow( + 'reviewer', + rolePlan.reviewerAgentType, + ), + arbiter_service_id: resolveRoleServiceShadow( + 'arbiter', + rolePlan.arbiterAgentType, + ), + owner_failover_active: false, activated_at: null, reason: null, explicit: false, }; } +function getStoredOrDefaultLease(chatJid: string): EffectiveChannelLease { + refreshChannelOwnerCache(); + const row = leaseCache.get(chatJid); + if (row) { + return normalizeLeaseRow(row, true); + } + return getDefaultLease(chatJid); +} + export function refreshChannelOwnerCache(force = false): void { const now = Date.now(); if (!force && now - lastLeaseRefreshAt < LEASE_CACHE_REFRESH_MS) { @@ -130,24 +178,63 @@ export function refreshChannelOwnerCache(force = false): void { export function getEffectiveChannelLease( chatJid: string, ): EffectiveChannelLease { - // Global failover overrides all per-channel leases + // Global failover overrides the owner execution backend for all channels, + // while preserving the room's reviewer/arbiter role assignments. if (globalFailoverActive) { + const baseLease = getStoredOrDefaultLease(chatJid); return { - chat_jid: chatJid, + ...baseLease, owner_service_id: CODEX_REVIEW_SERVICE_ID, - reviewer_service_id: CODEX_MAIN_SERVICE_ID, - arbiter_service_id: null, + owner_failover_active: true, activated_at: globalFailoverActivatedAt, reason: globalFailoverReason, explicit: true, }; } - refreshChannelOwnerCache(); - const row = leaseCache.get(chatJid); - if (row) { - return normalizeLeaseRow(row, true); + return getStoredOrDefaultLease(chatJid); +} + +export function resolveLeaseServiceId( + lease: Pick< + EffectiveChannelLease, + | 'owner_agent_type' + | 'reviewer_agent_type' + | 'arbiter_agent_type' + | 'owner_service_id' + | 'reviewer_service_id' + | 'arbiter_service_id' + | 'owner_failover_active' + >, + role: PairedRoomRole, +): string | null { + switch (role) { + case 'owner': + if (lease.owner_failover_active) { + return normalizeServiceId(lease.owner_service_id); + } + return ( + resolveRoleServiceShadow('owner', lease.owner_agent_type) ?? + normalizeServiceId(lease.owner_service_id) + ); + case 'reviewer': + return lease.reviewer_agent_type + ? resolveRoleServiceShadow('reviewer', lease.reviewer_agent_type) ?? + (lease.reviewer_service_id + ? normalizeServiceId(lease.reviewer_service_id) + : null) + : lease.reviewer_service_id + ? normalizeServiceId(lease.reviewer_service_id) + : null; + case 'arbiter': + return lease.arbiter_agent_type + ? resolveRoleServiceShadow('arbiter', lease.arbiter_agent_type) ?? + (lease.arbiter_service_id + ? normalizeServiceId(lease.arbiter_service_id) + : null) + : lease.arbiter_service_id + ? normalizeServiceId(lease.arbiter_service_id) + : null; } - return getDefaultLease(chatJid); } export function isOwnerServiceForChat( @@ -155,7 +242,7 @@ export function isOwnerServiceForChat( serviceId: string = SERVICE_ID, ): boolean { const lease = getEffectiveChannelLease(chatJid); - return normalizeServiceId(serviceId) === lease.owner_service_id; + return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'owner'); } export function isReviewerServiceForChat( @@ -163,14 +250,11 @@ export function isReviewerServiceForChat( serviceId: string = SERVICE_ID, ): boolean { const lease = getEffectiveChannelLease(chatJid); - return ( - lease.reviewer_service_id !== null && - normalizeServiceId(serviceId) === lease.reviewer_service_id - ); + return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'reviewer'); } export function hasReviewerLease(chatJid: string): boolean { - return getEffectiveChannelLease(chatJid).reviewer_service_id !== null; + return resolveLeaseServiceId(getEffectiveChannelLease(chatJid), 'reviewer') !== null; } export function isArbiterServiceForChat( @@ -178,10 +262,7 @@ export function isArbiterServiceForChat( serviceId: string = SERVICE_ID, ): boolean { const lease = getEffectiveChannelLease(chatJid); - return ( - lease.arbiter_service_id !== null && - normalizeServiceId(serviceId) === lease.arbiter_service_id - ); + return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'arbiter'); } export function shouldServiceProcessChat( @@ -192,7 +273,7 @@ export function shouldServiceProcessChat( } // ── Global failover ────────────────────────────────────────────── -// Claude API limits are account-level, so failover applies to all channels. +// Claude API limits are account-level, so owner failover applies to all channels. let globalFailoverActive = false; let globalFailoverReason: string | null = null; @@ -204,7 +285,7 @@ export function activateCodexFailover(_chatJid: string, reason: string): void { globalFailoverActivatedAt = new Date().toISOString(); logger.warn( { reason, activatedAt: globalFailoverActivatedAt }, - 'Global failover activated — all channels switching to codex', + 'Global failover activated — owner execution switching to codex across all channels', ); } @@ -229,7 +310,7 @@ export function clearGlobalFailover(): void { globalFailoverActive = false; globalFailoverReason = null; globalFailoverActivatedAt = null; - logger.info('Global failover cleared — resuming normal routing'); + logger.info('Global failover cleared — resuming normal owner routing'); } export function restoreDefaultChannelLease(chatJid: string): void { diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 958ee6d..707105a 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -134,9 +134,18 @@ vi.mock('./github-ci.js', () => ({ ), })); -import { _initTestDatabase, createTask, getTaskById } from './db.js'; +vi.mock('./service-routing.js', () => ({ + hasReviewerLease: vi.fn(() => false), +})); + +import { + _initTestDatabase, + createTask, + getTaskById, +} from './db.js'; import * as codexTokenRotation from './codex-token-rotation.js'; import { TIMEZONE } from './config.js'; +import * as serviceRouting from './service-routing.js'; import { createTaskStatusTracker } from './task-status-tracker.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import * as tokenRotation from './token-rotation.js'; @@ -185,6 +194,7 @@ describe('task scheduler', () => { vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false); vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1); vi.mocked(codexTokenRotation.markCodexTokenHealthy).mockClear(); + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false); vi.useFakeTimers(); }); @@ -331,6 +341,146 @@ describe('task scheduler', () => { expect(enqueueTask.mock.calls[0][1]).toBe('task-single-tick'); }); + it('uses the reviewer bot identity for paired-room scheduled output', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-paired-reviewer-output', + group_folder: 'paired-group', + chat_jid: 'paired@g.us', + agent_type: 'codex', + prompt: 'paired task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + vi.mocked(serviceRouting.hasReviewerLease).mockImplementation( + (jid) => jid === 'paired@g.us', + ); + (runAgentProcessMock as any).mockImplementationOnce( + async ( + _group: unknown, + _input: unknown, + _onProcess: unknown, + onOutput?: (output: Record) => Promise, + ) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'paired reviewer output', + }); + return { + status: 'success', + result: 'paired reviewer output', + }; + }, + ); + + const enqueueTask = vi.fn( + async (_groupJid: string, _taskId: string, fn: () => Promise) => { + await fn(); + }, + ); + const sendMessage = vi.fn(async () => {}); + const sendMessageViaReviewerBot = vi.fn(async () => {}); + + await runSchedulerTickOnce({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'paired@g.us': { + name: 'Paired', + folder: 'paired-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage, + sendMessageViaReviewerBot, + }); + + expect(runAgentProcessMock).toHaveBeenCalledTimes(1); + expect(sendMessageViaReviewerBot).toHaveBeenCalledTimes(1); + expect(sendMessageViaReviewerBot).toHaveBeenCalledWith( + 'paired@g.us', + 'paired reviewer output', + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('fails closed for paired-room scheduled output when the reviewer bot is missing', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-paired-reviewer-missing', + group_folder: 'paired-group', + chat_jid: 'paired@g.us', + agent_type: 'codex', + prompt: 'paired task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + vi.mocked(serviceRouting.hasReviewerLease).mockImplementation( + (jid) => jid === 'paired@g.us', + ); + (runAgentProcessMock as any).mockImplementationOnce( + async ( + _group: unknown, + _input: unknown, + _onProcess: unknown, + onOutput?: (output: Record) => Promise, + ) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'paired reviewer output', + }); + return { + status: 'success', + result: 'paired reviewer output', + }; + }, + ); + + const enqueueTask = vi.fn( + async (_groupJid: string, _taskId: string, fn: () => Promise) => { + await fn(); + }, + ); + const sendMessage = vi.fn(async () => {}); + + await runSchedulerTickOnce({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'paired@g.us': { + name: 'Paired', + folder: 'paired-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage, + }); + + expect(runAgentProcessMock).toHaveBeenCalledTimes(1); + expect(sendMessage).not.toHaveBeenCalled(); + expect(getTaskById('task-paired-reviewer-missing')).toBeDefined(); + }); + it('keeps group-context tasks on the chat queue key', async () => { const dueAt = new Date(Date.now() - 60_000).toISOString(); createTask({ diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 2734ceb..000f356 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -179,6 +179,25 @@ interface TaskExecutionContext { taskAgentType: AgentType; } +async function sendScheduledMessage( + deps: SchedulerDependencies, + chatJid: string, + text: string, +): Promise { + if (!hasReviewerLease(chatJid)) { + await deps.sendMessage(chatJid, text); + return; + } + + if (!deps.sendMessageViaReviewerBot) { + throw new Error( + 'Paired-room scheduled output requires a configured reviewer Discord bot', + ); + } + + await deps.sendMessageViaReviewerBot(chatJid, text); +} + function resolveTaskExecutionContext( task: ScheduledTask, deps: SchedulerDependencies, @@ -396,13 +415,8 @@ async function runTask( const outputText = getAgentOutputText(streamedOutput); if (outputText) { attemptResult = outputText; - // In paired rooms, post cron output via reviewer bot so the - // owner treats it as a peer request and acts on it. - const send = - hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot - ? deps.sendMessageViaReviewerBot - : deps.sendMessage; - await send(task.chat_jid, outputText); + // Paired-room scheduler output must use the reviewer bot slot. + await sendScheduledMessage(deps, task.chat_jid, outputText); } if (streamedOutput.status === 'error') { @@ -743,11 +757,7 @@ async function runGithubCiTask( if (check.terminal) { await statusTracker.update('completed'); if (check.completionMessage) { - const send = - hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot - ? deps.sendMessageViaReviewerBot - : deps.sendMessage; - await send(task.chat_jid, check.completionMessage); + await sendScheduledMessage(deps, task.chat_jid, check.completionMessage); } deleteTask(task.id); completedAndDeleted = true; diff --git a/src/token-rotation.test.ts b/src/token-rotation.test.ts new file mode 100644 index 0000000..e659508 --- /dev/null +++ b/src/token-rotation.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/ejclaw-claude-rot-data', +})); + +vi.mock('./env.js', () => ({ + getEnv: vi.fn((key: string) => process.env[key]), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('./utils.js', async () => { + const actual = + await vi.importActual('./utils.js'); + return { + ...actual, + readJsonFile: vi.fn(() => null), + writeJsonFile: vi.fn(), + }; +}); + +describe('token-rotation runtime reselection', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-02T12:00:00.000Z')); + process.env.CLAUDE_CODE_OAUTH_TOKENS = 'token-1,token-2'; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.CLAUDE_CODE_OAUTH_TOKENS; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + }); + + it('returns to the preferred healthy token after the cooldown expires at runtime', async () => { + const mod = await import('./token-rotation.js'); + + mod.initTokenRotation(); + expect(mod.getCurrentToken()).toBe('token-1'); + + expect(mod.rotateToken('rate limit')).toBe(true); + expect(mod.getCurrentToken()).toBe('token-2'); + expect(mod.getTokenRotationInfo()).toMatchObject({ + total: 2, + currentIndex: 1, + rateLimited: 1, + }); + + vi.advanceTimersByTime(60 * 60 * 1000 + 1); + + expect(mod.getCurrentToken()).toBe('token-1'); + expect(mod.getTokenRotationInfo()).toMatchObject({ + total: 2, + currentIndex: 0, + rateLimited: 0, + }); + }); +}); diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 94b68ae..da88989 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -114,9 +114,44 @@ function loadState(): void { ); } +function refreshRuntimeTokenSelection(): void { + if (tokens.length <= 1) return; + + const now = Date.now(); + const previousIndex = currentIndex; + let expiredCooldowns = 0; + + for (const token of tokens) { + if (token.rateLimitedUntil && token.rateLimitedUntil <= now) { + token.rateLimitedUntil = null; + expiredCooldowns += 1; + } + } + + const preferredIndex = tokens.findIndex( + (token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now, + ); + if (preferredIndex !== -1) { + currentIndex = preferredIndex; + } + + if (expiredCooldowns > 0 || currentIndex !== previousIndex) { + logger.info( + { + previousIndex, + currentIndex, + expiredCooldowns, + }, + 'Refreshed Claude token runtime selection', + ); + saveState(); + } +} + /** Get the current active token. */ export function getCurrentToken(): string | undefined { if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN; + refreshRuntimeTokenSelection(); return tokens[currentIndex % tokens.length]?.token; } @@ -181,9 +216,11 @@ export function rotateToken( export function markTokenHealthy(): void { if (tokens.length === 0) return; const state = tokens[currentIndex]; + let updated = false; if (state?.rateLimitedUntil) { const previousCooldownUntil = state.rateLimitedUntil; state.rateLimitedUntil = null; + updated = true; logger.info( { transition: 'rotation:clear-rate-limit', @@ -192,8 +229,9 @@ export function markTokenHealthy(): void { }, 'Cleared Claude token rate-limit state after successful response', ); - saveState(); } + refreshRuntimeTokenSelection(); + if (updated) saveState(); } /** Number of configured tokens. */ @@ -209,6 +247,7 @@ export function getAllTokens(): { isActive: boolean; isRateLimited: boolean; }[] { + refreshRuntimeTokenSelection(); const now = Date.now(); return tokens.map((t, i) => ({ index: i, @@ -244,6 +283,7 @@ export function getTokenRotationInfo(): { currentIndex: number; rateLimited: number; } { + refreshRuntimeTokenSelection(); const now = Date.now(); return { total: tokens.length, @@ -258,6 +298,7 @@ export function hasAvailableClaudeToken(): boolean { if (tokens.length === 0) { return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN); } + refreshRuntimeTokenSelection(); const now = Date.now(); return tokens.some( (token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now, diff --git a/src/types.ts b/src/types.ts index 1a91729..248e230 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,8 +46,11 @@ export interface RoomRoleContext { role: PairedRoomRole; ownerServiceId: string; reviewerServiceId: string; + ownerAgentType?: AgentType; + reviewerAgentType?: AgentType | null; failoverOwner: boolean; arbiterServiceId?: string; + arbiterAgentType?: AgentType | null; } export interface PairedProject { @@ -64,6 +67,9 @@ export interface PairedTask { group_folder: string; owner_service_id: string; reviewer_service_id: string; + owner_agent_type?: AgentType | null; + reviewer_agent_type?: AgentType | null; + arbiter_agent_type?: AgentType | null; title: string | null; source_ref: string | null; plan_notes: string | null;