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.
|
# Copy this file to .env and fill in the values.
|
||||||
# Single unified service manages all three Discord bots in one process.
|
# Single unified service manages all three Discord bots in one process.
|
||||||
|
|
||||||
# --- Discord bots (3 tokens for 3 bots) ---
|
# --- Discord bots (canonical role-fixed names) ---
|
||||||
DISCORD_BOT_TOKEN= # Claude bot
|
DISCORD_OWNER_BOT_TOKEN= # Owner bot
|
||||||
DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner)
|
DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot
|
||||||
DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (fallback reviewer)
|
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 ---
|
# --- Agent type configuration ---
|
||||||
OWNER_AGENT_TYPE=codex # codex | claude-code
|
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
|
## 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
|
## Key Files
|
||||||
|
|
||||||
@@ -57,9 +57,10 @@ bun run deploy
|
|||||||
|
|
||||||
Single unified service manages all three Discord bots in one process:
|
Single unified service manages all three Discord bots in one process:
|
||||||
- `ejclaw.service` — Single unified 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)
|
- 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/`
|
- Shared dirs: `store/`, `groups/`, `data/`
|
||||||
- SQLite WAL mode + `busy_timeout=5000` for concurrent access
|
- 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
|
## 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.
|
| Role | Purpose |
|
||||||
- **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.
|
| **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
|
## 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.
|
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
|
User message
|
||||||
@@ -131,7 +143,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
|
|||||||
- Docker (required for reviewer container isolation)
|
- Docker (required for reviewer container isolation)
|
||||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
|
- [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
|
### Install
|
||||||
|
|
||||||
@@ -144,15 +156,23 @@ bun run build
|
|||||||
bun run build:container # Build reviewer Docker image
|
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
|
### Environment
|
||||||
|
|
||||||
All configuration in a single `.env` file:
|
All configuration in a single `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Discord bots (3 tokens for 3 bots)
|
# Discord bots (canonical role-fixed names)
|
||||||
DISCORD_BOT_TOKEN= # Claude bot
|
DISCORD_OWNER_BOT_TOKEN= # Owner bot
|
||||||
DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner)
|
DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot
|
||||||
DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (arbiter)
|
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
|
# Agent types
|
||||||
OWNER_AGENT_TYPE=codex # codex | claude-code
|
OWNER_AGENT_TYPE=codex # codex | claude-code
|
||||||
|
|||||||
@@ -45,6 +45,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
|
|||||||
}).trim();
|
}).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(
|
export function buildReviewerGitGuardEnv(
|
||||||
baseEnv: NodeJS.ProcessEnv,
|
baseEnv: NodeJS.ProcessEnv,
|
||||||
reviewerRuntime: boolean,
|
reviewerRuntime: boolean,
|
||||||
@@ -55,9 +95,7 @@ export function buildReviewerGitGuardEnv(
|
|||||||
|
|
||||||
const realGitPath = resolveGitBinary(baseEnv);
|
const realGitPath = resolveGitBinary(baseEnv);
|
||||||
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
|
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
|
||||||
const wrapperDir = fs.mkdtempSync(
|
const wrapperDir = createExecutableWrapperDir(baseEnv);
|
||||||
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
|
|
||||||
);
|
|
||||||
const wrapperPath = path.join(wrapperDir, 'git');
|
const wrapperPath = path.join(wrapperDir, 'git');
|
||||||
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
||||||
.map((value) => `'${value}'`)
|
.map((value) => `'${value}'`)
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ describe('claude reviewer runtime guard', () => {
|
|||||||
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
|
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', () => {
|
it('allows mutating git commands in temp repos outside the protected workspace', () => {
|
||||||
const protectedDir = fs.mkdtempSync(
|
const protectedDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),
|
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
|
|||||||
}).trim();
|
}).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(
|
export function buildReviewerGitGuardEnv(
|
||||||
baseEnv: NodeJS.ProcessEnv,
|
baseEnv: NodeJS.ProcessEnv,
|
||||||
reviewerRuntime: boolean,
|
reviewerRuntime: boolean,
|
||||||
@@ -52,9 +92,7 @@ export function buildReviewerGitGuardEnv(
|
|||||||
|
|
||||||
const realGitPath = resolveGitBinary(baseEnv);
|
const realGitPath = resolveGitBinary(baseEnv);
|
||||||
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
|
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
|
||||||
const wrapperDir = fs.mkdtempSync(
|
const wrapperDir = createExecutableWrapperDir(baseEnv);
|
||||||
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
|
|
||||||
);
|
|
||||||
const wrapperPath = path.join(wrapperDir, 'git');
|
const wrapperPath = path.join(wrapperDir, 'git');
|
||||||
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
||||||
.map((value) => `'${value}'`)
|
.map((value) => `'${value}'`)
|
||||||
|
|||||||
@@ -49,6 +49,27 @@ describe('codex reviewer runtime guard', () => {
|
|||||||
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
|
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', () => {
|
it('allows mutating git commands in temp repos outside the protected workspace', () => {
|
||||||
const protectedDir = fs.mkdtempSync(
|
const protectedDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),
|
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', () => {
|
it('dispatches stack restart through the oneshot unit by default', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
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();
|
const execFileSyncImpl = vi.fn();
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ describe('restartStackServices', () => {
|
|||||||
serviceId: null,
|
serviceId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
expect(services).toEqual(['ejclaw']);
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
'systemctl',
|
'systemctl',
|
||||||
@@ -43,11 +41,9 @@ describe('restartStackServices', () => {
|
|||||||
expect(execFileSyncImpl).toHaveBeenCalledTimes(1);
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
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();
|
const execFileSyncImpl = vi.fn();
|
||||||
|
|
||||||
@@ -59,11 +55,11 @@ describe('restartStackServices', () => {
|
|||||||
serviceId: null,
|
serviceId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
expect(services).toEqual(['ejclaw']);
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
'systemctl',
|
'systemctl',
|
||||||
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
|
['--user', 'restart', 'ejclaw'],
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore' },
|
||||||
);
|
);
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||||
@@ -72,18 +68,7 @@ describe('restartStackServices', () => {
|
|||||||
['--user', 'is-active', '--quiet', 'ejclaw'],
|
['--user', 'is-active', '--quiet', 'ejclaw'],
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore' },
|
||||||
);
|
);
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenCalledTimes(2);
|
||||||
3,
|
|
||||||
'systemctl',
|
|
||||||
['--user', 'is-active', '--quiet', 'ejclaw-codex'],
|
|
||||||
{ stdio: 'ignore' },
|
|
||||||
);
|
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
|
||||||
4,
|
|
||||||
'systemctl',
|
|
||||||
['--user', 'is-active', '--quiet', 'ejclaw-review'],
|
|
||||||
{ stdio: 'ignore' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-systemd environments', () => {
|
it('rejects non-systemd environments', () => {
|
||||||
@@ -97,11 +82,34 @@ describe('restartStackServices', () => {
|
|||||||
).toThrow('restart:stack only supports Linux systemd services in this repo');
|
).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', () => {
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
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
|
const execFileSyncImpl = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -120,7 +128,7 @@ describe('restartStackServices', () => {
|
|||||||
serviceId: null,
|
serviceId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
expect(services).toEqual(['ejclaw']);
|
||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
'systemctl',
|
'systemctl',
|
||||||
@@ -130,7 +138,7 @@ describe('restartStackServices', () => {
|
|||||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
'systemctl',
|
'systemctl',
|
||||||
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
|
['--user', 'restart', 'ejclaw'],
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -138,7 +146,6 @@ describe('restartStackServices', () => {
|
|||||||
it('rejects direct fallback when the unit is missing for a managed service caller', () => {
|
it('rejects direct fallback when the unit is missing for a managed service caller', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
|
||||||
|
|
||||||
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
||||||
const error = new Error('unit missing');
|
const error = new Error('unit missing');
|
||||||
@@ -161,7 +168,6 @@ describe('restartStackServices', () => {
|
|||||||
it('does not hide a general start failure behind direct fallback', () => {
|
it('does not hide a general start failure behind direct fallback', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
|
||||||
|
|
||||||
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
||||||
const error = new Error('job failed');
|
const error = new Error('job failed');
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
|
import os from 'os';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
detectLegacyServiceIssues,
|
||||||
|
formatLegacyServiceFailureMessage,
|
||||||
|
type LegacyServiceIssue,
|
||||||
|
} from './legacy-service-guard.js';
|
||||||
import { getServiceManager, isRoot } from './platform.js';
|
import { getServiceManager, isRoot } from './platform.js';
|
||||||
import { getConfiguredServiceNames } from './service-defs.js';
|
import { getConfiguredServiceNames } from './service-defs.js';
|
||||||
|
|
||||||
@@ -17,6 +23,8 @@ interface RestartStackDeps {
|
|||||||
serviceManager?: ServiceManager;
|
serviceManager?: ServiceManager;
|
||||||
direct?: boolean;
|
direct?: boolean;
|
||||||
serviceId?: string | null;
|
serviceId?: string | null;
|
||||||
|
legacyServiceIssues?: LegacyServiceIssue[];
|
||||||
|
homeDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartStackServicesDirect(
|
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 services = getConfiguredServiceNames(projectRoot);
|
||||||
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
||||||
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import fs from 'fs';
|
export type ServiceKind = 'primary' | 'legacy';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export type ServiceKind = 'primary' | 'codex' | 'review';
|
|
||||||
|
|
||||||
export interface ServiceDef {
|
export interface ServiceDef {
|
||||||
/** Stable topology kind used by setup/verify logic */
|
/** Stable topology kind used by setup/verify logic */
|
||||||
@@ -26,11 +23,9 @@ interface ServiceTemplate {
|
|||||||
launchdLabel: string;
|
launchdLabel: string;
|
||||||
description: string;
|
description: string;
|
||||||
logName: string;
|
logName: string;
|
||||||
envFileName?: string;
|
|
||||||
assistantName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_TEMPLATES: ServiceTemplate[] = [
|
const CURRENT_SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||||
{
|
{
|
||||||
kind: 'primary',
|
kind: 'primary',
|
||||||
name: 'ejclaw',
|
name: 'ejclaw',
|
||||||
@@ -38,37 +33,27 @@ const SERVICE_TEMPLATES: ServiceTemplate[] = [
|
|||||||
description: 'EJClaw Personal Assistant (Claude Code)',
|
description: 'EJClaw Personal Assistant (Claude Code)',
|
||||||
logName: 'ejclaw',
|
logName: 'ejclaw',
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||||
{
|
{
|
||||||
kind: 'codex',
|
kind: 'legacy',
|
||||||
name: 'ejclaw-codex',
|
name: 'ejclaw-codex',
|
||||||
launchdLabel: 'com.ejclaw-codex',
|
launchdLabel: 'com.ejclaw-codex',
|
||||||
description: 'EJClaw Codex Assistant',
|
description: 'Legacy EJClaw Codex Assistant',
|
||||||
logName: 'ejclaw-codex',
|
logName: 'ejclaw-codex',
|
||||||
envFileName: '.env.codex',
|
|
||||||
assistantName: 'codex',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'review',
|
kind: 'legacy',
|
||||||
name: 'ejclaw-review',
|
name: 'ejclaw-review',
|
||||||
launchdLabel: 'com.ejclaw-review',
|
launchdLabel: 'com.ejclaw-review',
|
||||||
description: 'EJClaw Codex Review Assistant',
|
description: 'Legacy EJClaw Codex Review Assistant',
|
||||||
logName: 'ejclaw-review',
|
logName: 'ejclaw-review',
|
||||||
envFileName: '.env.codex-review',
|
|
||||||
assistantName: 'codex',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function materializeServiceDef(
|
function materializeServiceDef(template: ServiceTemplate): ServiceDef {
|
||||||
projectRoot: string,
|
const environmentFile = undefined;
|
||||||
template: ServiceTemplate,
|
|
||||||
): ServiceDef | null {
|
|
||||||
const environmentFile = template.envFileName
|
|
||||||
? path.join(projectRoot, template.envFileName)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (environmentFile && !fs.existsSync(environmentFile)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
@@ -77,21 +62,28 @@ function materializeServiceDef(
|
|||||||
description: template.description,
|
description: template.description,
|
||||||
logName: template.logName,
|
logName: template.logName,
|
||||||
environmentFile,
|
environmentFile,
|
||||||
extraEnv: template.assistantName
|
extraEnv: undefined,
|
||||||
? {
|
|
||||||
ASSISTANT_NAME: template.assistantName,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServiceDefs(projectRoot: string): ServiceDef[] {
|
export function getServiceDefs(projectRoot: string): ServiceDef[] {
|
||||||
return SERVICE_TEMPLATES.flatMap((template) => {
|
void projectRoot;
|
||||||
const def = materializeServiceDef(projectRoot, template);
|
return CURRENT_SERVICE_TEMPLATES.map((template) =>
|
||||||
return def ? [def] : [];
|
materializeServiceDef(template),
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyServiceDefs(projectRoot: string): ServiceDef[] {
|
||||||
|
void projectRoot;
|
||||||
|
return LEGACY_SERVICE_TEMPLATES.map((template) =>
|
||||||
|
materializeServiceDef(template),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfiguredServiceNames(projectRoot: string): string[] {
|
export function getConfiguredServiceNames(projectRoot: string): string[] {
|
||||||
return getServiceDefs(projectRoot).map((def) => def.name);
|
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,
|
buildStackRestartSystemdUnit,
|
||||||
buildSystemdUnit,
|
buildSystemdUnit,
|
||||||
} from './service-renderers.js';
|
} 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.
|
* Tests for service configuration generation.
|
||||||
@@ -127,19 +131,18 @@ describe('systemd unit generation', () => {
|
|||||||
const unit = buildSystemdUnit(
|
const unit = buildSystemdUnit(
|
||||||
{
|
{
|
||||||
...baseServiceDef,
|
...baseServiceDef,
|
||||||
kind: 'codex',
|
kind: 'primary',
|
||||||
environmentFile: '/srv/ejclaw/.env.codex',
|
environmentFile: '/srv/ejclaw/.env.extra',
|
||||||
extraEnv: { ASSISTANT_NAME: 'codex' },
|
extraEnv: { ASSISTANT_NAME: 'codex' },
|
||||||
logName: 'ejclaw-codex',
|
logName: 'ejclaw',
|
||||||
name: 'ejclaw-codex',
|
name: 'ejclaw',
|
||||||
},
|
},
|
||||||
'/srv/ejclaw',
|
'/srv/ejclaw',
|
||||||
'/usr/bin/bun',
|
'/usr/bin/bun',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.extra');
|
||||||
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.codex');
|
|
||||||
expect(unit).toContain('Environment=ASSISTANT_NAME=codex');
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
|
||||||
tempRoots.push(tempRoot);
|
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);
|
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([
|
expect(defs.map((def) => def.name)).toEqual([
|
||||||
'ejclaw',
|
|
||||||
'ejclaw-codex',
|
'ejclaw-codex',
|
||||||
'ejclaw-review',
|
'ejclaw-review',
|
||||||
]);
|
]);
|
||||||
expect(defs.map((def) => def.kind)).toEqual([
|
expect(defs.map((def) => def.kind)).toEqual(['legacy', 'legacy']);
|
||||||
'primary',
|
|
||||||
'codex',
|
|
||||||
'review',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a oneshot stack restart unit', () => {
|
it('generates a oneshot stack restart unit', () => {
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
* Step: service — Generate and load service manager config.
|
* Step: service — Generate and load service manager config.
|
||||||
* Replaces 08-setup-service.sh
|
* Replaces 08-setup-service.sh
|
||||||
*
|
*
|
||||||
* Supports the EJClaw service stack:
|
* Supports the unified EJClaw service:
|
||||||
* - ejclaw (Claude Code) — always installed
|
* - ejclaw — always installed
|
||||||
* - ejclaw-codex (Codex) — installed when .env.codex exists
|
|
||||||
* - ejclaw-review (Codex Review) — installed when .env.codex-review exists
|
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -13,7 +11,11 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { logger } from '../src/logger.js';
|
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 { getServiceDefs } from './service-defs.js';
|
||||||
import { setupLaunchd, setupLinux } from './service-installers.js';
|
import { setupLaunchd, setupLinux } from './service-installers.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
@@ -27,9 +29,41 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
const nodePath = getNodePath();
|
const nodePath = getNodePath();
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
const serviceManager = getServiceManager();
|
||||||
|
|
||||||
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
|
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
|
// Build first
|
||||||
logger.info('Building TypeScript');
|
logger.info('Building TypeScript');
|
||||||
try {
|
try {
|
||||||
@@ -54,14 +88,6 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||||
|
|
||||||
const serviceDefs = getServiceDefs(projectRoot);
|
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') {
|
if (platform === 'macos') {
|
||||||
for (const def of serviceDefs) {
|
for (const def of serviceDefs) {
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ vi.mock('./platform.js', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
checkLaunchdService,
|
checkLaunchdService,
|
||||||
|
checkLaunchdServiceArtifact,
|
||||||
checkNohupService,
|
checkNohupService,
|
||||||
|
checkNohupServiceArtifact,
|
||||||
checkSystemdService,
|
checkSystemdService,
|
||||||
|
checkSystemdServiceInScope,
|
||||||
getServiceChecks,
|
getServiceChecks,
|
||||||
} from './verify-services.js';
|
} from './verify-services.js';
|
||||||
import type { ServiceDef } from './service-defs.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', () => {
|
it('treats known but inactive systemd services as stopped', () => {
|
||||||
execSyncMock
|
execSyncMock
|
||||||
.mockImplementationOnce(() => {
|
.mockImplementationOnce(() => {
|
||||||
@@ -79,6 +119,15 @@ describe('verify service checks', () => {
|
|||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
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', () => {
|
it('builds per-service status checks from service definitions', () => {
|
||||||
const defs: ServiceDef[] = [
|
const defs: ServiceDef[] = [
|
||||||
{
|
{
|
||||||
@@ -89,18 +138,18 @@ describe('verify service checks', () => {
|
|||||||
logName: 'ejclaw',
|
logName: 'ejclaw',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'codex',
|
kind: 'primary',
|
||||||
name: 'ejclaw-codex',
|
name: 'ejclaw-secondary',
|
||||||
description: 'Codex',
|
description: 'Secondary',
|
||||||
launchdLabel: 'com.ejclaw.codex',
|
launchdLabel: 'com.ejclaw.secondary',
|
||||||
logName: 'ejclaw-codex',
|
logName: 'ejclaw-secondary',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n');
|
execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n');
|
||||||
|
|
||||||
expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([
|
expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([
|
||||||
{ name: 'ejclaw', status: 'running' },
|
{ 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;
|
status: ServiceStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceCheckOptions {
|
||||||
|
detectArtifacts?: boolean;
|
||||||
|
homeDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemdScope = 'system' | 'user';
|
||||||
|
|
||||||
export function checkLaunchdService(label: string): ServiceStatus {
|
export function checkLaunchdService(label: string): ServiceStatus {
|
||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||||
@@ -32,8 +39,26 @@ export function checkLaunchdService(label: string): ServiceStatus {
|
|||||||
return 'not_found';
|
return 'not_found';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkSystemdService(name: string): ServiceStatus {
|
function getLaunchdPlistPath(homeDir: string, launchdLabel: string): string {
|
||||||
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
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 {
|
try {
|
||||||
execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' });
|
execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' });
|
||||||
return 'running';
|
return 'running';
|
||||||
@@ -52,6 +77,10 @@ export function checkSystemdService(name: string): ServiceStatus {
|
|||||||
return 'not_found';
|
return 'not_found';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkSystemdService(name: string): ServiceStatus {
|
||||||
|
return checkSystemdServiceInScope(name, isRoot() ? 'system' : 'user');
|
||||||
|
}
|
||||||
|
|
||||||
export function checkNohupService(
|
export function checkNohupService(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
@@ -72,18 +101,40 @@ export function checkNohupService(
|
|||||||
return 'not_found';
|
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(
|
export function checkService(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
serviceManager: 'launchd' | 'systemd' | 'none',
|
serviceManager: 'launchd' | 'systemd' | 'none',
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
launchdLabel: string,
|
launchdLabel: string,
|
||||||
|
options: ServiceCheckOptions = {},
|
||||||
): ServiceStatus {
|
): ServiceStatus {
|
||||||
if (serviceManager === 'launchd') {
|
if (serviceManager === 'launchd') {
|
||||||
|
if (options.detectArtifacts && options.homeDir) {
|
||||||
|
return checkLaunchdServiceArtifact(
|
||||||
|
launchdLabel,
|
||||||
|
getLaunchdPlistPath(options.homeDir, launchdLabel),
|
||||||
|
);
|
||||||
|
}
|
||||||
return checkLaunchdService(launchdLabel);
|
return checkLaunchdService(launchdLabel);
|
||||||
}
|
}
|
||||||
if (serviceManager === 'systemd') {
|
if (serviceManager === 'systemd') {
|
||||||
return checkSystemdService(serviceName);
|
return checkSystemdService(serviceName);
|
||||||
}
|
}
|
||||||
|
if (options.detectArtifacts) {
|
||||||
|
return checkNohupServiceArtifact(projectRoot, serviceName);
|
||||||
|
}
|
||||||
return checkNohupService(projectRoot, serviceName);
|
return checkNohupService(projectRoot, serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +142,7 @@ export function getServiceChecks(
|
|||||||
serviceDefs: ServiceDef[],
|
serviceDefs: ServiceDef[],
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
serviceManager: 'launchd' | 'systemd' | 'none',
|
serviceManager: 'launchd' | 'systemd' | 'none',
|
||||||
|
options: ServiceCheckOptions = {},
|
||||||
): ServiceCheck[] {
|
): ServiceCheck[] {
|
||||||
return serviceDefs.map((def) => ({
|
return serviceDefs.map((def) => ({
|
||||||
name: def.name,
|
name: def.name,
|
||||||
@@ -99,6 +151,7 @@ export function getServiceChecks(
|
|||||||
serviceManager,
|
serviceManager,
|
||||||
def.name,
|
def.name,
|
||||||
def.launchdLabel,
|
def.launchdLabel,
|
||||||
|
options,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
buildVerifySummary,
|
buildVerifySummary,
|
||||||
detectChannelAuth,
|
detectChannelAuth,
|
||||||
detectCredentials,
|
detectCredentials,
|
||||||
|
detectLegacyDiscordTokenKeys,
|
||||||
loadRegisteredGroupsSummary,
|
loadRegisteredGroupsSummary,
|
||||||
|
loadRoleRoutingRequirementsSummary,
|
||||||
} from './verify-state.js';
|
} from './verify-state.js';
|
||||||
|
|
||||||
describe('verify state helpers', () => {
|
describe('verify state helpers', () => {
|
||||||
@@ -34,14 +36,88 @@ describe('verify state helpers', () => {
|
|||||||
expect(detectCredentials(tempRoot)).toBe('configured');
|
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(
|
expect(
|
||||||
detectChannelAuth(
|
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({
|
).toEqual({
|
||||||
discord: 'configured',
|
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',
|
launchdLabel: 'com.ejclaw',
|
||||||
logName: 'ejclaw',
|
logName: 'ejclaw',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
kind: 'codex',
|
|
||||||
name: 'ejclaw-codex',
|
|
||||||
description: 'Codex',
|
|
||||||
launchdLabel: 'com.ejclaw.codex',
|
|
||||||
logName: 'ejclaw-codex',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -97,15 +166,19 @@ describe('verify state helpers', () => {
|
|||||||
services,
|
services,
|
||||||
serviceDefs,
|
serviceDefs,
|
||||||
'configured',
|
'configured',
|
||||||
{ discord: 'configured' },
|
{
|
||||||
|
discord: 'configured',
|
||||||
|
'discord-review': 'configured',
|
||||||
|
'discord-arbiter': 'configured',
|
||||||
|
},
|
||||||
2,
|
2,
|
||||||
{ codex: 1 },
|
{ codex: 1 },
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
configuredChannels: ['discord'],
|
configuredChannels: ['discord', 'discord-review', 'discord-arbiter'],
|
||||||
codexConfigured: true,
|
codexConfigured: true,
|
||||||
reviewConfigured: false,
|
reviewConfigured: true,
|
||||||
servicesSummary: { ejclaw: 'running' },
|
servicesSummary: { ejclaw: 'running' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -128,4 +201,95 @@ describe('verify state helpers', () => {
|
|||||||
servicesSummary: { ejclaw: 'stopped' },
|
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 CredentialsStatus = 'configured' | 'missing';
|
||||||
export type VerifyStatus = 'success' | 'failed';
|
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 {
|
export interface RegisteredGroupsSummary {
|
||||||
registeredGroups: number;
|
registeredGroups: number;
|
||||||
groupsByAgent: Record<string, number>;
|
groupsByAgent: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoleRoutingRequirementsSummary {
|
||||||
|
tribunalRooms: number;
|
||||||
|
activeArbiterTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VerifySummary extends RegisteredGroupsSummary {
|
export interface VerifySummary extends RegisteredGroupsSummary {
|
||||||
status: VerifyStatus;
|
status: VerifyStatus;
|
||||||
servicesSummary: Record<string, string>;
|
servicesSummary: Record<string, string>;
|
||||||
configuredChannels: string[];
|
configuredChannels: string[];
|
||||||
channelAuth: Record<string, string>;
|
channelAuth: Record<string, string>;
|
||||||
|
legacyDiscordTokenKeys: string[];
|
||||||
|
tribunalRooms: number;
|
||||||
|
activeArbiterTasks: number;
|
||||||
|
// Legacy status fields kept for backward-compatible setup output.
|
||||||
codexConfigured: boolean;
|
codexConfigured: boolean;
|
||||||
reviewConfigured: boolean;
|
reviewConfigured: boolean;
|
||||||
}
|
}
|
||||||
@@ -38,18 +56,39 @@ export function detectCredentials(projectRoot: string): CredentialsStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function detectChannelAuth(
|
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,
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const channelAuth: 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';
|
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;
|
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(
|
export function loadRegisteredGroupsSummary(
|
||||||
dbPath = path.join(STORE_DIR, 'messages.db'),
|
dbPath = path.join(STORE_DIR, 'messages.db'),
|
||||||
): RegisteredGroupsSummary {
|
): RegisteredGroupsSummary {
|
||||||
@@ -88,6 +127,82 @@ export function loadRegisteredGroupsSummary(
|
|||||||
return { registeredGroups, groupsByAgent };
|
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(
|
export function buildVerifySummary(
|
||||||
services: ServiceCheck[],
|
services: ServiceCheck[],
|
||||||
serviceDefs: ServiceDef[],
|
serviceDefs: ServiceDef[],
|
||||||
@@ -95,22 +210,33 @@ export function buildVerifySummary(
|
|||||||
channelAuth: Record<string, string>,
|
channelAuth: Record<string, string>,
|
||||||
registeredGroups: number,
|
registeredGroups: number,
|
||||||
groupsByAgent: Record<string, number>,
|
groupsByAgent: Record<string, number>,
|
||||||
|
options: {
|
||||||
|
legacyDiscordTokenKeys?: string[];
|
||||||
|
tribunalRooms?: number;
|
||||||
|
activeArbiterTasks?: number;
|
||||||
|
} = {},
|
||||||
): VerifySummary {
|
): VerifySummary {
|
||||||
|
void serviceDefs;
|
||||||
const configuredChannels = Object.keys(channelAuth);
|
const configuredChannels = Object.keys(channelAuth);
|
||||||
const allConfiguredServicesRunning = services.every(
|
const allConfiguredServicesRunning = services.every(
|
||||||
(service) => service.status === 'running',
|
(service) => service.status === 'running',
|
||||||
);
|
);
|
||||||
const codexConfigured = serviceDefs.some(
|
const hasOwnerCapableChannel = 'discord' in channelAuth;
|
||||||
(service) => service.kind === 'codex',
|
const codexConfigured = 'discord-review' in channelAuth;
|
||||||
);
|
const reviewConfigured = 'discord-arbiter' in channelAuth;
|
||||||
const reviewConfigured = serviceDefs.some(
|
const legacyDiscordTokenKeys = options.legacyDiscordTokenKeys ?? [];
|
||||||
(service) => service.kind === 'review',
|
const tribunalRooms = options.tribunalRooms ?? 0;
|
||||||
);
|
const activeArbiterTasks = options.activeArbiterTasks ?? 0;
|
||||||
|
const reviewerConfigured = tribunalRooms === 0 || codexConfigured;
|
||||||
|
const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured;
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
allConfiguredServicesRunning &&
|
allConfiguredServicesRunning &&
|
||||||
credentials === 'configured' &&
|
credentials === 'configured' &&
|
||||||
configuredChannels.length > 0 &&
|
hasOwnerCapableChannel &&
|
||||||
|
legacyDiscordTokenKeys.length === 0 &&
|
||||||
|
reviewerConfigured &&
|
||||||
|
arbiterConfigured &&
|
||||||
registeredGroups > 0
|
registeredGroups > 0
|
||||||
? 'success'
|
? 'success'
|
||||||
: 'failed';
|
: 'failed';
|
||||||
@@ -125,6 +251,9 @@ export function buildVerifySummary(
|
|||||||
servicesSummary,
|
servicesSummary,
|
||||||
configuredChannels,
|
configuredChannels,
|
||||||
channelAuth,
|
channelAuth,
|
||||||
|
legacyDiscordTokenKeys,
|
||||||
|
tribunalRooms,
|
||||||
|
activeArbiterTasks,
|
||||||
registeredGroups,
|
registeredGroups,
|
||||||
groupsByAgent,
|
groupsByAgent,
|
||||||
codexConfigured,
|
codexConfigured,
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
* Step: verify — End-to-end health check of the full installation.
|
* Step: verify — End-to-end health check of the full installation.
|
||||||
* Replaces 09-verify.sh
|
* Replaces 09-verify.sh
|
||||||
*
|
*
|
||||||
* Supports the EJClaw service stack:
|
* Supports the unified EJClaw service:
|
||||||
* - ejclaw (Claude Code) — always checked
|
* - ejclaw — always checked
|
||||||
* - ejclaw-codex (Codex) — checked when .env.codex exists
|
|
||||||
* - ejclaw-review (Codex Review) — checked when .env.codex-review exists
|
|
||||||
*
|
*
|
||||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||||
*/
|
*/
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
|
import {
|
||||||
|
detectLegacyServiceIssues,
|
||||||
|
formatLegacyServiceFailureMessage,
|
||||||
|
} from './legacy-service-guard.js';
|
||||||
import { getServiceManager } from './platform.js';
|
import { getServiceManager } from './platform.js';
|
||||||
import { getServiceDefs } from './service-defs.js';
|
import { getServiceDefs } from './service-defs.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
@@ -17,7 +21,9 @@ import {
|
|||||||
buildVerifySummary,
|
buildVerifySummary,
|
||||||
detectChannelAuth,
|
detectChannelAuth,
|
||||||
detectCredentials,
|
detectCredentials,
|
||||||
|
detectLegacyDiscordTokenKeys,
|
||||||
loadRegisteredGroupsSummary,
|
loadRegisteredGroupsSummary,
|
||||||
|
loadRoleRoutingRequirementsSummary,
|
||||||
} from './verify-state.js';
|
} from './verify-state.js';
|
||||||
import { getServiceChecks } from './verify-services.js';
|
import { getServiceChecks } from './verify-services.js';
|
||||||
|
|
||||||
@@ -34,18 +40,35 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
// 1. Check service statuses
|
// 1. Check service statuses
|
||||||
const serviceDefs = getServiceDefs(projectRoot);
|
const serviceDefs = getServiceDefs(projectRoot);
|
||||||
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
|
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
|
||||||
|
const legacyServiceIssues = detectLegacyServiceIssues(
|
||||||
|
projectRoot,
|
||||||
|
mgr,
|
||||||
|
os.homedir(),
|
||||||
|
);
|
||||||
|
|
||||||
for (const svc of services) {
|
for (const svc of services) {
|
||||||
logger.info({ service: svc.name, status: svc.status }, 'Service status');
|
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 credentials = detectCredentials(projectRoot);
|
||||||
const channelAuth = detectChannelAuth();
|
const channelAuth = detectChannelAuth();
|
||||||
|
const legacyDiscordTokenKeys = detectLegacyDiscordTokenKeys();
|
||||||
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary();
|
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary();
|
||||||
|
const { tribunalRooms, activeArbiterTasks } =
|
||||||
|
loadRoleRoutingRequirementsSummary();
|
||||||
const {
|
const {
|
||||||
status,
|
status: baseStatus,
|
||||||
servicesSummary,
|
servicesSummary,
|
||||||
configuredChannels,
|
configuredChannels,
|
||||||
|
legacyDiscordTokenKeys: legacyDiscordTokens,
|
||||||
|
tribunalRooms: detectedTribunalRooms,
|
||||||
|
activeArbiterTasks: detectedActiveArbiterTasks,
|
||||||
codexConfigured,
|
codexConfigured,
|
||||||
reviewConfigured,
|
reviewConfigured,
|
||||||
} = buildVerifySummary(
|
} = buildVerifySummary(
|
||||||
@@ -55,17 +78,64 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
channelAuth,
|
channelAuth,
|
||||||
registeredGroups,
|
registeredGroups,
|
||||||
groupsByAgent,
|
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', {
|
emitStatus('VERIFY', {
|
||||||
SERVICES: JSON.stringify(servicesSummary),
|
SERVICES: JSON.stringify(servicesSummary),
|
||||||
|
LEGACY_SERVICES: JSON.stringify(legacyServicesSummary),
|
||||||
// Legacy field (keep for backward compatibility)
|
// Legacy field (keep for backward compatibility)
|
||||||
SERVICE: services[0].status,
|
SERVICE: services[0].status,
|
||||||
CREDENTIALS: credentials,
|
CREDENTIALS: credentials,
|
||||||
CONFIGURED_CHANNELS: configuredChannels.join(','),
|
CONFIGURED_CHANNELS: configuredChannels.join(','),
|
||||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||||
|
LEGACY_DISCORD_TOKENS: legacyDiscordTokens.join(','),
|
||||||
|
TRIBUNAL_ROOMS: detectedTribunalRooms,
|
||||||
|
ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks,
|
||||||
REGISTERED_GROUPS: registeredGroups,
|
REGISTERED_GROUPS: registeredGroups,
|
||||||
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
|
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
|
||||||
CODEX_CONFIGURED: codexConfigured,
|
CODEX_CONFIGURED: codexConfigured,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export type AgentTriggerReason =
|
|||||||
| 'usage-exhausted'
|
| 'usage-exhausted'
|
||||||
| 'auth-expired'
|
| 'auth-expired'
|
||||||
| 'org-access-denied'
|
| 'org-access-denied'
|
||||||
|
| 'session-failure'
|
||||||
| 'overloaded'
|
| 'overloaded'
|
||||||
| 'network-error'
|
| 'network-error'
|
||||||
| 'success-null-result';
|
| 'success-null-result';
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ vi.mock('./service-routing.js', () => ({
|
|||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
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 config from './config.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
@@ -202,8 +206,9 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
|||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
owner_service_id: 'codex-review',
|
owner_service_id: 'codex-review',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: true,
|
||||||
activated_at: '2026-03-28T00:00:00.000Z',
|
activated_at: '2026-03-28T00:00:00.000Z',
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
@@ -309,6 +314,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
|||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -334,3 +340,67 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
|||||||
expect(segments).toEqual(['platform prompt']);
|
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 path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
|
||||||
GROUPS_DIR,
|
GROUPS_DIR,
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
SERVICE_SESSION_SCOPE,
|
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: {
|
function buildBaseRunnerEnv(args: {
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -214,31 +238,8 @@ function prepareCodexSessionEnvironment(args: {
|
|||||||
process.env.CODEX_EFFORT;
|
process.env.CODEX_EFFORT;
|
||||||
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
||||||
|
|
||||||
const hostCodexDir = path.join(os.homedir(), '.codex');
|
|
||||||
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
|
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
|
||||||
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
syncHostCodexSessionFiles(sessionCodexDir);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
|
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
|
||||||
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
||||||
@@ -400,10 +401,10 @@ export function prepareGroupEnvironment(
|
|||||||
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
||||||
const isPairedRoom = hasReviewerLease(chatJid);
|
const isPairedRoom = hasReviewerLease(chatJid);
|
||||||
const effectiveLease = getEffectiveChannelLease(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 =
|
const useCodexReviewFailoverPromptPack =
|
||||||
isReviewService(SERVICE_ID) &&
|
isReviewService(SERVICE_ID) && effectiveLease.owner_failover_active === true;
|
||||||
effectiveLease.explicit &&
|
|
||||||
effectiveLease.owner_service_id === CODEX_REVIEW_SERVICE_ID;
|
|
||||||
|
|
||||||
const ownerCommonPlatformPrompt = readOptionalPromptFile(
|
const ownerCommonPlatformPrompt = readOptionalPromptFile(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
@@ -553,6 +554,12 @@ export function prepareContainerSessionEnvironment(args: {
|
|||||||
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
|
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
|
||||||
if (sessionClaudeMd) {
|
if (sessionClaudeMd) {
|
||||||
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
|
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
|
||||||
|
const sessionCodexDir = path.join(sessionDir, '.codex');
|
||||||
|
syncHostCodexSessionFiles(sessionCodexDir);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(sessionCodexDir, 'AGENTS.md'),
|
||||||
|
sessionClaudeMd + '\n',
|
||||||
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
sessionDir,
|
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);
|
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', () => {
|
it('does not own WhatsApp group JIDs', () => {
|
||||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
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 ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
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.
|
* Download a Discord attachment to local disk.
|
||||||
@@ -182,16 +227,22 @@ export class DiscordChannel implements Channel {
|
|||||||
private typingIntervals = new Map<string, NodeJS.Timeout>();
|
private typingIntervals = new Map<string, NodeJS.Timeout>();
|
||||||
private typingGenerations = new Map<string, number>();
|
private typingGenerations = new Map<string, number>();
|
||||||
private agentTypeFilter?: AgentType;
|
private agentTypeFilter?: AgentType;
|
||||||
|
private receivesInbound: boolean;
|
||||||
|
private ownsDiscordJids: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
botToken: string,
|
botToken: string,
|
||||||
opts: DiscordChannelOpts,
|
opts: DiscordChannelOpts,
|
||||||
agentTypeFilter?: AgentType,
|
agentTypeFilter?: AgentType,
|
||||||
channelName?: string,
|
channelName?: string,
|
||||||
|
receivesInbound = true,
|
||||||
|
ownsDiscordJids = true,
|
||||||
) {
|
) {
|
||||||
this.botToken = botToken;
|
this.botToken = botToken;
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.agentTypeFilter = agentTypeFilter;
|
this.agentTypeFilter = agentTypeFilter;
|
||||||
|
this.receivesInbound = receivesInbound;
|
||||||
|
this.ownsDiscordJids = ownsDiscordJids;
|
||||||
if (channelName) {
|
if (channelName) {
|
||||||
this.name = channelName;
|
this.name = channelName;
|
||||||
} else if (agentTypeFilter) {
|
} else if (agentTypeFilter) {
|
||||||
@@ -212,6 +263,7 @@ export class DiscordChannel implements Channel {
|
|||||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||||
const channelId = message.channelId;
|
const channelId = message.channelId;
|
||||||
const chatJid = `dc:${channelId}`;
|
const chatJid = `dc:${channelId}`;
|
||||||
|
if (!this.receivesInbound) return;
|
||||||
const isOwnBotMessage = message.author.id === this.client?.user?.id;
|
const isOwnBotMessage = message.author.id === this.client?.user?.id;
|
||||||
if (isOwnBotMessage) return;
|
if (isOwnBotMessage) return;
|
||||||
if (message.author.bot && !hasReviewerLease(chatJid)) return;
|
if (message.author.bot && !hasReviewerLease(chatJid)) return;
|
||||||
@@ -356,7 +408,8 @@ export class DiscordChannel implements Channel {
|
|||||||
isGroup,
|
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];
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
if (!group) {
|
if (!group) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -544,6 +597,7 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
ownsJid(jid: string): boolean {
|
ownsJid(jid: string): boolean {
|
||||||
if (!jid.startsWith('dc:')) return false;
|
if (!jid.startsWith('dc:')) return false;
|
||||||
|
if (!this.ownsDiscordJids) return false;
|
||||||
if (!this.agentTypeFilter) return true;
|
if (!this.agentTypeFilter) return true;
|
||||||
const group = this.opts.registeredGroups()[jid];
|
const group = this.opts.registeredGroups()[jid];
|
||||||
if (!group) return false;
|
if (!group) return false;
|
||||||
@@ -733,32 +787,66 @@ export class DiscordChannel implements Channel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerChannel('discord', (opts: ChannelOpts) => {
|
registerChannel(DISCORD_OWNER_CHANNEL, (opts: ChannelOpts) => {
|
||||||
const token = getEnv('DISCORD_BOT_TOKEN') || '';
|
const token = getRoleTokenOrLogMigrationError(
|
||||||
|
DISCORD_OWNER_TOKEN_KEY,
|
||||||
|
DISCORD_OWNER_LEGACY_TOKEN_KEYS,
|
||||||
|
'owner',
|
||||||
|
);
|
||||||
if (!token) {
|
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;
|
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(
|
return new DiscordChannel(
|
||||||
token,
|
token,
|
||||||
opts,
|
opts,
|
||||||
hasCodexBot ? 'claude-code' : undefined,
|
undefined,
|
||||||
'discord',
|
DISCORD_REVIEWER_CHANNEL,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the secondary Codex bot channel.
|
registerChannel(DISCORD_ARBITER_CHANNEL, (opts: ChannelOpts) => {
|
||||||
registerChannel('discord-codex', (opts: ChannelOpts) => {
|
const ownerToken = getEnv(DISCORD_OWNER_TOKEN_KEY) || '';
|
||||||
const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || '';
|
const reviewerToken = getEnv(DISCORD_REVIEWER_TOKEN_KEY) || '';
|
||||||
|
const token = getRoleTokenOrLogMigrationError(
|
||||||
|
DISCORD_ARBITER_TOKEN_KEY,
|
||||||
|
DISCORD_ARBITER_LEGACY_TOKEN_KEYS,
|
||||||
|
'arbiter',
|
||||||
|
);
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
return new DiscordChannel(token, opts, 'codex', 'discord-codex');
|
if (token === ownerToken || token === reviewerToken) {
|
||||||
});
|
logger.warn(
|
||||||
|
'Discord: arbiter bot token matches another role token; skipping duplicate arbiter bot login',
|
||||||
// Register the review bot channel (codex agent type, separate token).
|
);
|
||||||
registerChannel('discord-review', (opts: ChannelOpts) => {
|
return null;
|
||||||
const token = getEnv('DISCORD_REVIEW_BOT_TOKEN') || '';
|
}
|
||||||
if (!token) return null;
|
return new DiscordChannel(
|
||||||
return new DiscordChannel(token, opts, 'codex', 'discord-review');
|
token,
|
||||||
|
opts,
|
||||||
|
undefined,
|
||||||
|
DISCORD_ARBITER_CHANNEL,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const ARBITER_AGENT_TYPE: AgentType | undefined =
|
|||||||
? rawArbiterAgentType
|
? rawArbiterAgentType
|
||||||
: undefined;
|
: 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
|
export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
|
||||||
? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID
|
? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID
|
||||||
: null;
|
: 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,
|
DATA_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
REVIEWER_AGENT_TYPE,
|
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
isSessionCommandSenderAllowed,
|
isSessionCommandSenderAllowed,
|
||||||
STATUS_CHANNEL_ID,
|
STATUS_CHANNEL_ID,
|
||||||
@@ -445,10 +444,8 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start subsystems (independently of connection handler)
|
// Start subsystems (independently of connection handler)
|
||||||
// Resolve the reviewer channel so cron output in paired rooms is posted
|
// Paired-room scheduler output goes through the reviewer bot slot.
|
||||||
// via the reviewer bot — the owner then treats it as a peer request.
|
const reviewerChannelName = 'discord-review';
|
||||||
const reviewerChannelName =
|
|
||||||
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
|
||||||
const reviewerChannelForCron = findChannelByName(
|
const reviewerChannelForCron = findChannelByName(
|
||||||
channels,
|
channels,
|
||||||
reviewerChannelName,
|
reviewerChannelName,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ vi.mock('./available-groups.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./config.js', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
|
ARBITER_SERVICE_ID: null,
|
||||||
|
CLAUDE_SERVICE_ID: 'claude',
|
||||||
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
||||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
@@ -61,13 +63,48 @@ vi.mock('./service-routing.js', () => ({
|
|||||||
clearGlobalFailover: vi.fn(),
|
clearGlobalFailover: vi.fn(),
|
||||||
getEffectiveChannelLease: vi.fn(() => ({
|
getEffectiveChannelLease: vi.fn(() => ({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-review',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
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', () => {
|
vi.mock('./logger.js', () => {
|
||||||
@@ -155,6 +192,7 @@ import * as agentRunner from './agent-runner.js';
|
|||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
@@ -299,13 +337,13 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
roomRoleContext: {
|
roomRoleContext: expect.objectContaining({
|
||||||
serviceId: 'claude',
|
serviceId: 'claude',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
ownerServiceId: 'claude',
|
ownerServiceId: 'claude',
|
||||||
reviewerServiceId: 'codex-main',
|
reviewerServiceId: 'codex-review',
|
||||||
failoverOwner: false,
|
failoverOwner: false,
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -317,6 +355,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
const group = { ...makeGroup(), folder: 'test-group' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
@@ -347,6 +388,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
const group = { ...makeGroup(), folder: 'test-group' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
@@ -383,13 +427,13 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
roomRoleContext: {
|
roomRoleContext: expect.objectContaining({
|
||||||
serviceId: 'claude',
|
serviceId: 'claude',
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
ownerServiceId: 'claude',
|
ownerServiceId: 'claude',
|
||||||
reviewerServiceId: 'claude',
|
reviewerServiceId: 'claude',
|
||||||
failoverOwner: false,
|
failoverOwner: false,
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -401,6 +445,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
const group = { ...makeGroup(), folder: 'test-group' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
@@ -438,13 +485,13 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
roomRoleContext: {
|
roomRoleContext: expect.objectContaining({
|
||||||
serviceId: 'claude',
|
serviceId: 'claude',
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
ownerServiceId: 'codex-main',
|
ownerServiceId: 'codex-main',
|
||||||
reviewerServiceId: 'claude',
|
reviewerServiceId: 'claude',
|
||||||
failoverOwner: false,
|
failoverOwner: false,
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -460,6 +507,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
};
|
};
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
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 () => {
|
it('allows silent reviewer outputs', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
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 () => {
|
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
|
||||||
const group = {
|
const group = {
|
||||||
...makeGroup(),
|
...makeGroup(),
|
||||||
@@ -673,6 +891,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
|
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
@@ -759,6 +980,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
|
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
@@ -1011,7 +1235,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
expect(outputs).toEqual(['fresh Claude retry success']);
|
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 outputs: string[] = [];
|
||||||
const deps = makeDeps();
|
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(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
|
||||||
expect(deps.clearSession).toHaveBeenCalledTimes(2);
|
expect(deps.clearSession).toHaveBeenCalledTimes(2);
|
||||||
expect(outputs).toEqual([]);
|
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 () => {
|
it('returns error after all Claude accounts are usage-exhausted', async () => {
|
||||||
@@ -1117,7 +1356,9 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
expect(db.createServiceHandoff).toHaveBeenCalledWith(
|
expect(db.createServiceHandoff).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
target_service_id: 'codex-review',
|
source_role: 'owner',
|
||||||
|
source_agent_type: 'claude-code',
|
||||||
|
target_role: 'owner',
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
start_seq: 10,
|
start_seq: 10,
|
||||||
end_seq: 12,
|
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 () => {
|
it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => {
|
||||||
const outputs: string[] = [];
|
const outputs: string[] = [];
|
||||||
|
|
||||||
@@ -1322,7 +1614,9 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
expect(db.createServiceHandoff).toHaveBeenCalledWith(
|
expect(db.createServiceHandoff).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
target_service_id: 'codex-review',
|
source_role: 'owner',
|
||||||
|
source_agent_type: 'claude-code',
|
||||||
|
target_role: 'owner',
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
reason: 'claude-org-access-denied',
|
reason: 'claude-org-access-denied',
|
||||||
intended_role: 'owner',
|
intended_role: 'owner',
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { readArbiterPrompt } from './platform-prompts.js';
|
|||||||
import {
|
import {
|
||||||
activateCodexFailover,
|
activateCodexFailover,
|
||||||
getEffectiveChannelLease,
|
getEffectiveChannelLease,
|
||||||
|
resolveLeaseServiceId,
|
||||||
} from './service-routing.js';
|
} from './service-routing.js';
|
||||||
import {
|
import {
|
||||||
evaluateStreamedOutput,
|
evaluateStreamedOutput,
|
||||||
@@ -108,26 +109,26 @@ export async function runAgentForGroup(
|
|||||||
// In unified mode, determine role from the lease directly.
|
// In unified mode, determine role from the lease directly.
|
||||||
// Default to owner; the auto-review trigger in completePairedExecutionContext
|
// Default to owner; the auto-review trigger in completePairedExecutionContext
|
||||||
// will switch to reviewer when the task is in review_ready state.
|
// 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)
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
: null;
|
: null;
|
||||||
const inferredRole = resolveActiveRole(pairedTask?.status);
|
const inferredRole = resolveActiveRole(pairedTask?.status);
|
||||||
const canHonorForcedRole = Boolean(
|
const canHonorForcedRole = Boolean(
|
||||||
args.forcedRole === 'owner' ||
|
args.forcedRole === 'owner' ||
|
||||||
(args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) ||
|
(args.forcedRole === 'reviewer' && currentLease.reviewer_agent_type) ||
|
||||||
(args.forcedRole === 'arbiter' && currentLease.arbiter_service_id),
|
(args.forcedRole === 'arbiter' && currentLease.arbiter_agent_type),
|
||||||
);
|
);
|
||||||
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole;
|
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole;
|
||||||
const effectiveServiceId =
|
const effectiveServiceId = resolveLeaseServiceId(currentLease, activeRole);
|
||||||
activeRole === 'arbiter'
|
if (!effectiveServiceId) {
|
||||||
? currentLease.arbiter_service_id!
|
throw new Error(`Missing runtime service id for ${activeRole} lease`);
|
||||||
: activeRole === 'reviewer'
|
}
|
||||||
? currentLease.reviewer_service_id!
|
|
||||||
: currentLease.owner_service_id;
|
|
||||||
const reviewerMode = activeRole === 'reviewer';
|
const reviewerMode = activeRole === 'reviewer';
|
||||||
const arbiterMode = activeRole === 'arbiter';
|
const arbiterMode = activeRole === 'arbiter';
|
||||||
|
const reviewerServiceId = resolveLeaseServiceId(currentLease, 'reviewer');
|
||||||
|
const arbiterServiceId = resolveLeaseServiceId(currentLease, 'arbiter');
|
||||||
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
|
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
|
||||||
currentLease.reviewer_service_id != null,
|
currentLease.reviewer_agent_type != null,
|
||||||
group.agentType,
|
group.agentType,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -194,8 +195,10 @@ export async function runAgentForGroup(
|
|||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
});
|
});
|
||||||
// Inject role-specific model overrides into envOverrides
|
// Forced fallbacks run under a different agent runtime, so keep the
|
||||||
if (pairedExecutionContext) {
|
// fallback session on its default model/effort unless explicitly configured
|
||||||
|
// for that runtime elsewhere.
|
||||||
|
if (pairedExecutionContext && !args.forcedAgentType) {
|
||||||
const roleConfig = getRoleModelConfig(activeRole);
|
const roleConfig = getRoleModelConfig(activeRole);
|
||||||
if (roleConfig.model) {
|
if (roleConfig.model) {
|
||||||
const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL';
|
const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL';
|
||||||
@@ -230,8 +233,10 @@ export async function runAgentForGroup(
|
|||||||
groupAgentType: group.agentType,
|
groupAgentType: group.agentType,
|
||||||
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
|
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
|
||||||
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
|
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
|
||||||
reviewerServiceId: currentLease.reviewer_service_id,
|
reviewerServiceId,
|
||||||
arbiterServiceId: currentLease.arbiter_service_id,
|
arbiterServiceId,
|
||||||
|
reviewerAgentType: currentLease.reviewer_agent_type,
|
||||||
|
arbiterAgentType: currentLease.arbiter_agent_type,
|
||||||
reviewerMode,
|
reviewerMode,
|
||||||
arbiterMode,
|
arbiterMode,
|
||||||
sessionFolder,
|
sessionFolder,
|
||||||
@@ -295,7 +300,8 @@ export async function runAgentForGroup(
|
|||||||
reason === '429' ||
|
reason === '429' ||
|
||||||
reason === 'usage-exhausted' ||
|
reason === 'usage-exhausted' ||
|
||||||
reason === 'auth-expired' ||
|
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)) {
|
if (!shouldHandoffToCodex(reason, sawVisibleOutput)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (currentLease.reviewer_service_id === null) {
|
if (currentLease.reviewer_agent_type === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Per-role fallback toggle
|
// Per-role fallback toggle
|
||||||
@@ -322,8 +328,9 @@ export async function runAgentForGroup(
|
|||||||
createServiceHandoff({
|
createServiceHandoff({
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
source_service_id: SERVICE_SESSION_SCOPE,
|
source_role: activeRole,
|
||||||
target_service_id: CODEX_REVIEW_SERVICE_ID,
|
target_role: 'arbiter',
|
||||||
|
source_agent_type: effectiveAgentType,
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
prompt,
|
prompt,
|
||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
@@ -344,8 +351,9 @@ export async function runAgentForGroup(
|
|||||||
createServiceHandoff({
|
createServiceHandoff({
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
source_service_id: SERVICE_SESSION_SCOPE,
|
source_role: activeRole,
|
||||||
target_service_id: CODEX_REVIEW_SERVICE_ID,
|
target_role: 'reviewer',
|
||||||
|
source_agent_type: effectiveAgentType,
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
prompt,
|
prompt,
|
||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
@@ -364,8 +372,9 @@ export async function runAgentForGroup(
|
|||||||
createServiceHandoff({
|
createServiceHandoff({
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
source_service_id: SERVICE_SESSION_SCOPE,
|
source_role: activeRole,
|
||||||
target_service_id: CODEX_REVIEW_SERVICE_ID,
|
target_role: activeRole,
|
||||||
|
source_agent_type: effectiveAgentType,
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
prompt,
|
prompt,
|
||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
@@ -375,7 +384,7 @@ export async function runAgentForGroup(
|
|||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
'Claude unavailable, handed off current turn to codex-review',
|
'Claude unavailable, handed off current owner turn to codex fallback',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -442,6 +451,41 @@ export async function runAgentForGroup(
|
|||||||
|
|
||||||
const wrappedOnOutput = onOutput
|
const wrappedOnOutput = onOutput
|
||||||
? async (output: AgentOutput) => {
|
? 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 (
|
if (
|
||||||
isClaudeCodeAgent &&
|
isClaudeCodeAgent &&
|
||||||
provider === 'claude' &&
|
provider === 'claude' &&
|
||||||
@@ -468,7 +512,6 @@ export async function runAgentForGroup(
|
|||||||
});
|
});
|
||||||
streamedState = evaluation.state;
|
streamedState = evaluation.state;
|
||||||
|
|
||||||
const outputText = getAgentOutputText(output);
|
|
||||||
if (typeof outputText === 'string' && outputText.length > 0) {
|
if (typeof outputText === 'string' && outputText.length > 0) {
|
||||||
pairedExecutionSummary = outputText.slice(0, 500);
|
pairedExecutionSummary = outputText.slice(0, 500);
|
||||||
pairedFullOutput = outputText;
|
pairedFullOutput = outputText;
|
||||||
@@ -772,7 +815,7 @@ export async function runAgentForGroup(
|
|||||||
log.error(
|
log.error(
|
||||||
'Retryable Claude session failure persisted after fresh retry',
|
'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(),
|
failServiceHandoff: vi.fn(),
|
||||||
getAllChats: vi.fn(() => []),
|
getAllChats: vi.fn(() => []),
|
||||||
getAllTasks: vi.fn(() => []),
|
getAllTasks: vi.fn(() => []),
|
||||||
|
getAllPendingServiceHandoffs: vi.fn(() => []),
|
||||||
getLastHumanMessageTimestamp: vi.fn(() => null),
|
getLastHumanMessageTimestamp: vi.fn(() => null),
|
||||||
getLastHumanMessageContent: vi.fn(() => null),
|
getLastHumanMessageContent: vi.fn(() => null),
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
@@ -114,7 +115,6 @@ vi.mock('./db.js', () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
getOpenWorkItem: vi.fn(() => undefined),
|
getOpenWorkItem: vi.fn(() => undefined),
|
||||||
getPendingServiceHandoffs: vi.fn(() => []),
|
|
||||||
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
|
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
|
||||||
getPairedTurnOutputs: vi.fn(() => []),
|
getPairedTurnOutputs: vi.fn(() => []),
|
||||||
getRecentChatMessages: vi.fn(() => []),
|
getRecentChatMessages: vi.fn(() => []),
|
||||||
@@ -124,6 +124,7 @@ vi.mock('./db.js', () => {
|
|||||||
chat_jid: input.chat_jid,
|
chat_jid: input.chat_jid,
|
||||||
agent_type: input.agent_type || 'claude-code',
|
agent_type: input.agent_type || 'claude-code',
|
||||||
service_id: 'claude',
|
service_id: 'claude',
|
||||||
|
delivery_role: input.delivery_role ?? null,
|
||||||
status: 'produced',
|
status: 'produced',
|
||||||
start_seq: input.start_seq,
|
start_seq: input.start_seq,
|
||||||
end_seq: input.end_seq,
|
end_seq: input.end_seq,
|
||||||
@@ -151,6 +152,20 @@ vi.mock('./service-routing.js', () => ({
|
|||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
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),
|
shouldServiceProcessChat: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -185,6 +200,7 @@ import * as db from './db.js';
|
|||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import {
|
import {
|
||||||
createMessageRuntime,
|
createMessageRuntime,
|
||||||
|
resolveHandoffCursorKey,
|
||||||
resolveHandoffRoleOverride,
|
resolveHandoffRoleOverride,
|
||||||
} from './message-runtime.js';
|
} from './message-runtime.js';
|
||||||
import * as config from './config.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 {
|
return {
|
||||||
name: 'discord',
|
name,
|
||||||
connect: vi.fn().mockResolvedValue(undefined),
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
|
sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
|
||||||
isConnected: vi.fn(() => true),
|
isConnected: vi.fn(() => true),
|
||||||
ownsJid: vi.fn((jid: string) => jid === chatJid),
|
ownsJid: vi.fn((jid: string) => ownsJid && jid === chatJid),
|
||||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
editMessage: 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', () => {
|
it('prefers intended_role over reason prefixes for handoff role resolution', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveHandoffRoleOverride({
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: 'arbiter',
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'reviewer-claude-429',
|
||||||
|
}),
|
||||||
|
).toBe('arbiter');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
intended_role: 'reviewer',
|
intended_role: 'reviewer',
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
}),
|
}),
|
||||||
).toBe('reviewer');
|
).toBe('reviewer');
|
||||||
expect(
|
expect(
|
||||||
resolveHandoffRoleOverride({
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
intended_role: null,
|
intended_role: null,
|
||||||
reason: 'arbiter-claude-429',
|
reason: 'arbiter-claude-429',
|
||||||
}),
|
}),
|
||||||
).toBe('arbiter');
|
).toBe('arbiter');
|
||||||
expect(
|
expect(
|
||||||
resolveHandoffRoleOverride({
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
intended_role: null,
|
intended_role: null,
|
||||||
reason: 'reviewer-claude-usage-exhausted',
|
reason: 'reviewer-claude-usage-exhausted',
|
||||||
}),
|
}),
|
||||||
).toBe('reviewer');
|
).toBe('reviewer');
|
||||||
expect(
|
expect(
|
||||||
resolveHandoffRoleOverride({
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
intended_role: null,
|
intended_role: null,
|
||||||
reason: 'claude-usage-exhausted',
|
reason: 'claude-usage-exhausted',
|
||||||
}),
|
}),
|
||||||
).toBeUndefined();
|
).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 () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
@@ -576,15 +618,180 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
|
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 () => {
|
it('does not inject filtered raw bot finals into workspace-based review prompts', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
const saveState = vi.fn();
|
const saveState = vi.fn();
|
||||||
const lastAgentTimestamps: Record<string, string> = {};
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
const channel: Channel = {
|
const ownerChannel: Channel = {
|
||||||
...makeChannel(chatJid),
|
...makeChannel(chatJid),
|
||||||
isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'),
|
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.isClaudeService).mockReturnValue(false);
|
||||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||||
@@ -653,7 +860,7 @@ describe('createMessageRuntime', () => {
|
|||||||
pollInterval: 1_000,
|
pollInterval: 1_000,
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
triggerPattern: /^@Andy\b/i,
|
triggerPattern: /^@Andy\b/i,
|
||||||
channels: [channel],
|
channels: [ownerChannel, reviewerChannel],
|
||||||
queue: {
|
queue: {
|
||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
@@ -678,7 +885,10 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBe('41');
|
expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBe('41');
|
||||||
expect(saveState).toHaveBeenCalled();
|
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 () => {
|
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 () => {
|
it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
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(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
@@ -876,7 +1087,7 @@ describe('createMessageRuntime', () => {
|
|||||||
pollInterval: 1_000,
|
pollInterval: 1_000,
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
triggerPattern: /^@Andy\b/i,
|
triggerPattern: /^@Andy\b/i,
|
||||||
channels: [channel],
|
channels: [ownerChannel, arbiterChannel],
|
||||||
queue: {
|
queue: {
|
||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
@@ -899,19 +1110,234 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(arbiterChannel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'arbiter 확인 완료',
|
'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 chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
const group = makeGroup('claude-code');
|
||||||
const saveState = vi.fn();
|
const saveState = vi.fn();
|
||||||
const lastAgentTimestamps: Record<string, string> = {};
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
const channel: Channel = {
|
const ownerChannel = makeChannel(chatJid);
|
||||||
...makeChannel(chatJid),
|
const reviewerChannel: Channel = {
|
||||||
|
...makeChannel(chatJid, 'discord-review', false),
|
||||||
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -967,7 +1393,7 @@ describe('createMessageRuntime', () => {
|
|||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, input, _onProcess, onOutput) => {
|
async (_group, input, _onProcess, onOutput) => {
|
||||||
expect(input.prompt).toContain(
|
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(
|
expect(input.prompt).not.toContain(
|
||||||
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
@@ -992,7 +1418,7 @@ describe('createMessageRuntime', () => {
|
|||||||
pollInterval: 1_000,
|
pollInterval: 1_000,
|
||||||
timezone: 'Asia/Seoul',
|
timezone: 'Asia/Seoul',
|
||||||
triggerPattern: /^@Andy\b/i,
|
triggerPattern: /^@Andy\b/i,
|
||||||
channels: [channel],
|
channels: [ownerChannel, reviewerChannel],
|
||||||
queue: {
|
queue: {
|
||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
@@ -1009,7 +1435,7 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.processGroupMessages(chatJid, {
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
runId: 'run-same-service-raw-history',
|
runId: 'run-fixed-reviewer-bot-history',
|
||||||
reason: 'messages',
|
reason: 'messages',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1017,11 +1443,12 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
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 chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
const group = makeGroup('claude-code');
|
||||||
const channel: Channel = {
|
const ownerChannel = makeChannel(chatJid);
|
||||||
...makeChannel(chatJid),
|
const arbiterChannel: Channel = {
|
||||||
|
...makeChannel(chatJid, 'discord-arbiter', false),
|
||||||
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1077,7 +1504,7 @@ describe('createMessageRuntime', () => {
|
|||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, input, _onProcess, onOutput) => {
|
async (_group, input, _onProcess, onOutput) => {
|
||||||
expect(input.prompt).toContain(
|
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(
|
expect(input.prompt).not.toContain(
|
||||||
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
@@ -1102,7 +1529,7 @@ describe('createMessageRuntime', () => {
|
|||||||
pollInterval: 1_000,
|
pollInterval: 1_000,
|
||||||
timezone: 'Asia/Seoul',
|
timezone: 'Asia/Seoul',
|
||||||
triggerPattern: /^@Andy\b/i,
|
triggerPattern: /^@Andy\b/i,
|
||||||
channels: [channel],
|
channels: [ownerChannel, arbiterChannel],
|
||||||
queue: {
|
queue: {
|
||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
@@ -1119,7 +1546,7 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.processGroupMessages(chatJid, {
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
runId: 'run-same-service-arbiter-fallback',
|
runId: 'run-fixed-arbiter-bot-history',
|
||||||
reason: 'messages',
|
reason: 'messages',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
completeServiceHandoffAndAdvanceTargetCursor,
|
completeServiceHandoffAndAdvanceTargetCursor,
|
||||||
createProducedWorkItem,
|
createProducedWorkItem,
|
||||||
failServiceHandoff,
|
failServiceHandoff,
|
||||||
|
getAllPendingServiceHandoffs,
|
||||||
getOpenWorkItem,
|
getOpenWorkItem,
|
||||||
getPendingServiceHandoffs,
|
|
||||||
getMessagesSinceSeq,
|
getMessagesSinceSeq,
|
||||||
getNewMessagesBySeq,
|
getNewMessagesBySeq,
|
||||||
markWorkItemDelivered,
|
markWorkItemDelivered,
|
||||||
@@ -21,12 +21,7 @@ import {
|
|||||||
type WorkItem,
|
type WorkItem,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
CLAUDE_SERVICE_ID,
|
|
||||||
CODEX_MAIN_SERVICE_ID,
|
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
|
||||||
isSessionCommandSenderAllowed,
|
isSessionCommandSenderAllowed,
|
||||||
REVIEWER_AGENT_TYPE,
|
|
||||||
SERVICE_ID,
|
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +62,6 @@ import {
|
|||||||
import { createScopedLogger, logger } from './logger.js';
|
import { createScopedLogger, logger } from './logger.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import {
|
import {
|
||||||
getEffectiveChannelLease,
|
|
||||||
hasReviewerLease,
|
hasReviewerLease,
|
||||||
} from './service-routing.js';
|
} from './service-routing.js';
|
||||||
|
|
||||||
@@ -97,8 +91,11 @@ export function isDuplicateOfLastBotFinal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHandoffRoleOverride(
|
export function resolveHandoffRoleOverride(
|
||||||
handoff: Pick<ServiceHandoff, 'intended_role' | 'reason'>,
|
handoff: Pick<ServiceHandoff, 'target_role' | 'intended_role' | 'reason'>,
|
||||||
): PairedRoomRole | undefined {
|
): PairedRoomRole | undefined {
|
||||||
|
if (handoff.target_role) {
|
||||||
|
return handoff.target_role;
|
||||||
|
}
|
||||||
if (handoff.intended_role) {
|
if (handoff.intended_role) {
|
||||||
return handoff.intended_role;
|
return handoff.intended_role;
|
||||||
}
|
}
|
||||||
@@ -111,6 +108,16 @@ export function resolveHandoffRoleOverride(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveHandoffCursorKey(
|
||||||
|
chatJid: string,
|
||||||
|
role?: PairedRoomRole,
|
||||||
|
): string {
|
||||||
|
if (!role || role === 'owner') {
|
||||||
|
return chatJid;
|
||||||
|
}
|
||||||
|
return `${chatJid}:${role}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageRuntimeDeps {
|
export interface MessageRuntimeDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
idleTimeout: number;
|
idleTimeout: number;
|
||||||
@@ -129,6 +136,14 @@ export interface MessageRuntimeDeps {
|
|||||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
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): {
|
export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||||
processGroupMessages: (
|
processGroupMessages: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
@@ -149,10 +164,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
messages: NewMessage[],
|
messages: NewMessage[],
|
||||||
): NewMessage[] => {
|
): NewMessage[] => {
|
||||||
if (!hasReviewerLease(chatJid)) return messages;
|
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
|
// Build bot-user-id → channel-name mapping from connected channels
|
||||||
const botIdToChannelName = new Map<string, string>();
|
const botIdToChannelName = new Map<string, string>();
|
||||||
for (const ch of deps.channels) {
|
for (const ch of deps.channels) {
|
||||||
@@ -164,36 +175,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Map channel name → service ID
|
const channelToRole: Record<string, PairedRoomRole> = {
|
||||||
const channelToService: Record<string, string> = {
|
discord: 'owner',
|
||||||
discord: 'claude',
|
'discord-review': 'reviewer',
|
||||||
'discord-codex': 'codex-main',
|
'discord-arbiter': 'arbiter',
|
||||||
'discord-review': 'codex-review',
|
|
||||||
};
|
};
|
||||||
return messages.map((msg) => {
|
return messages.map((msg) => {
|
||||||
if (!msg.is_bot_message) return msg;
|
if (!msg.is_bot_message) return msg;
|
||||||
const channelName = botIdToChannelName.get(msg.sender);
|
const channelName = botIdToChannelName.get(msg.sender);
|
||||||
if (!channelName) return msg;
|
if (!channelName) return msg;
|
||||||
const serviceId = channelToService[channelName];
|
const role = channelToRole[channelName];
|
||||||
if (!serviceId) return msg;
|
return role ? { ...msg, sender_name: role } : 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 };
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -467,6 +459,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel: Channel;
|
channel: Channel;
|
||||||
startSeq: number | null;
|
startSeq: number | null;
|
||||||
endSeq: number | null;
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
forcedRole?: PairedRoomRole;
|
forcedRole?: PairedRoomRole;
|
||||||
forcedAgentType?: AgentType;
|
forcedAgentType?: AgentType;
|
||||||
@@ -504,6 +497,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
agent_type:
|
agent_type:
|
||||||
args.forcedAgentType ?? group.agentType ?? 'claude-code',
|
args.forcedAgentType ?? group.agentType ?? 'claude-code',
|
||||||
|
delivery_role: args.deliveryRole ?? args.forcedRole ?? null,
|
||||||
start_seq: startSeq,
|
start_seq: startSeq,
|
||||||
end_seq: endSeq,
|
end_seq: endSeq,
|
||||||
result_payload: text,
|
result_payload: text,
|
||||||
@@ -563,17 +557,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enqueuePendingHandoffs = (): void => {
|
const enqueuePendingHandoffs = (): void => {
|
||||||
// Unified service handles all three bots — collect handoffs for all service IDs.
|
// Unified runtime claims pending handoffs once and resolves delivery from role context.
|
||||||
const allServiceIds = new Set([
|
for (const handoff of getAllPendingServiceHandoffs()) {
|
||||||
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) {
|
|
||||||
if (!claimServiceHandoff(handoff.id)) {
|
if (!claimServiceHandoff(handoff.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -608,14 +593,29 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const handoffRole = resolveHandoffRoleOverride(handoff);
|
const handoffRole = resolveHandoffRoleOverride(handoff);
|
||||||
let handoffChannel = channel;
|
let handoffChannel = channel;
|
||||||
if (handoffRole === 'reviewer') {
|
if (handoffRole === 'reviewer') {
|
||||||
const revChName =
|
// Role-fixed delivery intentionally does not follow fallback agent type.
|
||||||
handoff.target_agent_type === 'claude-code'
|
const reviewerChannel = findChannelByName(
|
||||||
? 'discord'
|
deps.channels,
|
||||||
: 'discord-review';
|
getFixedRoleChannelName('reviewer'),
|
||||||
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
);
|
||||||
|
if (!reviewerChannel) {
|
||||||
|
failServiceHandoff(
|
||||||
|
handoff.id,
|
||||||
|
getMissingRoleChannelMessage('reviewer'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handoffChannel = reviewerChannel;
|
||||||
} else if (handoffRole === 'arbiter') {
|
} else if (handoffRole === 'arbiter') {
|
||||||
handoffChannel =
|
const arbiterChannel = findChannelByName(
|
||||||
findChannelByName(deps.channels, 'discord-review') || channel;
|
deps.channels,
|
||||||
|
getFixedRoleChannelName('arbiter'),
|
||||||
|
);
|
||||||
|
if (!arbiterChannel) {
|
||||||
|
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handoffChannel = arbiterChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runId = `handoff-${handoff.id}`;
|
const runId = `handoff-${handoff.id}`;
|
||||||
@@ -626,6 +626,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
handoffId: handoff.id,
|
handoffId: handoff.id,
|
||||||
runId,
|
runId,
|
||||||
handoffRole,
|
handoffRole,
|
||||||
|
targetRole: handoff.target_role ?? null,
|
||||||
targetServiceId: handoff.target_service_id,
|
targetServiceId: handoff.target_service_id,
|
||||||
targetAgentType: handoff.target_agent_type,
|
targetAgentType: handoff.target_agent_type,
|
||||||
reason: handoff.reason,
|
reason: handoff.reason,
|
||||||
@@ -651,14 +652,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cursorKey = resolveHandoffCursorKey(handoff.chat_jid, handoffRole);
|
||||||
const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({
|
const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({
|
||||||
id: handoff.id,
|
id: handoff.id,
|
||||||
target_service_id: handoff.target_service_id,
|
|
||||||
chat_jid: handoff.chat_jid,
|
chat_jid: handoff.chat_jid,
|
||||||
|
cursor_key: cursorKey,
|
||||||
end_seq: handoff.end_seq,
|
end_seq: handoff.end_seq,
|
||||||
});
|
});
|
||||||
if (appliedCursor) {
|
if (appliedCursor) {
|
||||||
deps.getLastAgentTimestamps()[handoff.chat_jid] = appliedCursor;
|
deps.getLastAgentTimestamps()[cursorKey] = appliedCursor;
|
||||||
|
deps.saveState();
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -668,6 +671,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
outputStatus: result.outputStatus,
|
outputStatus: result.outputStatus,
|
||||||
visiblePhase: result.visiblePhase,
|
visiblePhase: result.visiblePhase,
|
||||||
appliedCursor,
|
appliedCursor,
|
||||||
|
cursorKey:
|
||||||
|
appliedCursor != null
|
||||||
|
? resolveHandoffCursorKey(handoff.chat_jid, handoffRole)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
'Completed claimed service handoff',
|
'Completed claimed service handoff',
|
||||||
);
|
);
|
||||||
@@ -701,44 +708,39 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For paired rooms, determine the reviewer channel for correct bot routing.
|
// For paired rooms, reviewer/arbiter outputs use their fixed role bots.
|
||||||
// This is used whenever the reviewer needs to send output.
|
const reviewerChannelName = 'discord-review';
|
||||||
const reviewerChannelName =
|
|
||||||
hasReviewerLease(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code'
|
|
||||||
? 'discord'
|
|
||||||
: 'discord-review';
|
|
||||||
const foundReviewerChannel = findChannelByName(
|
const foundReviewerChannel = findChannelByName(
|
||||||
deps.channels,
|
deps.channels,
|
||||||
reviewerChannelName,
|
reviewerChannelName,
|
||||||
);
|
);
|
||||||
const reviewerChannel = foundReviewerChannel || channel;
|
|
||||||
|
|
||||||
// Arbiter always uses discord-review bot regardless of model —
|
const arbiterChannelName = 'discord-arbiter';
|
||||||
// the arbiter role is tied to the 3rd bot, not the model behind it.
|
|
||||||
const arbiterChannelName = 'discord-review';
|
|
||||||
const foundArbiterChannel = findChannelByName(
|
const foundArbiterChannel = findChannelByName(
|
||||||
deps.channels,
|
deps.channels,
|
||||||
arbiterChannelName,
|
arbiterChannelName,
|
||||||
);
|
);
|
||||||
const arbiterChannel = foundArbiterChannel || channel;
|
|
||||||
|
|
||||||
// Resolve the correct Discord channel for a given task status.
|
// Resolve the correct Discord channel for a given task status.
|
||||||
const roleToChannel: Record<string, Channel> = {
|
const roleToChannel: Record<string, Channel | null> = {
|
||||||
owner: channel,
|
owner: channel,
|
||||||
reviewer: reviewerChannel,
|
reviewer: foundReviewerChannel || null,
|
||||||
arbiter: arbiterChannel,
|
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 = (
|
const buildPendingPairedTurn = (
|
||||||
task: PairedTask,
|
task: PairedTask,
|
||||||
rawMissedMessages: NewMessage[],
|
rawMissedMessages: NewMessage[],
|
||||||
): {
|
): {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
channel: Channel;
|
channel: Channel | null;
|
||||||
cursor: string | number | null;
|
cursor: string | number | null;
|
||||||
cursorKey?: string;
|
cursorKey?: string;
|
||||||
|
role?: 'reviewer' | 'arbiter';
|
||||||
} | null => {
|
} | null => {
|
||||||
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
|
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
|
||||||
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
|
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
|
||||||
@@ -751,6 +753,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel: resolveChannel(taskStatus),
|
channel: resolveChannel(taskStatus),
|
||||||
cursor,
|
cursor,
|
||||||
cursorKey: resolveCursorKey(chatJid, taskStatus),
|
cursorKey: resolveCursorKey(chatJid, taskStatus),
|
||||||
|
role: 'reviewer',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,6 +763,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel: resolveChannel(taskStatus),
|
channel: resolveChannel(taskStatus),
|
||||||
cursor,
|
cursor,
|
||||||
cursorKey: resolveCursorKey(chatJid, taskStatus),
|
cursorKey: resolveCursorKey(chatJid, taskStatus),
|
||||||
|
role: 'arbiter',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,10 +780,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
|
|
||||||
const executePendingPairedTurn = async (args: {
|
const executePendingPairedTurn = async (args: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
channel: Channel;
|
channel: Channel | null;
|
||||||
cursor: string | number | null;
|
cursor: string | number | null;
|
||||||
cursorKey?: string;
|
cursorKey?: string;
|
||||||
|
role?: 'reviewer' | 'arbiter';
|
||||||
}): Promise<boolean> => {
|
}): 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) {
|
if (args.cursor != null) {
|
||||||
advanceLastAgentCursor(
|
advanceLastAgentCursor(
|
||||||
deps.getLastAgentTimestamps(),
|
deps.getLastAgentTimestamps(),
|
||||||
@@ -796,6 +813,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
channel: args.channel,
|
channel: args.channel,
|
||||||
|
deliveryRole: args.role,
|
||||||
startSeq: null,
|
startSeq: null,
|
||||||
endSeq: null,
|
endSeq: null,
|
||||||
});
|
});
|
||||||
@@ -808,10 +826,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
{
|
{
|
||||||
reviewerChannelName,
|
reviewerChannelName,
|
||||||
foundChannel: foundReviewerChannel?.name ?? null,
|
foundChannel: foundReviewerChannel?.name ?? null,
|
||||||
usingChannel: reviewerChannel.name,
|
|
||||||
arbiterChannelName,
|
arbiterChannelName,
|
||||||
foundArbiterChannel: foundArbiterChannel?.name ?? null,
|
foundArbiterChannel: foundArbiterChannel?.name ?? null,
|
||||||
usingArbiterChannel: arbiterChannel.name,
|
|
||||||
availableChannels: deps.channels.map((c) => c.name),
|
availableChannels: deps.channels.map((c) => c.name),
|
||||||
},
|
},
|
||||||
'Paired room reviewer/arbiter channel resolution',
|
'Paired room reviewer/arbiter channel resolution',
|
||||||
@@ -828,7 +844,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const pendingTask = hasReviewerLease(chatJid)
|
const pendingTask = hasReviewerLease(chatJid)
|
||||||
? getLatestOpenPairedTaskForChat(chatJid)
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
: null;
|
: 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(
|
const delivered = await deliverOpenWorkItem(
|
||||||
deliveryChannel,
|
deliveryChannel,
|
||||||
openWorkItem,
|
openWorkItem,
|
||||||
@@ -999,6 +1033,28 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
const startSeq = missedMessages[0].seq ?? null;
|
const startSeq = missedMessages[0].seq ?? null;
|
||||||
const endSeq = missedMessages[missedMessages.length - 1].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) {
|
if (endSeq !== null) {
|
||||||
advanceLastAgentCursor(
|
advanceLastAgentCursor(
|
||||||
deps.getLastAgentTimestamps(),
|
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 hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
|
||||||
const { deliverySucceeded, visiblePhase } = await executeTurn({
|
const { deliverySucceeded, visiblePhase } = await executeTurn({
|
||||||
group,
|
group,
|
||||||
@@ -1025,6 +1072,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
channel: turnChannel,
|
channel: turnChannel,
|
||||||
|
deliveryRole: pendingTaskForChannel ? turnRole : undefined,
|
||||||
startSeq,
|
startSeq,
|
||||||
endSeq,
|
endSeq,
|
||||||
hasHumanMessage: hasHumanMsg,
|
hasHumanMessage: hasHumanMsg,
|
||||||
|
|||||||
@@ -55,21 +55,29 @@ const group: RegisteredGroup = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ownerContext: RoomRoleContext = {
|
const ownerContext: RoomRoleContext = {
|
||||||
serviceId: 'codex-main',
|
serviceId: config.CODEX_MAIN_SERVICE_ID,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
ownerServiceId: 'codex-main',
|
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
|
||||||
reviewerServiceId: 'codex-review',
|
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
failoverOwner: false,
|
failoverOwner: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reviewerContext: RoomRoleContext = {
|
const reviewerContext: RoomRoleContext = {
|
||||||
serviceId: 'codex-review',
|
serviceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
ownerServiceId: 'codex-main',
|
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
|
||||||
reviewerServiceId: 'codex-review',
|
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
failoverOwner: false,
|
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 {
|
function createCanonicalRepoWithCommit(commitMessage: string): string {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
|
||||||
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
|
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
|
||||||
@@ -103,8 +111,8 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
|
||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
plan_notes: null,
|
plan_notes: null,
|
||||||
@@ -181,7 +189,12 @@ describe('paired execution context', () => {
|
|||||||
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
||||||
expect(db.createPairedTask).toHaveBeenCalledWith(
|
expect(db.createPairedTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
|
||||||
|
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||||
status: 'active',
|
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({
|
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', () => {
|
it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => {
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ARBITER_DEADLOCK_THRESHOLD,
|
ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
|
CLAUDE_SERVICE_ID,
|
||||||
|
CODEX_MAIN_SERVICE_ID,
|
||||||
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
PAIRED_MAX_ROUND_TRIPS,
|
PAIRED_MAX_ROUND_TRIPS,
|
||||||
|
REVIEWER_AGENT_TYPE,
|
||||||
isArbiterEnabled,
|
isArbiterEnabled,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +31,14 @@ import {
|
|||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
import type {
|
import type {
|
||||||
|
AgentType,
|
||||||
PairedRoomRole,
|
PairedRoomRole,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedWorkspace,
|
PairedWorkspace,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
RoomRoleContext,
|
RoomRoleContext,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { resolveRoleAgentPlan } from './role-agent-plan.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reviewer verdict detection
|
// Reviewer verdict detection
|
||||||
@@ -158,6 +165,21 @@ function ensurePairedProject(
|
|||||||
return group.workDir;
|
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
|
// ensureActiveTask
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,12 +207,29 @@ function ensureActiveTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
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 = {
|
const task: PairedTask = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
owner_service_id: roomRoleContext.ownerServiceId,
|
owner_service_id: ownerServiceShadow,
|
||||||
reviewer_service_id: roomRoleContext.reviewerServiceId,
|
reviewer_service_id: reviewerServiceShadow,
|
||||||
|
owner_agent_type: rolePlan.ownerAgentType,
|
||||||
|
reviewer_agent_type: rolePlan.reviewerAgentType,
|
||||||
|
arbiter_agent_type: rolePlan.arbiterAgentType,
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
||||||
plan_notes: null,
|
plan_notes: null,
|
||||||
|
|||||||
@@ -18,6 +18,79 @@ async function loadModules() {
|
|||||||
return { db, manager };
|
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', () => {
|
describe('paired workspace manager', () => {
|
||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
let previousDataDir: string | undefined;
|
let previousDataDir: string | undefined;
|
||||||
@@ -90,6 +163,9 @@ describe('paired workspace manager', () => {
|
|||||||
expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe(
|
expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
|
||||||
|
).toBe(ownerBranchName('paired-room'));
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
@@ -647,4 +723,204 @@ describe('paired workspace manager', () => {
|
|||||||
),
|
),
|
||||||
).toBe('owner changed again\n');
|
).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 });
|
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[] {
|
function listGitPaths(repoDir: string, args: string[]): string[] {
|
||||||
const output = execFileSync('git', args, {
|
const output = execFileSync('git', args, {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -415,6 +553,13 @@ export function provisionOwnerWorkspaceForPairedTask(
|
|||||||
): PairedWorkspace {
|
): PairedWorkspace {
|
||||||
const { task, canonicalWorkDir } = getTaskAndProject(taskId);
|
const { task, canonicalWorkDir } = getTaskAndProject(taskId);
|
||||||
ensureGitRepository(canonicalWorkDir);
|
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
|
// Use a stable per-channel path (not per-task) so the Claude SDK
|
||||||
// recognizes it as the same project across tasks → session persists.
|
// recognizes it as the same project across tasks → session persists.
|
||||||
@@ -425,19 +570,70 @@ export function provisionOwnerWorkspaceForPairedTask(
|
|||||||
'owner',
|
'owner',
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
|
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
|
||||||
|
repairOwnerWorktreeRegistration(workspaceDir, canonicalWorkDir);
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(workspaceDir, '.git'))) {
|
const workspaceGitPath = path.join(workspaceDir, '.git');
|
||||||
runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalWorkDir);
|
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(
|
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 {
|
} else {
|
||||||
// Worktree exists — pull latest changes from canonical repo
|
ensureGitRepository(workspaceDir);
|
||||||
try {
|
|
||||||
runGit(['checkout', '--detach', 'HEAD'], workspaceDir);
|
const currentBranch = resolveBranchName(workspaceDir);
|
||||||
} catch {
|
if (currentBranch === targetBranch) {
|
||||||
// Already at HEAD or detached — fine
|
// 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',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
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(
|
expect(
|
||||||
buildRoomRoleContext(
|
buildRoomRoleContext(
|
||||||
{
|
{
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
owner_service_id: 'codex-review',
|
owner_agent_type: 'claude-code',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_agent_type: 'codex',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'stale-reviewer-shadow',
|
||||||
arbiter_service_id: null,
|
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',
|
activated_at: '2026-03-28T10:00:00.000Z',
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
@@ -45,7 +78,9 @@ describe('buildRoomRoleContext', () => {
|
|||||||
serviceId: 'codex-review',
|
serviceId: 'codex-review',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
ownerServiceId: 'codex-review',
|
ownerServiceId: 'codex-review',
|
||||||
reviewerServiceId: 'codex-main',
|
reviewerServiceId: 'claude',
|
||||||
|
ownerAgentType: 'claude-code',
|
||||||
|
reviewerAgentType: 'claude-code',
|
||||||
failoverOwner: true,
|
failoverOwner: true,
|
||||||
arbiterServiceId: undefined,
|
arbiterServiceId: undefined,
|
||||||
});
|
});
|
||||||
@@ -59,6 +94,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: 'codex-review',
|
arbiter_service_id: 'codex-review',
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -83,6 +119,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -108,6 +145,7 @@ describe('buildRoomRoleContext', () => {
|
|||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import {
|
import { normalizeServiceId } from './config.js';
|
||||||
CODEX_MAIN_SERVICE_ID,
|
import { resolveLeaseServiceId } from './service-routing.js';
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
|
||||||
normalizeServiceId,
|
|
||||||
} from './config.js';
|
|
||||||
import type { PairedRoomRole, RoomRoleContext } from './types.js';
|
import type { PairedRoomRole, RoomRoleContext } from './types.js';
|
||||||
import type { EffectiveChannelLease } from './service-routing.js';
|
import type { EffectiveChannelLease } from './service-routing.js';
|
||||||
|
|
||||||
@@ -12,18 +9,16 @@ export function buildRoomRoleContext(
|
|||||||
preferredRole?: PairedRoomRole,
|
preferredRole?: PairedRoomRole,
|
||||||
): RoomRoleContext | undefined {
|
): RoomRoleContext | undefined {
|
||||||
const normalizedServiceId = normalizeServiceId(serviceId);
|
const normalizedServiceId = normalizeServiceId(serviceId);
|
||||||
const reviewerServiceId = lease.reviewer_service_id
|
const reviewerServiceId = resolveLeaseServiceId(lease, 'reviewer');
|
||||||
? normalizeServiceId(lease.reviewer_service_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!reviewerServiceId) {
|
if (!reviewerServiceId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerServiceId = normalizeServiceId(lease.owner_service_id);
|
const ownerServiceId =
|
||||||
const arbiterServiceId = lease.arbiter_service_id
|
resolveLeaseServiceId(lease, 'owner') ??
|
||||||
? normalizeServiceId(lease.arbiter_service_id)
|
normalizeServiceId(lease.owner_service_id);
|
||||||
: undefined;
|
const arbiterServiceId = resolveLeaseServiceId(lease, 'arbiter') ?? undefined;
|
||||||
|
|
||||||
const matches = {
|
const matches = {
|
||||||
owner: ownerServiceId === normalizedServiceId,
|
owner: ownerServiceId === normalizedServiceId,
|
||||||
@@ -51,9 +46,16 @@ export function buildRoomRoleContext(
|
|||||||
role,
|
role,
|
||||||
ownerServiceId,
|
ownerServiceId,
|
||||||
reviewerServiceId,
|
reviewerServiceId,
|
||||||
failoverOwner:
|
...(lease.owner_agent_type
|
||||||
ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
|
? { ownerAgentType: lease.owner_agent_type }
|
||||||
reviewerServiceId === CODEX_MAIN_SERVICE_ID,
|
: {}),
|
||||||
|
...(lease.reviewer_agent_type !== undefined
|
||||||
|
? { reviewerAgentType: lease.reviewer_agent_type }
|
||||||
|
: {}),
|
||||||
|
failoverOwner: Boolean(lease.owner_failover_active),
|
||||||
arbiterServiceId,
|
arbiterServiceId,
|
||||||
|
...(lease.arbiter_agent_type !== undefined
|
||||||
|
? { arbiterAgentType: lease.arbiter_agent_type }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getEffectiveChannelLease,
|
getEffectiveChannelLease,
|
||||||
getGlobalFailoverInfo,
|
getGlobalFailoverInfo,
|
||||||
refreshChannelOwnerCache,
|
refreshChannelOwnerCache,
|
||||||
|
resolveLeaseServiceId,
|
||||||
} from './service-routing.js';
|
} from './service-routing.js';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -21,7 +22,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('service-routing global failover', () => {
|
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', {
|
_setRegisteredGroupForTests('dc:paired', {
|
||||||
name: 'Paired Room',
|
name: 'Paired Room',
|
||||||
folder: 'paired-room',
|
folder: 'paired-room',
|
||||||
@@ -44,14 +45,47 @@ describe('service-routing global failover', () => {
|
|||||||
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
owner_service_id: 'codex-review',
|
owner_service_id: 'codex-review',
|
||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'claude',
|
||||||
|
owner_failover_active: true,
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
});
|
});
|
||||||
// Any other channel is also affected
|
// Any other channel is also affected
|
||||||
expect(getEffectiveChannelLease('dc:other')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:other')).toMatchObject({
|
||||||
owner_service_id: 'codex-review',
|
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,
|
explicit: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -80,6 +114,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -105,6 +140,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:explicit-single',
|
chat_jid: 'dc:explicit-single',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -134,6 +170,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:stored-owner-claude',
|
chat_jid: 'dc:stored-owner-claude',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,6 +189,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:stored-owner-fallback',
|
chat_jid: 'dc:stored-owner-fallback',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -170,6 +208,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:explicit-tribunal',
|
chat_jid: 'dc:explicit-tribunal',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -190,6 +229,7 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:explicit-tribunal-codex',
|
chat_jid: 'dc:explicit-tribunal-codex',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -199,7 +239,44 @@ describe('service-routing global failover', () => {
|
|||||||
chat_jid: 'dc:unregistered',
|
chat_jid: 'dc:unregistered',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
explicit: 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 {
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
ARBITER_SERVICE_ID,
|
ARBITER_SERVICE_ID,
|
||||||
CLAUDE_SERVICE_ID,
|
|
||||||
CODEX_MAIN_SERVICE_ID,
|
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
OWNER_AGENT_TYPE,
|
OWNER_AGENT_TYPE,
|
||||||
REVIEWER_SERVICE_ID_FOR_TYPE,
|
REVIEWER_AGENT_TYPE,
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
isArbiterEnabled,
|
isArbiterEnabled,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
@@ -15,17 +14,25 @@ import {
|
|||||||
getEffectiveRuntimeRoomMode,
|
getEffectiveRuntimeRoomMode,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getStoredRoomSettings,
|
getStoredRoomSettings,
|
||||||
setChannelOwnerLease,
|
|
||||||
type ChannelOwnerLeaseRow,
|
type ChannelOwnerLeaseRow,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { logger } from './logger.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 {
|
export interface EffectiveChannelLease {
|
||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
|
owner_agent_type?: AgentType;
|
||||||
|
reviewer_agent_type?: AgentType | null;
|
||||||
|
arbiter_agent_type?: AgentType | null;
|
||||||
owner_service_id: string;
|
owner_service_id: string;
|
||||||
reviewer_service_id: string | null;
|
reviewer_service_id: string | null;
|
||||||
arbiter_service_id: string | null;
|
arbiter_service_id: string | null;
|
||||||
|
owner_failover_active?: boolean;
|
||||||
activated_at: string | null;
|
activated_at: string | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
explicit: boolean;
|
explicit: boolean;
|
||||||
@@ -40,27 +47,56 @@ function normalizeLeaseRow(
|
|||||||
row: ChannelOwnerLeaseRow,
|
row: ChannelOwnerLeaseRow,
|
||||||
explicit: boolean,
|
explicit: boolean,
|
||||||
): EffectiveChannelLease {
|
): 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 {
|
return {
|
||||||
chat_jid: row.chat_jid,
|
chat_jid: row.chat_jid,
|
||||||
owner_service_id: normalizeServiceId(row.owner_service_id),
|
owner_agent_type: ownerAgentType,
|
||||||
reviewer_service_id: row.reviewer_service_id
|
reviewer_agent_type: reviewerAgentType,
|
||||||
? normalizeServiceId(row.reviewer_service_id)
|
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,
|
: null,
|
||||||
arbiter_service_id: row.arbiter_service_id
|
arbiter_service_id: arbiterAgentType
|
||||||
? normalizeServiceId(row.arbiter_service_id)
|
? resolveRoleServiceShadow('arbiter', arbiterAgentType) ??
|
||||||
: isArbiterEnabled()
|
(row.arbiter_service_id ? normalizeServiceId(row.arbiter_service_id) : null)
|
||||||
? ARBITER_SERVICE_ID
|
: row.arbiter_service_id
|
||||||
: null,
|
? normalizeServiceId(row.arbiter_service_id)
|
||||||
|
: isArbiterEnabled()
|
||||||
|
? ARBITER_SERVICE_ID
|
||||||
|
: null,
|
||||||
|
owner_failover_active: false,
|
||||||
activated_at: row.activated_at,
|
activated_at: row.activated_at,
|
||||||
reason: row.reason,
|
reason: row.reason,
|
||||||
explicit,
|
explicit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServiceIdForAgentType(agentType: AgentType): string {
|
|
||||||
return agentType === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferFallbackOwnerAgentType(
|
function inferFallbackOwnerAgentType(
|
||||||
hasClaude: boolean,
|
hasClaude: boolean,
|
||||||
hasCodex: boolean,
|
hasCodex: boolean,
|
||||||
@@ -86,34 +122,46 @@ function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
|
|||||||
|
|
||||||
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
||||||
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
||||||
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid);
|
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid) ?? 'claude-code';
|
||||||
const ownerServiceId = ownerAgentType
|
const rolePlan = resolveRoleAgentPlan({
|
||||||
? getServiceIdForAgentType(ownerAgentType)
|
paired: roomMode === 'tribunal',
|
||||||
: CLAUDE_SERVICE_ID;
|
groupAgentType: ownerAgentType,
|
||||||
|
configuredReviewer: REVIEWER_AGENT_TYPE,
|
||||||
if (roomMode === 'tribunal') {
|
configuredArbiter: ARBITER_AGENT_TYPE,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: ownerServiceId,
|
owner_agent_type: rolePlan.ownerAgentType,
|
||||||
reviewer_service_id: null,
|
reviewer_agent_type: rolePlan.reviewerAgentType,
|
||||||
arbiter_service_id: null,
|
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,
|
activated_at: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
explicit: false,
|
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 {
|
export function refreshChannelOwnerCache(force = false): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && now - lastLeaseRefreshAt < LEASE_CACHE_REFRESH_MS) {
|
if (!force && now - lastLeaseRefreshAt < LEASE_CACHE_REFRESH_MS) {
|
||||||
@@ -130,24 +178,63 @@ export function refreshChannelOwnerCache(force = false): void {
|
|||||||
export function getEffectiveChannelLease(
|
export function getEffectiveChannelLease(
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
): EffectiveChannelLease {
|
): 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) {
|
if (globalFailoverActive) {
|
||||||
|
const baseLease = getStoredOrDefaultLease(chatJid);
|
||||||
return {
|
return {
|
||||||
chat_jid: chatJid,
|
...baseLease,
|
||||||
owner_service_id: CODEX_REVIEW_SERVICE_ID,
|
owner_service_id: CODEX_REVIEW_SERVICE_ID,
|
||||||
reviewer_service_id: CODEX_MAIN_SERVICE_ID,
|
owner_failover_active: true,
|
||||||
arbiter_service_id: null,
|
|
||||||
activated_at: globalFailoverActivatedAt,
|
activated_at: globalFailoverActivatedAt,
|
||||||
reason: globalFailoverReason,
|
reason: globalFailoverReason,
|
||||||
explicit: true,
|
explicit: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
refreshChannelOwnerCache();
|
return getStoredOrDefaultLease(chatJid);
|
||||||
const row = leaseCache.get(chatJid);
|
}
|
||||||
if (row) {
|
|
||||||
return normalizeLeaseRow(row, true);
|
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(
|
export function isOwnerServiceForChat(
|
||||||
@@ -155,7 +242,7 @@ export function isOwnerServiceForChat(
|
|||||||
serviceId: string = SERVICE_ID,
|
serviceId: string = SERVICE_ID,
|
||||||
): boolean {
|
): boolean {
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
return normalizeServiceId(serviceId) === lease.owner_service_id;
|
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'owner');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isReviewerServiceForChat(
|
export function isReviewerServiceForChat(
|
||||||
@@ -163,14 +250,11 @@ export function isReviewerServiceForChat(
|
|||||||
serviceId: string = SERVICE_ID,
|
serviceId: string = SERVICE_ID,
|
||||||
): boolean {
|
): boolean {
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
return (
|
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'reviewer');
|
||||||
lease.reviewer_service_id !== null &&
|
|
||||||
normalizeServiceId(serviceId) === lease.reviewer_service_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasReviewerLease(chatJid: string): boolean {
|
export function hasReviewerLease(chatJid: string): boolean {
|
||||||
return getEffectiveChannelLease(chatJid).reviewer_service_id !== null;
|
return resolveLeaseServiceId(getEffectiveChannelLease(chatJid), 'reviewer') !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isArbiterServiceForChat(
|
export function isArbiterServiceForChat(
|
||||||
@@ -178,10 +262,7 @@ export function isArbiterServiceForChat(
|
|||||||
serviceId: string = SERVICE_ID,
|
serviceId: string = SERVICE_ID,
|
||||||
): boolean {
|
): boolean {
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
return (
|
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'arbiter');
|
||||||
lease.arbiter_service_id !== null &&
|
|
||||||
normalizeServiceId(serviceId) === lease.arbiter_service_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldServiceProcessChat(
|
export function shouldServiceProcessChat(
|
||||||
@@ -192,7 +273,7 @@ export function shouldServiceProcessChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Global failover ──────────────────────────────────────────────
|
// ── 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 globalFailoverActive = false;
|
||||||
let globalFailoverReason: string | null = null;
|
let globalFailoverReason: string | null = null;
|
||||||
@@ -204,7 +285,7 @@ export function activateCodexFailover(_chatJid: string, reason: string): void {
|
|||||||
globalFailoverActivatedAt = new Date().toISOString();
|
globalFailoverActivatedAt = new Date().toISOString();
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ reason, activatedAt: globalFailoverActivatedAt },
|
{ 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;
|
globalFailoverActive = false;
|
||||||
globalFailoverReason = null;
|
globalFailoverReason = null;
|
||||||
globalFailoverActivatedAt = 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 {
|
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 * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
import { TIMEZONE } from './config.js';
|
import { TIMEZONE } from './config.js';
|
||||||
|
import * as serviceRouting from './service-routing.js';
|
||||||
import { createTaskStatusTracker } from './task-status-tracker.js';
|
import { createTaskStatusTracker } from './task-status-tracker.js';
|
||||||
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
import * as tokenRotation from './token-rotation.js';
|
import * as tokenRotation from './token-rotation.js';
|
||||||
@@ -185,6 +194,7 @@ describe('task scheduler', () => {
|
|||||||
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false);
|
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false);
|
||||||
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1);
|
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1);
|
||||||
vi.mocked(codexTokenRotation.markCodexTokenHealthy).mockClear();
|
vi.mocked(codexTokenRotation.markCodexTokenHealthy).mockClear();
|
||||||
|
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,6 +341,146 @@ describe('task scheduler', () => {
|
|||||||
expect(enqueueTask.mock.calls[0][1]).toBe('task-single-tick');
|
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 () => {
|
it('keeps group-context tasks on the chat queue key', async () => {
|
||||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
createTask({
|
createTask({
|
||||||
|
|||||||
@@ -179,6 +179,25 @@ interface TaskExecutionContext {
|
|||||||
taskAgentType: AgentType;
|
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(
|
function resolveTaskExecutionContext(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
deps: SchedulerDependencies,
|
deps: SchedulerDependencies,
|
||||||
@@ -396,13 +415,8 @@ async function runTask(
|
|||||||
const outputText = getAgentOutputText(streamedOutput);
|
const outputText = getAgentOutputText(streamedOutput);
|
||||||
if (outputText) {
|
if (outputText) {
|
||||||
attemptResult = outputText;
|
attemptResult = outputText;
|
||||||
// In paired rooms, post cron output via reviewer bot so the
|
// Paired-room scheduler output must use the reviewer bot slot.
|
||||||
// owner treats it as a peer request and acts on it.
|
await sendScheduledMessage(deps, task.chat_jid, outputText);
|
||||||
const send =
|
|
||||||
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
|
|
||||||
? deps.sendMessageViaReviewerBot
|
|
||||||
: deps.sendMessage;
|
|
||||||
await send(task.chat_jid, outputText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamedOutput.status === 'error') {
|
if (streamedOutput.status === 'error') {
|
||||||
@@ -743,11 +757,7 @@ async function runGithubCiTask(
|
|||||||
if (check.terminal) {
|
if (check.terminal) {
|
||||||
await statusTracker.update('completed');
|
await statusTracker.update('completed');
|
||||||
if (check.completionMessage) {
|
if (check.completionMessage) {
|
||||||
const send =
|
await sendScheduledMessage(deps, task.chat_jid, check.completionMessage);
|
||||||
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
|
|
||||||
? deps.sendMessageViaReviewerBot
|
|
||||||
: deps.sendMessage;
|
|
||||||
await send(task.chat_jid, check.completionMessage);
|
|
||||||
}
|
}
|
||||||
deleteTask(task.id);
|
deleteTask(task.id);
|
||||||
completedAndDeleted = true;
|
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. */
|
/** Get the current active token. */
|
||||||
export function getCurrentToken(): string | undefined {
|
export function getCurrentToken(): string | undefined {
|
||||||
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
refreshRuntimeTokenSelection();
|
||||||
return tokens[currentIndex % tokens.length]?.token;
|
return tokens[currentIndex % tokens.length]?.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,9 +216,11 @@ export function rotateToken(
|
|||||||
export function markTokenHealthy(): void {
|
export function markTokenHealthy(): void {
|
||||||
if (tokens.length === 0) return;
|
if (tokens.length === 0) return;
|
||||||
const state = tokens[currentIndex];
|
const state = tokens[currentIndex];
|
||||||
|
let updated = false;
|
||||||
if (state?.rateLimitedUntil) {
|
if (state?.rateLimitedUntil) {
|
||||||
const previousCooldownUntil = state.rateLimitedUntil;
|
const previousCooldownUntil = state.rateLimitedUntil;
|
||||||
state.rateLimitedUntil = null;
|
state.rateLimitedUntil = null;
|
||||||
|
updated = true;
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
transition: 'rotation:clear-rate-limit',
|
transition: 'rotation:clear-rate-limit',
|
||||||
@@ -192,8 +229,9 @@ export function markTokenHealthy(): void {
|
|||||||
},
|
},
|
||||||
'Cleared Claude token rate-limit state after successful response',
|
'Cleared Claude token rate-limit state after successful response',
|
||||||
);
|
);
|
||||||
saveState();
|
|
||||||
}
|
}
|
||||||
|
refreshRuntimeTokenSelection();
|
||||||
|
if (updated) saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of configured tokens. */
|
/** Number of configured tokens. */
|
||||||
@@ -209,6 +247,7 @@ export function getAllTokens(): {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isRateLimited: boolean;
|
isRateLimited: boolean;
|
||||||
}[] {
|
}[] {
|
||||||
|
refreshRuntimeTokenSelection();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return tokens.map((t, i) => ({
|
return tokens.map((t, i) => ({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -244,6 +283,7 @@ export function getTokenRotationInfo(): {
|
|||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
rateLimited: number;
|
rateLimited: number;
|
||||||
} {
|
} {
|
||||||
|
refreshRuntimeTokenSelection();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return {
|
return {
|
||||||
total: tokens.length,
|
total: tokens.length,
|
||||||
@@ -258,6 +298,7 @@ export function hasAvailableClaudeToken(): boolean {
|
|||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
||||||
}
|
}
|
||||||
|
refreshRuntimeTokenSelection();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return tokens.some(
|
return tokens.some(
|
||||||
(token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now,
|
(token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now,
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ export interface RoomRoleContext {
|
|||||||
role: PairedRoomRole;
|
role: PairedRoomRole;
|
||||||
ownerServiceId: string;
|
ownerServiceId: string;
|
||||||
reviewerServiceId: string;
|
reviewerServiceId: string;
|
||||||
|
ownerAgentType?: AgentType;
|
||||||
|
reviewerAgentType?: AgentType | null;
|
||||||
failoverOwner: boolean;
|
failoverOwner: boolean;
|
||||||
arbiterServiceId?: string;
|
arbiterServiceId?: string;
|
||||||
|
arbiterAgentType?: AgentType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairedProject {
|
export interface PairedProject {
|
||||||
@@ -64,6 +67,9 @@ export interface PairedTask {
|
|||||||
group_folder: string;
|
group_folder: string;
|
||||||
owner_service_id: string;
|
owner_service_id: string;
|
||||||
reviewer_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;
|
title: string | null;
|
||||||
source_ref: string | null;
|
source_ref: string | null;
|
||||||
plan_notes: string | null;
|
plan_notes: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user