Refactor local memory and role-fixed runtime routing
This commit is contained in:
11
.env.example
11
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
42
README.md
42
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
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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}'`)
|
||||
|
||||
@@ -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-'),
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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}'`)
|
||||
|
||||
@@ -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-'),
|
||||
|
||||
123
setup/legacy-service-guard.test.ts
Normal file
123
setup/legacy-service-guard.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
209
setup/legacy-service-guard.ts
Normal file
209
setup/legacy-service-guard.ts
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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, '<plist />');
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
export interface RoleRoutingRequirementsSummary {
|
||||
tribunalRooms: number;
|
||||
activeArbiterTasks: number;
|
||||
}
|
||||
|
||||
export interface VerifySummary extends RegisteredGroupsSummary {
|
||||
status: VerifyStatus;
|
||||
servicesSummary: Record<string, string>;
|
||||
configuredChannels: string[];
|
||||
channelAuth: Record<string, string>;
|
||||
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<string, string> {
|
||||
const channelAuth: Record<string, string> = {};
|
||||
|
||||
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<string, string>,
|
||||
registeredGroups: number,
|
||||
groupsByAgent: Record<string, number>,
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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,
|
||||
|
||||
@@ -99,6 +99,7 @@ export type AgentTriggerReason =
|
||||
| 'usage-exhausted'
|
||||
| 'auth-expired'
|
||||
| 'org-access-denied'
|
||||
| 'session-failure'
|
||||
| 'overloaded'
|
||||
| 'network-error'
|
||||
| 'success-null-result';
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
90
src/arbiter-service-shadow.test.ts
Normal file
90
src/arbiter-service-shadow.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, NodeJS.Timeout>();
|
||||
private typingGenerations = new Map<string, number>();
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
1195
src/db.test.ts
1195
src/db.test.ts
File diff suppressed because it is too large
Load Diff
@@ -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<void> {
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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(
|
||||
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
'<message sender="reviewer" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
);
|
||||
expect(input.prompt).not.toContain(
|
||||
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
@@ -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(
|
||||
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
'<message sender="arbiter" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
);
|
||||
expect(input.prompt).not.toContain(
|
||||
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ServiceHandoff, 'intended_role' | 'reason'>,
|
||||
handoff: Pick<ServiceHandoff, 'target_role' | 'intended_role' | 'reason'>,
|
||||
): 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<string, string>();
|
||||
for (const ch of deps.channels) {
|
||||
@@ -164,36 +175,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Map channel name → service ID
|
||||
const channelToService: Record<string, string> = {
|
||||
discord: 'claude',
|
||||
'discord-codex': 'codex-main',
|
||||
'discord-review': 'codex-review',
|
||||
const channelToRole: Record<string, PairedRoomRole> = {
|
||||
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<string, Channel> = {
|
||||
const roleToChannel: Record<string, Channel | null> = {
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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> = {}): 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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = { '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<ReturnType<typeof loadModules>>['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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
src/role-service-shadow.ts
Normal file
68
src/role-service-shadow.ts
Normal file
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>) => Promise<void>,
|
||||
) => {
|
||||
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<void>) => {
|
||||
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<string, unknown>) => Promise<void>,
|
||||
) => {
|
||||
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<void>) => {
|
||||
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({
|
||||
|
||||
@@ -179,6 +179,25 @@ interface TaskExecutionContext {
|
||||
taskAgentType: AgentType;
|
||||
}
|
||||
|
||||
async function sendScheduledMessage(
|
||||
deps: SchedulerDependencies,
|
||||
chatJid: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
||||
68
src/token-rotation.test.ts
Normal file
68
src/token-rotation.test.ts
Normal file
@@ -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<typeof import('./utils.js')>('./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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user