Merge pull request #5 from phj1081/dev

Stabilize paired runtime and promote dev fixes to main
This commit is contained in:
Eyejoker
2026-04-20 13:34:44 +09:00
committed by GitHub
54 changed files with 2573 additions and 194 deletions

View File

@@ -2,6 +2,13 @@
이 문서는 EJClaw의 주요 변경 사항을 기록합니다.
## [0.2.2] - 2026-04-15
### Fixed
- CI watcher 완료 뒤 reviewer가 끝난 상태에서 owner 후속 흐름이 이어지지 않던 문제 수정
- `merge_ready` 상태에서 새 human message가 오면 이전 task를 supersede 처리해, 직전 owner final이나 turn output이 다음 질문으로 누출되며 한 턴 늦게 보이던 문제 수정
## [0.2.1] - 2026-04-13
### Changed

View File

@@ -1,6 +1,6 @@
# EJClaw
![Version](https://img.shields.io/badge/version-0.2.1-blue)
![Version](https://img.shields.io/badge/version-0.2.2-blue)
![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.101-blueviolet)
![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.120.0-green)
![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black)

View File

@@ -1,6 +1,6 @@
{
"name": "ejclaw",
"version": "0.2.1",
"version": "0.2.2",
"description": "Discord tribunal multi-agent assistant with owner, reviewer, and arbiter roles.",
"packageManager": "bun@1.3.11",
"type": "module",

View File

@@ -3,6 +3,7 @@
You are the **reviewer** in this paired room.
- Your role: review, challenge, verify the owner's work. When you find issues, tell the owner exactly what to fix — the owner is the implementer, not you
- Do not stop at rebuttal. If the owner's approach is viable but clearly suboptimal, suggest 1-2 better alternatives with the reason and tradeoff for each
- The owner's role: implement, execute, respond to user requests
- Do not infer role from the visible bot name — use the paired-room role context for this turn
- When the arbiter renders a verdict (PROCEED/REVISE/RESET), follow it — the arbiter's judgment is binding
@@ -17,6 +18,7 @@ Before accepting any proposal, run it through:
4. **Hidden assumptions** — What are we taking for granted that could be wrong?
Push back with evidence when the owner is wrong. Hold your ground when you are right. Point out logical gaps, missing edge cases, over-engineering. Agree when the owner is genuinely correct.
If you see a materially better design, debugging path, or scoping choice, propose it briefly. Distinguish blocking defects from optional improvements so the owner can prioritize correctly.
## Completion status
@@ -34,7 +36,8 @@ Push back with evidence when the owner is wrong. Hold your ground when you are r
- Treat `EJCLAW_WORK_DIR` as the canonical verification root for this turn. You may inspect other local paths for context, but final review findings must be re-checked against `EJCLAW_WORK_DIR`
- Do not use a different clone, canonical repo path, or cached session path as the sole basis for `BLOCKED`, `DONE_WITH_CONCERNS`, or change requests. If another path disagrees with `EJCLAW_WORK_DIR`, prefer `EJCLAW_WORK_DIR` and explicitly call out the mismatch
- When test/typecheck/build evidence is needed, prefer the dedicated verification path (`run_verification`) over assuming the reviewer workspace should execute the full project locally
- Separate correctness issues from improvement ideas. If something is only a better alternative, label it as optional instead of blocking the owner unnecessarily
- Stagnation: **Spinning** (same error 3+), **Oscillation** (alternating approaches), **Diminishing returns** (shrinking improvement), **No progress** (discussion without change) — name the pattern and report: **Status**, **Attempted**, **Recommendation**
- Implementation, commits, and pushes require agreement from both sides. Either can veto
- Keep reviews concise — approve quickly when there is nothing to critique
- Keep reviews concise — approve quickly when there is nothing to critique, and keep alternative proposals short and actionable
- Never mention or tag the user (@username) during the owner↔reviewer loop — the system handles escalation automatically. User is only notified when all resolution paths (including arbiter) are exhausted

View File

@@ -5,7 +5,7 @@
"": {
"name": "ejclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
"@anthropic-ai/claude-agent-sdk": "0.2.114",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
@@ -18,7 +18,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.101", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-LwDQ6ueXnd1EVJbP0XICUO5en4FYJ8Cv/98/+RofoRr4l1cdAdYn1/n758DrTeGkMvTqb4lbdyMNs0RnT7mtoA=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
@@ -26,38 +42,6 @@
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],

View File

@@ -10,7 +10,7 @@
"start": "bun dist/index.js"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
"@anthropic-ai/claude-agent-sdk": "0.2.114",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",

View File

@@ -0,0 +1,146 @@
/**
* Resolves the path to the Claude Code native CLI binary bundled with
* `@anthropic-ai/claude-agent-sdk`.
*
* SDK 0.2.113+ ships a per-platform native binary via optional dependencies.
* Its internal auto-detection (W7() in sdk.mjs) tries the linux-x64-musl
* package BEFORE linux-x64, so on glibc hosts where bun has installed the musl
* package as an empty shell (no executable binary) the SDK throws:
*
* "Claude Code native binary not found at .../claude-agent-sdk-linux-x64-musl/claude"
*
* By resolving the bundled binary path explicitly we:
* - Skip the SDK's unreliable musl/glibc auto-detection.
* - Keep EJClaw self-contained (no dependency on a host `claude` install).
*
* Override: set `EJCLAW_CLAUDE_CLI_PATH` to a custom absolute binary path.
*/
import fs from 'fs';
import { createRequire } from 'module';
import path from 'path';
const ENV_OVERRIDE = 'EJCLAW_CLAUDE_CLI_PATH';
interface PlatformCandidate {
pkg: string;
file: string;
}
function platformCandidates(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): PlatformCandidate[] {
if (platform === 'linux') {
// glibc-first: the SDK gets this wrong (musl-first) — we correct it here.
return [
{ pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}`, file: 'claude' },
{
pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl`,
file: 'claude',
},
];
}
if (platform === 'darwin') {
return [
{
pkg: `@anthropic-ai/claude-agent-sdk-darwin-${arch}`,
file: arch === 'arm64' ? 'cli' : 'cli',
},
];
}
if (platform === 'win32') {
return [
{
pkg: `@anthropic-ai/claude-agent-sdk-win32-${arch}`,
file: 'cli.exe',
},
];
}
return [];
}
/**
* Returns an absolute path to the bundled Claude Code CLI binary.
*
* Resolution order:
* 1. `EJCLAW_CLAUDE_CLI_PATH` env var (if set and file exists).
* 2. Platform-appropriate bundled binary under
* `runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-*`.
*
* Throws if no binary can be found — callers must not fall back silently.
*/
export function resolveBundledClaudeCodeExecutable(options?: {
/** Override platform (tests only). */
platform?: NodeJS.Platform;
/** Override arch (tests only). */
arch?: string;
/** Override env source (tests only). */
env?: NodeJS.ProcessEnv;
/** Override fs.existsSync (tests only). */
existsSync?: (p: string) => boolean;
/** Override how candidate dirs are resolved (tests only). */
resolvePackageDir?: (pkg: string) => string | null;
}): string {
const env = options?.env ?? process.env;
const existsSync = options?.existsSync ?? fs.existsSync;
const platform = options?.platform ?? process.platform;
const arch = options?.arch ?? process.arch;
const override = env[ENV_OVERRIDE];
if (override && override.trim().length > 0) {
const resolved = path.resolve(override);
if (!existsSync(resolved)) {
throw new Error(
`${ENV_OVERRIDE} is set to "${override}" but no file exists at that path.`,
);
}
return resolved;
}
const resolvePackageDir =
options?.resolvePackageDir ?? defaultResolvePackageDir;
const candidates = platformCandidates(platform, arch);
const tried: string[] = [];
for (const { pkg, file } of candidates) {
const dir = resolvePackageDir(pkg);
if (!dir) {
tried.push(`${pkg} (package not resolvable)`);
continue;
}
const candidatePath = path.join(dir, file);
if (existsSync(candidatePath)) {
return candidatePath;
}
tried.push(candidatePath);
}
throw new Error(
`Unable to locate a bundled Claude Code CLI binary for platform=${platform} arch=${arch}. ` +
`Tried: ${tried.join(', ') || '(no candidates for this platform)'}. ` +
`Set ${ENV_OVERRIDE} to an absolute path to the CLI binary if you have a custom install.`,
);
}
/**
* Default package directory resolver. Uses `require.resolve` against this
* module's location so it works regardless of whether agent-runner is invoked
* from its own node_modules layout or via a parent workspace.
*/
function defaultResolvePackageDir(pkg: string): string | null {
try {
// Resolve the package entrypoint instead of package.json because the SDK's
// exports map does not expose `./package.json`.
const req = createRequire(import.meta.url);
const entrypoint = req.resolve(pkg);
return path.dirname(entrypoint);
} catch {
return null;
}
}
export const __test__ = {
platformCandidates,
ENV_OVERRIDE,
};

View File

@@ -24,6 +24,7 @@ import {
} from 'ejclaw-runners-shared';
import { fileURLToPath } from 'url';
import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js';
import {
prependRoomRoleHeader,
type RoomRoleContext,
@@ -88,6 +89,17 @@ function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로
// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.)
let cachedClaudeCliPath: string | null = null;
function getClaudeCliPath(): string {
if (cachedClaudeCliPath) return cachedClaudeCliPath;
cachedClaudeCliPath = resolveBundledClaudeCodeExecutable();
log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`);
return cachedClaudeCliPath;
}
// Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup
const agentAbortController = new AbortController();
process.on('SIGTERM', () => {
@@ -267,6 +279,8 @@ async function runQuery(
for await (const message of query({
prompt: stream,
options: {
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
pathToClaudeCodeExecutable: getClaudeCliPath(),
cwd: effectiveCwd,
model,
thinking,
@@ -702,6 +716,8 @@ async function main(): Promise<void> {
for await (const message of query({
prompt: trimmedPrompt,
options: {
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
pathToClaudeCodeExecutable: getClaudeCliPath(),
cwd: mainEffectiveCwd,
resume: sessionId,
systemPrompt: undefined,

View File

@@ -798,6 +798,14 @@ If folder is omitted, the host generates one automatically.`,
.enum(['claude-code', 'codex'])
.optional()
.describe('Preferred owner agent type for the room'),
reviewer_agent_type: z
.enum(['claude-code', 'codex'])
.optional()
.describe('Optional reviewer agent type override for tribunal rooms'),
arbiter_agent_type: z
.enum(['claude-code', 'codex'])
.optional()
.describe('Optional arbiter agent type override for tribunal rooms'),
folder: z
.string()
.optional()
@@ -826,6 +834,8 @@ If folder is omitted, the host generates one automatically.`,
name: string;
room_mode?: 'single' | 'tribunal';
owner_agent_type?: 'claude-code' | 'codex';
reviewer_agent_type?: 'claude-code' | 'codex';
arbiter_agent_type?: 'claude-code' | 'codex';
folder?: string;
trigger?: string;
requires_trigger?: boolean;
@@ -850,6 +860,8 @@ If folder is omitted, the host generates one automatically.`,
name: args.name,
room_mode: args.room_mode || 'single',
owner_agent_type: args.owner_agent_type,
reviewer_agent_type: args.reviewer_agent_type,
arbiter_agent_type: args.arbiter_agent_type,
folder: args.folder,
trigger: args.trigger,
requiresTrigger: args.requires_trigger,

View File

@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveBundledClaudeCodeExecutable } from '../src/bundled-cli-path.js';
describe('resolveBundledClaudeCodeExecutable', () => {
const origEnv = process.env.EJCLAW_CLAUDE_CLI_PATH;
afterEach(() => {
if (origEnv === undefined) {
delete process.env.EJCLAW_CLAUDE_CLI_PATH;
} else {
process.env.EJCLAW_CLAUDE_CLI_PATH = origEnv;
}
});
it('honors EJCLAW_CLAUDE_CLI_PATH when the file exists', () => {
const fake = '/tmp/fake-claude-binary-path';
const existsSync = (p: string): boolean => p === fake;
const result = resolveBundledClaudeCodeExecutable({
env: { EJCLAW_CLAUDE_CLI_PATH: fake },
existsSync,
platform: 'linux',
arch: 'x64',
resolvePackageDir: () => null,
});
expect(result).toBe(path.resolve(fake));
});
it('throws when env override points at a non-existent file', () => {
const existsSync = () => false;
expect(() =>
resolveBundledClaudeCodeExecutable({
env: { EJCLAW_CLAUDE_CLI_PATH: '/does/not/exist' },
existsSync,
platform: 'linux',
arch: 'x64',
resolvePackageDir: () => null,
}),
).toThrow(/EJCLAW_CLAUDE_CLI_PATH/);
});
it('prefers Linux glibc binary over musl binary', () => {
const glibcDir =
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64';
const muslDir =
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl';
const existsSync = (p: string): boolean => {
// Simulate: both dirs resolve, but only glibc has an actual binary.
return p === path.join(glibcDir, 'claude');
};
const result = resolveBundledClaudeCodeExecutable({
env: {},
existsSync,
platform: 'linux',
arch: 'x64',
resolvePackageDir: (pkg) =>
pkg === '@anthropic-ai/claude-agent-sdk-linux-x64'
? glibcDir
: pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
? muslDir
: null,
});
expect(result).toBe(path.join(glibcDir, 'claude'));
});
it('falls back to musl binary if glibc package is not present', () => {
const muslDir =
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl';
const existsSync = (p: string): boolean =>
p === path.join(muslDir, 'claude');
const result = resolveBundledClaudeCodeExecutable({
env: {},
existsSync,
platform: 'linux',
arch: 'x64',
resolvePackageDir: (pkg) =>
pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
? muslDir
: null,
});
expect(result).toBe(path.join(muslDir, 'claude'));
});
it('resolves Darwin arm64 binary under `cli`', () => {
const dir =
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64';
const existsSync = (p: string): boolean => p === path.join(dir, 'cli');
const result = resolveBundledClaudeCodeExecutable({
env: {},
existsSync,
platform: 'darwin',
arch: 'arm64',
resolvePackageDir: (pkg) =>
pkg === '@anthropic-ai/claude-agent-sdk-darwin-arm64' ? dir : null,
});
expect(result).toBe(path.join(dir, 'cli'));
});
it('resolves Windows binary under `cli.exe`', () => {
const dir =
'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64';
const binary = path.join(dir, 'cli.exe');
const existsSync = (p: string): boolean => p === binary;
const result = resolveBundledClaudeCodeExecutable({
env: {},
existsSync,
platform: 'win32',
arch: 'x64',
resolvePackageDir: (pkg) =>
pkg === '@anthropic-ai/claude-agent-sdk-win32-x64' ? dir : null,
});
expect(result).toBe(binary);
});
it('throws a descriptive error when no binary is found', () => {
const existsSync = () => false;
expect(() =>
resolveBundledClaudeCodeExecutable({
env: {},
existsSync,
platform: 'linux',
arch: 'x64',
resolvePackageDir: () => null,
}),
).toThrow(/Unable to locate a bundled Claude Code CLI binary/);
});
it('end-to-end: resolves the actual bundled binary on this host when installed', () => {
// Real-world smoke test. Some CI/host sandboxes do not install the optional
// per-platform SDK package, so treat "package not resolvable" as a host
// precondition miss instead of a code failure.
try {
const result = resolveBundledClaudeCodeExecutable({
env: {},
});
expect(fs.existsSync(result)).toBe(true);
if (process.platform === 'linux') {
expect(result).not.toMatch(/linux-x64-musl/);
}
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/Unable to locate a bundled Claude Code CLI binary/,
);
}
});
});

View File

@@ -1246,6 +1246,40 @@ describe('paired task state', () => {
expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2);
});
it('preserves explicit created_at when inserting a paired turn output', () => {
createPairedTask({
id: 'paired-task-turn-output-created-at',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
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',
});
insertPairedTurnOutput(
'paired-task-turn-output-created-at',
0,
'owner',
'carried forward owner final',
'2026-03-28T00:01:23.000Z',
);
const outputs = getPairedTurnOutputs('paired-task-turn-output-created-at');
expect(outputs).toHaveLength(1);
expect(outputs[0].created_at).toBe('2026-03-28T00:01:23.000Z');
});
it('fails init when paired task agent and service metadata conflict', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-shadow-');
const dbPath = path.join(tempDir, 'messages.db');
@@ -2396,6 +2430,28 @@ describe('room assignment writes', () => {
});
});
it('includes arbiter-only room agent overrides in capability views', () => {
assignRoom('dc:arbiter-only-capability', {
name: 'Arbiter Only Capability',
roomMode: 'tribunal',
ownerAgentType: 'claude-code',
reviewerAgentType: 'claude-code',
arbiterAgentType: 'codex',
folder: 'arbiter-only-capability',
});
expect(
getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort(),
).toEqual(['claude-code', 'codex']);
expect(
getAllRoomBindings('codex')['dc:arbiter-only-capability'],
).toMatchObject({
name: 'Arbiter Only Capability',
folder: 'arbiter-only-capability',
agentType: 'codex',
});
});
it('updates room_settings-backed metadata across tribunal capability views', () => {
assignRoom('dc:projection-room', {
name: 'Projection Room',

View File

@@ -57,6 +57,7 @@ export {
getRouterStateForService,
getSession,
getSessionForAgentType,
getStoredRoomRoleAgentPlan,
getStoredRoomSettings,
getTaskById,
getTasksForGroup,
@@ -74,6 +75,7 @@ export {
} from './db/runtime-tasks-rooms.js';
export {
_clearPairedTurnReservationsForTests,
cancelPairedTurn,
claimPairedTurnReservation,
claimServiceHandoff,
clearChannelOwnerLease,

View File

@@ -118,6 +118,7 @@ export function applyBaseSchema(database: Database): void {
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
owner_failure_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,

View File

@@ -35,6 +35,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 8, name: 'paired_task_schema_cleanup' },
{ version: 9, name: 'paired_workspace_project_schema_cleanup' },
{ version: 10, name: 'paired_turn_provenance_upgrade' },
{ version: 11, name: 'owner_failure_count' },
];
}

View File

@@ -0,0 +1,23 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const OWNER_FAILURE_COUNT_MIGRATION: SchemaMigrationDefinition = {
version: 11,
name: 'owner_failure_count',
apply(database: Database) {
if (!tableHasColumn(database, 'paired_tasks', 'owner_failure_count')) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN owner_failure_count INTEGER NOT NULL DEFAULT 0
`);
}
database.exec(`
UPDATE paired_tasks
SET owner_failure_count = 0
WHERE owner_failure_count IS NULL
`);
},
};

View File

@@ -10,6 +10,7 @@ import { RUNTIME_SERVICE_METADATA_MIGRATION } from './007_runtime-service-metada
import { PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION } from './008_paired-task-schema-cleanup.js';
import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js';
import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js';
import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
import type {
SchemaMigrationArgs,
SchemaMigrationDefinition,
@@ -28,6 +29,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION,
PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION,
PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION,
OWNER_FAILURE_COUNT_MIGRATION,
];
function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -50,6 +50,7 @@ export type PairedTaskUpdates = Partial<
| 'plan_notes'
| 'review_requested_at'
| 'round_trip_count'
| 'owner_failure_count'
| 'status'
| 'arbiter_verdict'
| 'arbiter_requested_at'
@@ -171,6 +172,7 @@ export function createPairedTaskInDatabase(
plan_notes,
review_requested_at,
round_trip_count,
owner_failure_count,
status,
arbiter_verdict,
arbiter_requested_at,
@@ -178,7 +180,7 @@ export function createPairedTaskInDatabase(
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
@@ -195,6 +197,7 @@ export function createPairedTaskInDatabase(
task.plan_notes,
task.review_requested_at,
task.round_trip_count,
task.owner_failure_count ?? 0,
task.status,
task.arbiter_verdict,
task.arbiter_requested_at,
@@ -279,6 +282,10 @@ export function updatePairedTaskInDatabase(
fields.push('round_trip_count = ?');
values.push(updates.round_trip_count);
}
if (updates.owner_failure_count !== undefined) {
fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
@@ -338,6 +345,10 @@ export function updatePairedTaskIfUnchangedInDatabase(
fields.push('round_trip_count = ?');
values.push(updates.round_trip_count);
}
if (updates.owner_failure_count !== undefined) {
fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);

View File

@@ -11,6 +11,7 @@ export function insertPairedTurnOutputInDatabase(
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
): void {
if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
logger.warn(
@@ -36,7 +37,7 @@ export function insertPairedTurnOutputInDatabase(
turnNumber,
role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
new Date().toISOString(),
createdAt ?? new Date().toISOString(),
);
}

View File

@@ -463,6 +463,20 @@ export function failPairedTurnInDatabase(
});
}
export function cancelPairedTurnInDatabase(
database: Database,
args: {
turnIdentity: PairedTurnIdentity;
error?: string | null;
},
): void {
markPairedTurnTerminalStateInDatabase(database, {
turnIdentity: args.turnIdentity,
state: 'cancelled',
error: args.error,
});
}
export function getPairedTurnByIdFromDatabase(
database: Database,
turnId: string,

View File

@@ -1,8 +1,16 @@
import { Database } from 'bun:sqlite';
import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
import {
ARBITER_AGENT_TYPE,
OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE,
} from '../config.js';
import { isValidGroupFolder } from '../group-folder.js';
import { logger } from '../logger.js';
import {
type RoleAgentPlan,
resolveRoleAgentPlan,
} from '../role-agent-plan.js';
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
export type RoomModeSource = 'explicit' | 'inferred';
@@ -58,8 +66,12 @@ export interface LegacyRoomMigrationPlan {
export interface SyncRoomRoleOverridesOptions {
ownerAgentConfig?: RegisteredGroup['agentConfig'];
ownerCreatedAt?: string;
reviewerAgentType?: AgentType;
reviewerAgentConfig?: RegisteredGroup['agentConfig'];
reviewerCreatedAt?: string;
arbiterAgentType?: AgentType | null;
arbiterAgentConfig?: RegisteredGroup['agentConfig'];
arbiterCreatedAt?: string;
updatedAt?: string;
}
@@ -207,14 +219,35 @@ export function getStoredRoomRowsFromDatabase(
.filter((row): row is StoredRoomSettings => Boolean(row));
}
export function resolveStoredRoomRoleAgentPlan(
database: Database,
stored: StoredRoomSettings,
): RoleAgentPlan {
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
const ownerAgentType =
stored.ownerAgentType ??
overrides.get('owner')?.agentType ??
OWNER_AGENT_TYPE;
return resolveRoleAgentPlan({
paired: stored.roomMode === 'tribunal',
groupAgentType: ownerAgentType,
configuredReviewer:
overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE,
configuredArbiter:
overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE,
});
}
export function buildRegisteredGroupFromStoredSettings(
database: Database,
stored: StoredRoomSettings,
requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
const ownerAgentType =
stored.ownerAgentType ||
rolePlan.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
@@ -233,8 +266,12 @@ export function buildRegisteredGroupFromStoredSettings(
return undefined;
}
const role: 'owner' | 'reviewer' =
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer';
const role: 'owner' | 'reviewer' | 'arbiter' =
ownerAgentType === resolvedAgentType
? 'owner'
: rolePlan.reviewerAgentType === resolvedAgentType
? 'reviewer'
: 'arbiter';
const capabilityMetadata = getStoredRoomRoleOverrideRows(
database,
stored.chatJid,
@@ -273,24 +310,17 @@ export function resolveStoredRoomCapabilityTypes(
database: Database,
stored: StoredRoomSettings,
): AgentType[] {
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
const ownerAgentType =
stored.ownerAgentType ??
overrides.get('owner')?.agentType ??
OWNER_AGENT_TYPE;
if (stored.modeSource !== 'explicit') {
return inferStoredRoomCapabilityTypes(database, stored);
}
const types = new Set<AgentType>([ownerAgentType]);
if (stored.roomMode === 'tribunal') {
const reviewerAgentType =
overrides.get('reviewer')?.agentType ??
(ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE);
types.add(reviewerAgentType);
const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
const types = new Set<AgentType>([rolePlan.ownerAgentType]);
if (stored.roomMode === 'tribunal' && rolePlan.reviewerAgentType) {
types.add(rolePlan.reviewerAgentType);
}
if (stored.roomMode === 'tribunal' && rolePlan.arbiterAgentType) {
types.add(rolePlan.arbiterAgentType);
}
return [...types];
}
@@ -526,11 +556,21 @@ export function syncRoomRoleOverridesForRoom(
});
if (roomMode === 'tribunal') {
const reviewerAgentType =
const existingReviewerOverride = existingOverrides.get('reviewer');
const previousDefaultReviewerAgentType =
existingOwnerOverride?.agentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE;
const computedReviewerAgentType =
ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE;
const existingReviewerOverride = existingOverrides.get('reviewer');
const reviewerAgentType =
options.reviewerAgentType ??
(existingReviewerOverride &&
existingReviewerOverride.agentType !== previousDefaultReviewerAgentType
? existingReviewerOverride.agentType
: computedReviewerAgentType);
upsertRoomRoleOverride(database, chatJid, {
role: 'reviewer',
agentType: reviewerAgentType,
@@ -547,6 +587,40 @@ export function syncRoomRoleOverridesForRoom(
now,
updatedAt: now,
});
const existingArbiterOverride = existingOverrides.get('arbiter');
const arbiterAgentType =
options.arbiterAgentType ??
existingArbiterOverride?.agentType ??
ARBITER_AGENT_TYPE ??
null;
if (arbiterAgentType) {
upsertRoomRoleOverride(database, chatJid, {
role: 'arbiter',
agentType: arbiterAgentType,
agentConfig:
options.arbiterAgentConfig ??
(existingArbiterOverride?.agentType === arbiterAgentType
? existingArbiterOverride.agentConfig
: undefined),
createdAt:
(existingArbiterOverride?.agentType === arbiterAgentType
? existingArbiterOverride.createdAt
: undefined) ??
options.arbiterCreatedAt ??
now,
updatedAt: now,
});
} else {
database
.prepare(
`DELETE FROM room_role_overrides
WHERE chat_jid = ?
AND role = 'arbiter'`,
)
.run(chatJid);
}
} else {
database
.prepare(
@@ -555,6 +629,13 @@ export function syncRoomRoleOverridesForRoom(
AND role = 'reviewer'`,
)
.run(chatJid);
database
.prepare(
`DELETE FROM room_role_overrides
WHERE chat_jid = ?
AND role = 'arbiter'`,
)
.run(chatJid);
}
}

View File

@@ -17,6 +17,7 @@ import {
normalizeStoredAgentType,
resolveAssignedRoomFolder,
resolveStoredRoomCapabilityTypes,
resolveStoredRoomRoleAgentPlan,
syncRoomRoleOverridesForRoom,
updateStoredRoomMetadata,
upsertRoomRoleOverride,
@@ -31,6 +32,8 @@ export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
ownerAgentType?: AgentType;
reviewerAgentType?: AgentType;
arbiterAgentType?: AgentType | null;
folder?: string;
isMain?: boolean;
workDir?: string;
@@ -233,6 +236,8 @@ export function assignRoomInDatabase(
syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
ownerAgentConfig: input.ownerAgentConfig,
ownerCreatedAt: input.addedAt ?? now,
reviewerAgentType: input.reviewerAgentType,
arbiterAgentType: input.arbiterAgentType,
updatedAt: input.addedAt ?? now,
});
})();
@@ -294,6 +299,14 @@ export function getStoredRoomSettingsFromDatabase(
return getStoredRoomSettingsRowFromDatabase(database, chatJid);
}
export function getStoredRoomRoleAgentPlanFromDatabase(
database: Database,
chatJid: string,
): ReturnType<typeof resolveStoredRoomRoleAgentPlan> | undefined {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
}
function getStoredRoomModeRowFromDatabase(
database: Database,
chatJid: string,

View File

@@ -50,6 +50,7 @@ import {
insertPairedTurnOutputInDatabase,
} from './paired-turn-outputs.js';
import {
cancelPairedTurnInDatabase,
clearPairedTurnsInDatabase,
completePairedTurnInDatabase,
failPairedTurnInDatabase,
@@ -176,6 +177,13 @@ export function failPairedTurn(args: {
failPairedTurnInDatabase(requireDatabase(), args);
}
export function cancelPairedTurn(args: {
turnIdentity: PairedTurnIdentity;
error?: string | null;
}): void {
cancelPairedTurnInDatabase(requireDatabase(), args);
}
export function getPairedTurnById(
turnId: string,
): PairedTurnRecord | undefined {
@@ -292,6 +300,7 @@ export function insertPairedTurnOutput(
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
): void {
insertPairedTurnOutputInDatabase(
requireDatabase(),
@@ -299,6 +308,7 @@ export function insertPairedTurnOutput(
turnNumber,
role,
outputText,
createdAt,
);
}

View File

@@ -34,6 +34,7 @@ import {
getExplicitRoomModeFromDatabase,
getRegisteredAgentTypesForJidFromDatabase,
getRegisteredGroupFromDatabase,
getStoredRoomRoleAgentPlanFromDatabase,
getStoredRoomSettingsFromDatabase,
setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase,
@@ -354,6 +355,14 @@ export function getStoredRoomSettings(
return getStoredRoomSettingsFromDatabase(db, chatJid);
}
export function getStoredRoomRoleAgentPlan(
chatJid: string,
): ReturnType<typeof getStoredRoomRoleAgentPlanFromDatabase> {
const db = getDatabaseIfInitialized();
if (!db) return undefined;
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
}

View File

@@ -19,7 +19,7 @@ export interface HostEvidenceRequest {
export interface HostEvidenceResult {
ok: boolean;
action: HostEvidenceAction;
action: HostEvidenceAction | 'ejclaw_room_runtime';
command: string;
stdout: string;
stderr: string;

View File

@@ -565,6 +565,73 @@ describe('IPC message authorization', () => {
);
});
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
const sendMessage = vi.fn(async () => {});
const injectInboundMessage = vi.fn(async () => {});
const result = await (forwardAuthorizedIpcMessage as any)(
{
type: 'inject_inbound_message',
chatJid: 'other@g.us',
text: 'act like a human input',
senderRole: 'owner',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
},
'other-group',
false,
groups,
sendMessage,
injectInboundMessage,
);
expect(sendMessage).not.toHaveBeenCalled();
expect(injectInboundMessage).toHaveBeenCalledWith(
expect.objectContaining({
chatJid: 'other@g.us',
text: 'act like a human input',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
}),
);
expect(result).toEqual(
expect.objectContaining({
outcome: 'sent',
chatJid: 'other@g.us',
}),
);
});
it('does not inject unauthorized inbound messages for another group', async () => {
const sendMessage = vi.fn(async () => {});
const injectInboundMessage = vi.fn(async () => {});
const result = await (forwardAuthorizedIpcMessage as any)(
{
type: 'inject_inbound_message',
chatJid: 'third@g.us',
text: 'should be blocked',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
},
'other-group',
false,
groups,
sendMessage,
injectInboundMessage,
);
expect(result.outcome).toBe('blocked');
expect(sendMessage).not.toHaveBeenCalled();
expect(injectInboundMessage).not.toHaveBeenCalled();
});
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
const sendMessage = vi.fn(async () => {});
@@ -975,6 +1042,41 @@ describe('assign_room success', () => {
]);
});
it('main group can assign per-room reviewer and arbiter agent types', async () => {
await processTaskIpc(
{
type: 'assign_room',
jid: 'tribunal-role-overrides@g.us',
name: 'Tribunal Role Overrides',
room_mode: 'tribunal',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: 'claude-code',
},
'whatsapp_main',
true,
deps,
);
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject(
{
chatJid: 'tribunal-role-overrides@g.us',
roomMode: 'tribunal',
modeSource: 'explicit',
name: 'Tribunal Role Overrides',
ownerAgentType: 'claude-code',
},
);
expect(
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
).toEqual(['claude-code', 'codex']);
expect(
getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'),
).toMatchObject({
agentType: 'codex',
});
});
it('assign_room rejects request with missing fields', async () => {
await processTaskIpc(
{

View File

@@ -15,8 +15,24 @@ export async function forwardAuthorizedIpcMessage(
senderRole?: string,
runId?: string,
) => Promise<void>,
injectInboundMessage?: (payload: {
chatJid: string;
text: string;
sender?: string;
senderName?: string;
messageId?: string;
timestamp?: string;
treatAsHuman: boolean;
sourceKind?: import('./types.js').MessageSourceKind;
}) => Promise<void>,
): Promise<IpcMessageForwardResult> {
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
if (
!(
(msg.type === 'message' || msg.type === 'inject_inbound_message') &&
msg.chatJid &&
msg.text
)
) {
return { outcome: 'ignored', senderRole: msg.senderRole ?? null };
}
@@ -34,6 +50,35 @@ export async function forwardAuthorizedIpcMessage(
};
}
if (msg.type === 'inject_inbound_message') {
if (!injectInboundMessage) {
return {
outcome: 'ignored',
chatJid: msg.chatJid,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
senderRole: msg.senderRole ?? null,
};
}
await injectInboundMessage({
chatJid: msg.chatJid,
text: msg.text,
sender: msg.sender,
senderName: msg.senderName,
messageId: msg.messageId,
timestamp: msg.timestamp,
treatAsHuman: msg.treatAsHuman === true,
sourceKind: msg.sourceKind,
});
return {
outcome: 'sent',
chatJid: msg.chatJid,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
senderRole: msg.senderRole ?? null,
};
}
await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId);
return {
outcome: 'sent',

View File

@@ -241,6 +241,69 @@ export async function processTaskIpc(
break;
}
if (data.action === 'ejclaw_room_runtime') {
if (!data.chatJid) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Missing chatJid for ejclaw_room_runtime request',
});
logger.warn(
{ sourceGroup, requestId: data.requestId },
'Rejected ejclaw_room_runtime request without chatJid',
);
break;
}
if (!deps.getRoomRuntimeReport) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Room runtime reporting is not configured',
});
logger.warn(
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
);
break;
}
const report = deps.getRoomRuntimeReport({
chatJid: data.chatJid,
sourceGroup,
isMain,
});
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: true,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: JSON.stringify(report, null, 2),
stderr: '',
exitCode: 0,
});
logger.info(
{
sourceGroup,
requestId: data.requestId,
action: data.action,
chatJid: data.chatJid,
},
'Processed room runtime report request via IPC',
);
break;
}
if (!isHostEvidenceAction(data.action)) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
@@ -405,11 +468,36 @@ export async function processTaskIpc(
);
break;
}
if (
data.reviewer_agent_type !== undefined &&
data.reviewer_agent_type !== 'claude-code' &&
data.reviewer_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
'Invalid assign_room request - unknown reviewer_agent_type',
);
break;
}
if (
data.arbiter_agent_type !== undefined &&
data.arbiter_agent_type !== null &&
data.arbiter_agent_type !== 'claude-code' &&
data.arbiter_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
'Invalid assign_room request - unknown arbiter_agent_type',
);
break;
}
deps.assignRoom(data.jid, {
name: data.name,
roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type,
reviewerAgentType: data.reviewer_agent_type,
arbiterAgentType: data.arbiter_agent_type,
folder: data.folder,
isMain: data.isMain,
workDir: data.workDir,

View File

@@ -1,6 +1,65 @@
import type { AvailableGroup } from './agent-runner.js';
import type { AssignRoomInput } from './db.js';
import type { AgentType, RegisteredGroup, RoomMode } from './types.js';
import type {
AgentType,
MessageSourceKind,
PairedTask,
RegisteredGroup,
RoomMode,
} from './types.js';
export interface InjectInboundMessagePayload {
chatJid: string;
text: string;
sender?: string;
senderName?: string;
messageId?: string;
timestamp?: string;
treatAsHuman: boolean;
sourceKind?: MessageSourceKind;
}
export interface RoomRuntimeReport {
chatJid: string;
groupFolder: string;
room: RegisteredGroup | null;
queue: {
status: 'processing' | 'waiting' | 'inactive';
runPhase: string;
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
currentRunId: string | null;
runningTaskId: string | null;
processName: string | null;
ipcDir: string | null;
retryCount: number;
retryScheduledAt: number | null;
waiting: boolean;
};
latestOpenTask: Pick<
PairedTask,
| 'id'
| 'status'
| 'title'
| 'round_trip_count'
| 'updated_at'
| 'owner_agent_type'
| 'reviewer_agent_type'
| 'arbiter_agent_type'
> | null;
recentMessages: Array<{
id: string;
seq?: number;
timestamp: string;
sender: string;
senderName: string;
isFromMe: boolean;
isBotMessage: boolean;
sourceKind: MessageSourceKind;
contentPreview: string;
}>;
}
export interface IpcDeps {
sendMessage: (
@@ -19,6 +78,14 @@ export interface IpcDeps {
isMain: boolean,
availableGroups: AvailableGroup[],
) => void;
injectInboundMessage?: (
payload: InjectInboundMessagePayload,
) => Promise<void>;
getRoomRuntimeReport?: (args: {
chatJid: string;
sourceGroup: string;
isMain: boolean;
}) => RoomRuntimeReport;
}
export interface IpcMessagePayload {
@@ -27,6 +94,12 @@ export interface IpcMessagePayload {
text?: string;
senderRole?: string;
runId?: string;
sender?: string;
senderName?: string;
messageId?: string;
timestamp?: string;
treatAsHuman?: boolean;
sourceKind?: MessageSourceKind;
}
export interface IpcMessageForwardResult {
@@ -54,6 +127,8 @@ export interface TaskIpcPayload {
folder?: string;
room_mode?: RoomMode;
owner_agent_type?: AgentType;
reviewer_agent_type?: AgentType;
arbiter_agent_type?: AgentType | null;
isMain?: boolean;
workDir?: string;
scopeKind?: string;

View File

@@ -114,6 +114,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
isMain,
roomBindings,
deps.sendMessage,
deps.injectInboundMessage,
);
if (forwardResult.outcome === 'sent') {
logger.info(

View File

@@ -5,7 +5,10 @@ import type { AttemptStreamedTrigger } from './agent-attempt-retry.js';
import { runAgentProcess, type AgentOutput } from './agent-runner.js';
import { getCodexAccountCount } from './codex-token-rotation.js';
import type { PreparedPairedExecutionContext } from './paired-execution-context.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import {
shouldResetCodexSessionOnAgentFailure,
shouldResetSessionOnAgentFailure,
} from './session-recovery.js';
import type { AgentType, RegisteredGroup, RoomRoleContext } from './types.js';
export interface MessageAgentAttempt {
@@ -148,6 +151,13 @@ export async function runMessageAgentAttempt(args: {
) {
resetSessionRequested = true;
}
if (
!isClaudeCodeAgent &&
provider === 'codex' &&
shouldResetCodexSessionOnAgentFailure(output)
) {
resetSessionRequested = true;
}
if (
output.newSessionId &&
!resetSessionRequested &&
@@ -197,7 +207,9 @@ export async function runMessageAgentAttempt(args: {
? outputText.slice(0, 160)
: output.error?.slice(0, 160),
},
'Suppressed retryable Claude session failure from chat output',
provider === 'claude'
? 'Suppressed retryable Claude session failure from chat output'
: 'Suppressed retryable Codex session failure from chat output',
);
return;
}

View File

@@ -12,7 +12,11 @@ import type {
} from './agent-error-detection.js';
import type { PairedExecutionLifecycle } from './message-agent-executor-paired.js';
import type { MessageAgentAttempt } from './message-agent-executor-attempt-runner.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import {
shouldResetCodexSessionOnAgentFailure,
shouldResetSessionOnAgentFailure,
shouldRetryFreshCodexSessionOnAgentFailure,
} from './session-recovery.js';
import { getErrorMessage } from './utils.js';
type AttemptResult = 'success' | 'error';
@@ -208,6 +212,57 @@ export async function executeMessageAgentAttemptLifecycle(args: {
};
};
const isRetryableCodexSessionFailure = (
attempt: MessageAgentAttempt,
): boolean => {
if (provider !== 'codex' || attempt.sawOutput) {
return false;
}
if (attempt.retryableSessionFailureDetected === true) {
return true;
}
if (attempt.error == null) {
return false;
}
return shouldRetryFreshCodexSessionOnAgentFailure({
result: null,
error: getErrorMessage(attempt.error),
});
};
const recoverRetryableCodexSessionFailure = async (
attempt: MessageAgentAttempt,
): Promise<{
attempt: MessageAgentAttempt;
resolved: AttemptResult | null;
}> => {
if (!isRetryableCodexSessionFailure(attempt)) {
return { attempt, resolved: null };
}
clearStoredSession();
clearRoleSdkSessions();
log.warn(
'Cleared poisoned Codex session before visible output, retrying fresh session',
);
const freshAttempt = await runTrackedAttempt('codex');
if (!isRetryableCodexSessionFailure(freshAttempt)) {
return { attempt: freshAttempt, resolved: null };
}
clearStoredSession();
log.warn('Fresh Codex retry also hit a retryable session failure');
log.error('Retryable Codex session failure persisted after fresh retry');
return {
attempt: freshAttempt,
resolved: 'error',
};
};
const handlePrimaryAttemptFailure = async (
attempt: MessageAgentAttempt,
rotationMessage: string,
@@ -294,6 +349,19 @@ export async function executeMessageAgentAttemptLifecycle(args: {
);
}
if (
!isClaudeCodeAgent &&
provider === 'codex' &&
(resetSessionRequested || shouldResetCodexSessionOnAgentFailure(output))
) {
clearStoredSession();
clearRoleSdkSessions();
log.warn(
{ sessionFolder },
'Cleared poisoned Codex session after unrecoverable error',
);
}
if (output.status === 'error') {
return handlePrimaryAttemptFailure(
attempt,
@@ -354,6 +422,13 @@ export async function executeMessageAgentAttemptLifecycle(args: {
}
primaryAttempt = recoveredSessionAttempt.attempt;
const recoveredCodexSessionAttempt =
await recoverRetryableCodexSessionFailure(primaryAttempt);
if (recoveredCodexSessionAttempt.resolved) {
return recoveredCodexSessionAttempt.resolved;
}
primaryAttempt = recoveredCodexSessionAttempt.attempt;
if (primaryAttempt.error) {
return handlePrimaryAttemptFailure(
primaryAttempt,

View File

@@ -172,6 +172,17 @@ describe('message-agent-executor-rules', () => {
).toBe('pending');
});
it('resolves pending arbiter follow-up requeue after repeated owner execution failures', () => {
expect(
resolvePairedFollowUpQueueAction({
completedRole: 'owner',
executionStatus: 'failed',
sawOutput: false,
taskStatus: 'arbiter_requested',
}),
).toBe('pending');
});
it('does not request an executor-side follow-up after successful owner output moved the task to review_ready', () => {
expect(
resolvePairedFollowUpQueueAction({

View File

@@ -196,7 +196,9 @@ vi.mock('./agent-error-detection.js', async (importOriginal) => {
});
vi.mock('./session-recovery.js', () => ({
shouldResetCodexSessionOnAgentFailure: vi.fn(() => false),
shouldResetSessionOnAgentFailure: vi.fn(() => false),
shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false),
shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false),
}));
@@ -791,11 +793,30 @@ describe('runAgentForGroup room memory', () => {
}),
).resolves.toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.any(Object),
expect.any(Function),
expect.any(Function),
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
const [
effectiveGroup,
agentInput,
_registerProcess,
_onOutput,
envOverrides,
] = vi.mocked(agentRunner.runAgentProcess).mock.calls[0]!;
expect(effectiveGroup).toEqual(
expect.objectContaining({
folder: 'test-group',
agentType: 'codex',
}),
);
expect(agentInput).toEqual(
expect.objectContaining({
roomRoleContext: expect.objectContaining({
role: 'reviewer',
reviewerAgentType: 'codex',
serviceId: 'codex-review',
}),
}),
);
expect(envOverrides).toEqual(
expect.objectContaining({
EJCLAW_PAIRED_TURN_ID:
'paired-task-prep-advance:2026-04-10T00:00:00.000Z:reviewer-turn',
@@ -1155,7 +1176,7 @@ describe('runAgentForGroup room memory', () => {
},
});
expect(result).toBe('error');
expect(result).toBe('success');
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
@@ -3330,6 +3351,70 @@ describe('runAgentForGroup Claude rotation', () => {
expect(deps.clearSession).toHaveBeenCalledWith('test-claude');
});
it('drops a poisoned Codex session id before retrying a fresh session after remote compaction failure', async () => {
const group = {
...makeGroup(),
folder: 'test-codex',
agentType: 'codex' as const,
};
const deps = {
...makeDeps(),
getSessions: () => ({ 'test-codex': 'stale-codex-session-id' }),
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: null,
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(sessionRecovery.shouldRetryFreshCodexSessionOnAgentFailure)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
vi.mocked(sessionRecovery.shouldResetCodexSessionOnAgentFailure)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, input) => {
expect(input.sessionId).toBe('stale-codex-session-id');
throw new Error(
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
);
})
.mockImplementationOnce(async (_group, input, _onProcess, onOutput) => {
expect(input.sessionId).toBeUndefined();
await onOutput?.({
status: 'success',
phase: 'final',
result: 'fresh Codex retry success',
});
return {
status: 'success',
result: 'fresh Codex retry success',
newSessionId: 'fresh-codex-session-id',
};
});
const result = await runAgentForGroup(deps, {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-stale-codex-session-id-retry',
onOutput: async () => {},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(deps.clearSession).toHaveBeenCalledWith('test-codex');
});
it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => {
const outputs: string[] = [];
@@ -3590,6 +3675,19 @@ describe('runAgentForGroup Codex rotation', () => {
};
const outputs: string[] = [];
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
buildOwnerPendingPrompt,
buildPairedTurnPrompt,
buildReviewerPendingPrompt,
} from './message-runtime-prompts.js';
import type { NewMessage, PairedTurnOutput } from './types.js';
const CARRY_FORWARD_MARKER =
'[Carried forward context from the previous task: latest owner final]';
function makeHumanMessage(content: string): NewMessage {
return {
id: `msg-${content}`,
chat_jid: 'group@test',
sender: 'user@test',
sender_name: 'User',
content,
timestamp: '2026-04-20T01:00:00.000Z',
is_bot_message: false,
is_from_me: false,
};
}
function makeTurnOutput(outputText: string): PairedTurnOutput {
return {
id: 1,
task_id: 'task-1',
turn_number: 0,
role: 'owner',
output_text: outputText,
created_at: '2026-04-20T00:59:00.000Z',
};
}
describe('message-runtime-prompts carry-forward guidance', () => {
it('prepends a carry-forward warning to paired turn prompts', () => {
const prompt = buildPairedTurnPrompt({
taskId: 'task-1',
chatJid: 'group@test',
timezone: 'UTC',
missedMessages: [makeHumanMessage('새 질문')],
labeledFallbackMessages: [makeHumanMessage('새 질문')],
turnOutputs: [
makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`),
],
});
expect(
prompt.startsWith('System note:\nIf you see a message beginning with'),
).toBe(true);
expect(prompt).toContain(CARRY_FORWARD_MARKER);
expect(prompt).toContain(
'Respond only to the latest human request and the current task.',
);
});
it('prepends a carry-forward warning to reviewer pending prompts', () => {
const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test',
timezone: 'UTC',
turnOutputs: [
makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`),
],
recentHumanMessages: [makeHumanMessage('이제 새 질문')],
lastHumanMessage: '이제 새 질문',
});
expect(
prompt.startsWith('System note:\nIf you see a message beginning with'),
).toBe(true);
expect(prompt).toContain(
'Do not repeat, continue, or answer that carried-forward final directly.',
);
});
it('prepends a carry-forward warning to owner pending prompts', () => {
const prompt = buildOwnerPendingPrompt({
chatJid: 'group@test',
timezone: 'UTC',
turnOutputs: [
makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`),
],
recentHumanMessages: [makeHumanMessage('새 owner 질문')],
lastHumanMessage: '새 owner 질문',
});
expect(
prompt.startsWith('System note:\nIf you see a message beginning with'),
).toBe(true);
expect(prompt).toContain(
'Respond only to the latest human request and the current task.',
);
});
it('does not prepend the warning when there is no carried-forward turn output', () => {
const prompt = buildPairedTurnPrompt({
taskId: 'task-1',
chatJid: 'group@test',
timezone: 'UTC',
missedMessages: [makeHumanMessage('그냥 새 질문')],
labeledFallbackMessages: [makeHumanMessage('그냥 새 질문')],
turnOutputs: [makeTurnOutput('DONE\n일반 owner final')],
});
expect(
prompt.startsWith('System note:\nIf you see a message beginning with'),
).toBe(false);
});
});

View File

@@ -2,6 +2,12 @@ import { buildArbiterContextPrompt } from './arbiter-context.js';
import { formatMessages } from './router.js';
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
const CARRIED_FORWARD_OWNER_FINAL_MARKER =
'[Carried forward context from the previous task: latest owner final]';
const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note:
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
function turnOutputsToMessages(
outputs: PairedTurnOutput[],
chatJid: string,
@@ -29,6 +35,22 @@ function mergeHumanAndTurnOutputMessages(
].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean {
return outputs.some((output) =>
output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER),
);
}
function prependCarriedForwardGuidance(
prompt: string,
turnOutputs: PairedTurnOutput[],
): string {
if (!hasCarriedForwardOwnerFinal(turnOutputs)) {
return prompt;
}
return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`;
}
export function buildPairedTurnPrompt(args: {
taskId: string;
chatJid: string;
@@ -44,13 +66,16 @@ export function buildPairedTurnPrompt(args: {
const humanMessages = args.missedMessages.filter(
(message) => !message.is_bot_message,
);
return formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
humanMessages,
args.turnOutputs,
return prependCarriedForwardGuidance(
formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
humanMessages,
args.turnOutputs,
),
args.timezone,
),
args.timezone,
args.turnOutputs,
);
}
@@ -62,13 +87,16 @@ export function buildReviewerPendingPrompt(args: {
lastHumanMessage: string | null | undefined;
}): string {
if (args.turnOutputs.length > 0) {
return formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
args.recentHumanMessages,
args.turnOutputs,
return prependCarriedForwardGuidance(
formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
args.recentHumanMessages,
args.turnOutputs,
),
args.timezone,
),
args.timezone,
args.turnOutputs,
);
}
@@ -87,13 +115,16 @@ export function buildOwnerPendingPrompt(args: {
lastHumanMessage: string | null | undefined;
}): string {
if (args.turnOutputs.length > 0) {
return formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
args.recentHumanMessages,
args.turnOutputs,
return prependCarriedForwardGuidance(
formatMessages(
mergeHumanAndTurnOutputMessages(
args.chatJid,
args.recentHumanMessages,
args.turnOutputs,
),
args.timezone,
),
args.timezone,
args.turnOutputs,
);
}

View File

@@ -3,8 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
_initTestDatabase,
createPairedTask,
getLatestOpenPairedTaskForChat,
getPairedTaskById,
getPairedTurnById,
insertPairedTurnOutput,
markPairedTurnRunning,
updatePairedTaskIfUnchanged,
} from './db.js';
import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js';
@@ -13,6 +16,7 @@ import {
runQueuedGroupTurn,
} from './message-runtime-queue.js';
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import type { Channel, PairedTask, RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
@@ -23,6 +27,7 @@ function makeGroup(): RegisteredGroup {
added_at: '2026-03-30T00:00:00.000Z',
requiresTrigger: false,
agentType: 'codex',
workDir: '/repo',
};
}
@@ -343,4 +348,88 @@ describe('message-runtime-queue', () => {
}),
);
});
it('splits merge_ready owner turns into a fresh task and cancels the stale finalize turn', async () => {
const task = makeTask({
id: 'task-merge-ready-owner',
status: 'merge_ready',
updated_at: '2026-03-30T00:00:00.000Z',
});
createPairedTask(task);
insertPairedTurnOutput(task.id, 1, 'reviewer', 'DONE\nreview finished');
const staleFinalizeTurn = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'finalize-owner-turn',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity: staleFinalizeTurn,
executorServiceId: 'claude',
executorAgentType: 'claude-code',
runId: 'run-stale-finalize',
});
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
const outcome = await runQueuedGroupTurn({
chatJid: task.chat_jid,
group: makeGroup(),
runId: 'run-owner-after-merge-ready',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
timezone: 'UTC',
missedMessages: [
{
id: 'human-merge-ready-1',
chat_jid: task.chat_jid,
sender: 'user@test',
sender_name: 'User',
content: '새 질문입니다',
timestamp: '2026-03-30T00:00:02.000Z',
seq: 47,
is_bot_message: false,
},
],
task,
roleToChannel: {
owner: null,
reviewer: makeChannel(),
arbiter: null,
},
ownerChannel: makeChannel(),
lastAgentTimestamps: {},
saveState: vi.fn(),
executeTurn,
getFixedRoleChannelName: () => 'discord-review',
labelPairedSenders: (_chatJid, messages) => messages,
formatMessages: () => 'formatted prompt',
});
expect(outcome).toBe(true);
const freshTask = getLatestOpenPairedTaskForChat(task.chat_jid);
expect(freshTask).toBeDefined();
expect(freshTask?.id).not.toBe(task.id);
expect(getPairedTaskById(task.id)).toMatchObject({
status: 'completed',
completion_reason: 'superseded',
});
expect(getPairedTurnById(staleFinalizeTurn.turnId)).toMatchObject({
state: 'cancelled',
});
expect(executeTurn).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.not.stringContaining('DONE\nreview finished'),
deliveryRole: 'owner',
forcedRole: 'owner',
pairedTurnIdentity: expect.objectContaining({
taskId: freshTask?.id,
intentKind: 'owner-turn',
role: 'owner',
}),
}),
);
});
});

View File

@@ -4,6 +4,7 @@ import {
buildArbiterPromptForTask,
buildPairedTurnPrompt,
} from './message-runtime-prompts.js';
import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js';
import {
buildPendingPairedTurn,
executePendingPairedTurn,
@@ -157,16 +158,32 @@ export async function runQueuedGroupTurn(args: {
}): Promise<boolean> {
const { chatJid, group, runId, log, missedMessages, task, roleToChannel } =
args;
const taskStatus = task?.status;
let currentTask = task;
const hasHumanMsg = task
? !missedMessages.every(
(message) => message.is_from_me === true || !!message.is_bot_message,
)
: !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
const lastTurnOutputRole = task
? (getPairedTurnOutputs(task.id).at(-1)?.role ?? null)
let fallbackMessages = missedMessages;
if (currentTask && hasHumanMsg) {
const resolvedTask = resolveOwnerTaskForHumanMessage({
group,
chatJid,
existingTask: currentTask,
});
currentTask = resolvedTask.task;
if (resolvedTask.supersededTask) {
fallbackMessages = getRecentChatMessages(chatJid, 20).filter(
(message) => !message.is_bot_message,
);
}
}
const taskStatus = currentTask?.status;
const turnOutputs = currentTask ? getPairedTurnOutputs(currentTask.id) : [];
const lastTurnOutputRole = currentTask
? (turnOutputs.at(-1)?.role ?? null)
: null;
const turnRole = task
const turnRole = currentTask
? hasHumanMsg
? resolveQueuedTurnRole({
taskStatus,
@@ -203,43 +220,46 @@ export async function runQueuedGroupTurn(args: {
const turnChannel =
turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole];
const cursorKey = resolveCursorKeyForRole(chatJid, turnRole);
const forcedRole = task ? turnRole : undefined;
const queuedIntentKind = task
const forcedRole = currentTask ? turnRole : undefined;
const queuedIntentKind = currentTask
? resolveQueuedTurnReservationIntent({
task,
task: currentTask,
turnRole,
hasHumanMessage: hasHumanMsg,
})
: null;
const pairedTurnIdentity =
task && queuedIntentKind
currentTask && queuedIntentKind
? buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
taskId: currentTask.id,
taskUpdatedAt: currentTask.updated_at,
intentKind: queuedIntentKind,
role: turnRole,
})
: undefined;
let prompt: string;
if (turnRole === 'arbiter' && task) {
if (turnRole === 'arbiter' && currentTask) {
const recentMessages = getRecentChatMessages(chatJid, 20);
prompt = buildArbiterPromptForTask({
task,
task: currentTask,
chatJid,
timezone: args.timezone,
turnOutputs: getPairedTurnOutputs(task.id),
turnOutputs,
recentMessages,
labeledRecentMessages: args.labelPairedSenders(chatJid, recentMessages),
});
} else if (task) {
} else if (currentTask) {
prompt = buildPairedTurnPrompt({
taskId: task.id,
taskId: currentTask.id,
chatJid,
timezone: args.timezone,
missedMessages,
labeledFallbackMessages: args.labelPairedSenders(chatJid, missedMessages),
turnOutputs: getPairedTurnOutputs(task.id),
labeledFallbackMessages: args.labelPairedSenders(
chatJid,
fallbackMessages,
),
turnOutputs,
});
} else {
prompt = args.formatMessages(
@@ -272,19 +292,19 @@ export async function runQueuedGroupTurn(args: {
return false;
}
if (task) {
if (currentTask) {
const claimed = claimPairedTurnExecution({
chatJid,
runId,
task,
task: currentTask,
intentKind: queuedIntentKind!,
});
if (!claimed) {
log.info(
{
taskId: task.id,
taskId: currentTask.id,
taskStatus,
taskUpdatedAt: task.updated_at,
taskUpdatedAt: currentTask.updated_at,
intentKind: queuedIntentKind,
turnRole,
},
@@ -310,7 +330,7 @@ export async function runQueuedGroupTurn(args: {
chatJid,
runId,
channel: turnChannel,
deliveryRole: task ? turnRole : undefined,
deliveryRole: currentTask ? turnRole : undefined,
startSeq,
endSeq,
hasHumanMessage: hasHumanMsg,

View File

@@ -133,7 +133,7 @@ describe('message-runtime-rules', () => {
).toEqual({ kind: 'none' });
});
it('dispatches owner delivery success through a paired follow-up enqueue only for reviewer turns', () => {
it('dispatches owner delivery success through a paired follow-up enqueue for reviewer or arbiter turns', () => {
expect(
resolveFollowUpDispatch({
source: 'owner-delivery-success',
@@ -143,6 +143,15 @@ describe('message-runtime-rules', () => {
kind: 'enqueue',
queueKind: 'paired-follow-up',
});
expect(
resolveFollowUpDispatch({
source: 'owner-delivery-success',
nextTurnAction: { kind: 'arbiter-turn' },
}),
).toEqual({
kind: 'enqueue',
queueKind: 'paired-follow-up',
});
expect(
resolveFollowUpDispatch({
source: 'owner-delivery-success',
@@ -151,6 +160,21 @@ describe('message-runtime-rules', () => {
).toEqual({ kind: 'none' });
});
it('dispatches failed owner recovery through a paired follow-up when execution escalated to arbiter', () => {
expect(
resolveFollowUpDispatch({
source: 'executor-recovery',
nextTurnAction: { kind: 'arbiter-turn' },
completedRole: 'owner',
executionStatus: 'failed',
sawOutput: false,
}),
).toEqual({
kind: 'enqueue',
queueKind: 'paired-follow-up',
});
});
it('dispatches reviewer and arbiter delivery success through paired follow-up enqueue when a handoff is pending', () => {
expect(
resolveFollowUpDispatch({
@@ -291,6 +315,28 @@ describe('message-runtime-rules', () => {
expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId);
});
it('uses lease reviewer metadata when the room reviewer differs from the owner agent type', () => {
const resolution = resolveExecutionTarget({
lease: {
...baseLease,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
reviewer_service_id: 'codex-review',
},
pairedTaskStatus: 'review_ready',
groupFolder: 'group-1',
groupAgentType: 'claude-code',
});
expect(resolution).toMatchObject({
activeRole: 'reviewer',
configuredAgentType: 'codex',
effectiveAgentType: 'codex',
sessionFolder: 'group-1:reviewer',
});
expect(resolution.effectiveServiceId).toBe('codex-review');
});
it('resolves merge_ready execution target back to the owner/finalize path', () => {
const resolution = resolveExecutionTarget({
lease: baseLease,
@@ -322,8 +368,8 @@ describe('message-runtime-rules', () => {
expect(resolution).toMatchObject({
inferredRole: 'arbiter',
activeRole: 'arbiter',
configuredAgentType: 'claude-code',
effectiveAgentType: 'claude-code',
configuredAgentType: 'codex',
effectiveAgentType: 'codex',
sessionFolder: 'group-1:arbiter',
});
expect(resolution.effectiveServiceId).toBe(
@@ -331,6 +377,30 @@ describe('message-runtime-rules', () => {
);
});
it('uses lease arbiter metadata when the room arbiter differs from the owner agent type', () => {
const resolution = resolveExecutionTarget({
lease: {
...baseLease,
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
reviewer_service_id: 'claude',
arbiter_agent_type: 'claude-code',
arbiter_service_id: 'claude',
},
pairedTaskStatus: 'arbiter_requested',
groupFolder: 'group-1',
groupAgentType: 'codex',
});
expect(resolution).toMatchObject({
activeRole: 'arbiter',
configuredAgentType: 'claude-code',
effectiveAgentType: 'claude-code',
sessionFolder: 'group-1:arbiter',
});
expect(resolution.effectiveServiceId).toBe('claude');
});
it('honors an arbiter forced role only when arbiter is configured', () => {
const resolution = resolveExecutionTarget({
lease: {

View File

@@ -183,7 +183,8 @@ export function resolveFollowUpDispatch(args: {
args.source === 'owner-delivery-success' ||
args.completedRole === 'owner'
) {
return args.nextTurnAction.kind === 'reviewer-turn'
return args.nextTurnAction.kind === 'reviewer-turn' ||
args.nextTurnAction.kind === 'arbiter-turn'
? { kind: 'enqueue', queueKind: 'paired-follow-up' }
: { kind: 'none' };
}
@@ -211,6 +212,7 @@ export function resolveFollowUpDispatch(args: {
return { kind: 'none' };
}
if (
args.completedRole !== 'owner' &&
args.completedRole !== 'reviewer' &&
args.completedRole !== 'arbiter'
) {
@@ -246,12 +248,14 @@ export function resolveCursorKeyForRole(
export function resolveConfiguredRoleAgentPlan(
paired: boolean,
groupAgentType: AgentType | undefined,
configuredReviewer: AgentType = REVIEWER_AGENT_TYPE,
configuredArbiter: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): RoleAgentPlan {
return resolveRoleAgentPlan({
paired,
groupAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
configuredReviewer,
configuredArbiter,
});
}
@@ -259,8 +263,15 @@ export function resolveConfiguredRoleAgentPlan(
export function resolveEffectiveAgentType(
role: 'owner' | 'reviewer' | 'arbiter',
groupAgentType: AgentType | undefined,
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): AgentType {
const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType);
const plan = resolveConfiguredRoleAgentPlan(
role !== 'owner',
groupAgentType,
reviewerAgentType,
arbiterAgentType,
);
return resolveAgentTypeForRole(plan, role);
}
@@ -269,10 +280,17 @@ export function resolveSessionFolder(
groupFolder: string,
role: 'owner' | 'reviewer' | 'arbiter',
groupAgentType: AgentType | undefined,
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
): string {
// Arbiter always gets a separate session — must never share with owner/reviewer
if (role === 'arbiter') return `${groupFolder}:arbiter`;
const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType);
const plan = resolveConfiguredRoleAgentPlan(
role !== 'owner',
groupAgentType,
reviewerAgentType,
arbiterAgentType,
);
const effectiveType = resolveAgentTypeForRole(plan, role);
const groupDefault = plan.ownerAgentType;
if (effectiveType === groupDefault) return groupFolder;
@@ -314,13 +332,16 @@ export function resolveExecutionTarget(args: {
const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer');
const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter');
const ownerAgentType = args.lease.owner_agent_type ?? args.groupAgentType;
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
args.lease.reviewer_agent_type != null,
args.groupAgentType,
reviewerServiceId != null,
ownerAgentType,
args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE,
args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE,
);
const configuredAgentType = resolveEffectiveAgentType(
const configuredAgentType = resolveAgentTypeForRole(
roleAgentPlan,
activeRole,
args.groupAgentType,
);
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
@@ -337,7 +358,9 @@ export function resolveExecutionTarget(args: {
sessionFolder: resolveSessionFolder(
args.groupFolder,
activeRole,
args.groupAgentType,
roleAgentPlan.ownerAgentType,
roleAgentPlan.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
roleAgentPlan.arbiterAgentType ?? ARBITER_AGENT_TYPE,
),
};
}

View File

@@ -1,10 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EffectiveChannelLease } from './service-routing.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import * as pairedExecutionContext from './paired-execution-context.js';
/** Prefix helper for progress message assertions */
const P = (text: string) => `${TASK_STATUS_MESSAGE_PREFIX}${text}`;
const makeCodexLease = (chatJid: string): EffectiveChannelLease => ({
chat_jid: chatJid,
owner_agent_type: 'codex',
reviewer_agent_type: null,
arbiter_agent_type: null,
owner_service_id: 'codex',
reviewer_service_id: null,
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
});
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
writeGroupsSnapshot: vi.fn(),
@@ -34,6 +50,12 @@ vi.mock('./config.js', () => ({
vi.mock('./paired-execution-context.js', () => ({
preparePairedExecutionContext: vi.fn(() => undefined),
completePairedExecutionContext: vi.fn(),
resolveOwnerTaskForHumanMessage: vi.fn(
(args?: { existingTask?: unknown }) => ({
task: args?.existingTask ?? null,
supersededTask: null,
}),
),
}));
vi.mock('./db.js', () => {
@@ -693,6 +715,131 @@ describe('createMessageRuntime', () => {
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
});
it('suppresses a stale owner work item when a new human message supersedes a merge_ready task', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const enqueueMessageCheck = vi.fn();
const oldTask = {
id: 'task-merge-ready-old',
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',
} as any;
const newTask = {
...oldTask,
id: 'task-fresh-owner-turn',
status: 'active',
completion_reason: null,
created_at: '2026-03-30T00:00:05.000Z',
updated_at: '2026-03-30T00:00:05.000Z',
} as any;
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getOpenWorkItem).mockReturnValue({
id: 99,
group_folder: group.folder,
chat_jid: chatJid,
agent_type: 'codex',
service_id: 'claude',
delivery_role: 'owner',
status: 'delivery_retry',
start_seq: 1,
end_seq: 1,
result_payload: '이전 final입니다.',
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.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '새 작업입니다.',
timestamp: '2026-03-30T00:00:10.000Z',
seq: 10,
},
]);
vi.mocked(db.getLatestOpenPairedTaskForChat)
.mockReturnValueOnce(oldTask)
.mockReturnValue(newTask);
vi.mocked(
pairedExecutionContext.resolveOwnerTaskForHumanMessage,
).mockReturnValue({
task: newTask,
supersededTask: oldTask,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain('새 작업입니다.');
expect(input.prompt).not.toContain('이전 final입니다.');
await onOutput?.({
status: 'success',
phase: 'final',
result: 'DONE\n새 작업 처리',
output: { visibility: 'public', text: 'DONE\n새 작업 처리' },
} as any);
return {
status: 'success',
result: 'DONE\n새 작업 처리',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRoomBindings: () => ({ [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-supersede-owner-work-item',
reason: 'messages',
});
expect(result).toBe(true);
expect(db.markWorkItemDelivered).toHaveBeenCalledWith(99);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).not.toHaveBeenCalledWith(
chatJid,
'이전 final입니다.',
);
});
it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
@@ -3509,6 +3656,20 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
const notifyIdle = vi.fn();
const clearSession = vi.fn();
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid,
owner_agent_type: 'codex',
reviewer_agent_type: null,
arbiter_agent_type: null,
owner_service_id: 'codex',
reviewer_service_id: null,
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -3580,6 +3741,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
const notifyIdle = vi.fn();
const persistSession = vi.fn();
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
makeCodexLease(chatJid),
);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -3975,6 +4140,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
makeCodexLease(chatJid),
);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -4002,6 +4171,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
result: '아직 진행 중입니다.',
newSessionId: 'session-long-progress',
});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(70_000);
await vi.advanceTimersByTimeAsync(50_000);
await vi.advanceTimersByTimeAsync(3_480_000);
@@ -4604,6 +4774,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
makeCodexLease(chatJid),
);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -4713,6 +4887,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
makeCodexLease(chatJid),
);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',

View File

@@ -4,6 +4,7 @@ import {
getOpenWorkItemForChat,
getMessagesSinceSeq,
getLatestOpenPairedTaskForChat,
markWorkItemDelivered,
getPairedTaskById,
} from './db.js';
import {
@@ -39,6 +40,7 @@ import {
getProcessableMessages,
shouldSkipBotOnlyCollaboration,
} from './message-runtime-rules.js';
import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js';
import {
enqueuePairedFollowUpAfterEvent,
schedulePairedFollowUpWithMessageCheck,
@@ -345,14 +347,55 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
// Delivery retries can come from forced fallback runs whose agent_type
// differs from the room owner's registered agent type.
const openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
let pendingTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
if (
pendingTask?.status === 'merge_ready' &&
openWorkItem?.delivery_role === 'owner'
) {
const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
const preflightRawMessages = getMessagesSinceSeq(
chatJid,
sinceSeqCursor,
deps.assistantName,
);
const preflightMessages = filterLoopingPairedBotMessages(
chatJid,
getProcessableMessages(chatJid, preflightRawMessages, channel),
FAILURE_FINAL_TEXT,
);
const hasFreshHumanMessage = preflightMessages.some(
(message) => message.is_from_me !== true && !message.is_bot_message,
);
if (hasFreshHumanMessage) {
const resolvedTask = resolveOwnerTaskForHumanMessage({
group,
chatJid,
existingTask: pendingTask,
});
pendingTask = resolvedTask.task;
if (resolvedTask.supersededTask) {
markWorkItemDelivered(openWorkItem.id);
log.info(
{
chatJid,
workItemId: openWorkItem.id,
supersededTaskId: resolvedTask.supersededTask.id,
replacementTaskId: resolvedTask.task?.id ?? null,
},
'Suppressed stale owner delivery retry because a new human message superseded the merge_ready task',
);
openWorkItem = undefined;
}
}
}
const openWorkItemOutcome = await processOpenWorkItemDelivery({
chatJid,
runId,
openWorkItem,
pendingTask: hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null,
pendingTask,
channel,
roleToChannel,
log,

View File

@@ -21,26 +21,72 @@ import {
import type { PairedTask } from './types.js';
type OwnerFinalizeOutcome = 'stop' | 're_review';
const OWNER_FAILURE_ESCALATION_THRESHOLD = 2;
export function handleFailedOwnerExecution(args: {
task: PairedTask;
taskId: string;
summary?: string | null;
}): void {
const { task, taskId } = args;
const { task, taskId, summary } = args;
const now = new Date().toISOString();
const nextFailureCount = (task.owner_failure_count ?? 0) + 1;
if (nextFailureCount >= OWNER_FAILURE_ESCALATION_THRESHOLD) {
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage:
'Owner failed repeatedly without a visible verdict — requesting arbiter',
escalateLogMessage:
'Owner failed repeatedly without a visible verdict — escalating to user',
logContext: {
taskId,
role: 'owner',
previousStatus: task.status,
ownerFailureCount: nextFailureCount,
summary: summary?.slice(0, 160),
},
patch: {
owner_failure_count: nextFailureCount,
},
});
return;
}
if (task.status !== 'active') {
const now = new Date().toISOString();
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: 'active',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: nextFailureCount,
},
});
} else {
applyPairedTaskPatch({
taskId,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: nextFailureCount,
},
});
logger.info(
{ taskId, role: 'owner', previousStatus: task.status },
'Reset task to active after failed execution',
);
}
logger.info(
{
taskId,
role: 'owner',
previousStatus: task.status,
ownerFailureCount: nextFailureCount,
summary: summary?.slice(0, 160),
},
'Reset task to active after failed owner execution',
);
}
function handleOwnerFinalizeCompletion(args: {
@@ -90,6 +136,9 @@ function handleOwnerFinalizeCompletion(args: {
hasNewChanges,
summary: summary?.slice(0, 100),
},
patch: {
owner_failure_count: 0,
},
});
return 'stop';
}
@@ -102,6 +151,9 @@ function handleOwnerFinalizeCompletion(args: {
nextStatus: 'active',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: 0,
},
});
}
logger.info(
@@ -126,6 +178,7 @@ function handleOwnerFinalizeCompletion(args: {
updatedAt: now,
patch: {
completion_reason: 'done',
owner_failure_count: 0,
},
});
logger.info(
@@ -166,6 +219,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: {
updatedAt: now,
patch: {
round_trip_count: task.round_trip_count + 1,
owner_failure_count: 0,
},
});
if (hasActiveCiWatcherForChat(task.chat_jid)) {
@@ -229,6 +283,9 @@ export function handleOwnerCompletion(args: {
ownerVerdict,
summary: summary?.slice(0, 100),
},
patch: {
owner_failure_count: 0,
},
});
return;
}

View File

@@ -247,6 +247,7 @@ export function transitionPairedTaskStatus(args: {
plan_notes?: string | null;
review_requested_at?: string | null;
round_trip_count?: number;
owner_failure_count?: number;
arbiter_verdict?: string | null;
arbiter_requested_at?: string | null;
completion_reason?: string | null;
@@ -290,6 +291,7 @@ export function applyPairedTaskPatch(args: {
plan_notes?: string | null;
review_requested_at?: string | null;
round_trip_count?: number;
owner_failure_count?: number;
status?: PairedTaskStatus;
arbiter_verdict?: string | null;
arbiter_requested_at?: string | null;
@@ -324,6 +326,17 @@ export function requestArbiterOrEscalate(args: {
arbiterLogMessage: string;
escalateLogMessage: string;
logContext?: Record<string, unknown>;
patch?: {
title?: string | null;
source_ref?: string | null;
plan_notes?: string | null;
review_requested_at?: string | null;
round_trip_count?: number;
owner_failure_count?: number;
arbiter_verdict?: string | null;
arbiter_requested_at?: string | null;
completion_reason?: string | null;
};
}): boolean {
const {
taskId,
@@ -342,6 +355,7 @@ export function requestArbiterOrEscalate(args: {
expectedUpdatedAt,
updatedAt: now,
patch: {
...args.patch,
arbiter_requested_at: now,
},
});
@@ -358,6 +372,7 @@ export function requestArbiterOrEscalate(args: {
expectedUpdatedAt,
updatedAt: now,
patch: {
...args.patch,
completion_reason: 'escalated',
},
});

View File

@@ -12,11 +12,15 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => {
const updatePairedTask = vi.fn();
return {
cancelPairedTurn: vi.fn(),
createPairedTask: vi.fn(),
getLatestPairedTaskForChat: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedTaskById: vi.fn(),
getPairedTurnById: vi.fn(),
getPairedTurnOutputs: vi.fn(() => []),
getPairedWorkspace: vi.fn(),
insertPairedTurnOutput: vi.fn(),
updatePairedTask,
updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => {
updatePairedTask(id, updates);
@@ -49,6 +53,7 @@ import * as config from './config.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,
resolveOwnerTaskForHumanMessage,
} from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type {
@@ -137,6 +142,7 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
plan_notes: null,
review_requested_at: null,
round_trip_count: 0,
owner_failure_count: 0,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
@@ -247,6 +253,78 @@ describe('paired execution context', () => {
});
});
it('carries forward the latest owner final when a merge_ready task is superseded by new human input', () => {
const supersededTask = buildPairedTask({
id: 'task-superseded',
status: 'merge_ready',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
supersededTask,
);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-superseded',
turn_number: 1,
role: 'owner',
output_text: 'DONE_WITH_CONCERNS\n이전 task owner final',
created_at: '2026-03-28T00:01:00.000Z',
},
{
id: 2,
task_id: 'task-superseded',
turn_number: 2,
role: 'reviewer',
output_text: 'DONE\nreview approved',
created_at: '2026-03-28T00:02:00.000Z',
},
]);
const result = resolveOwnerTaskForHumanMessage({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
existingTask: supersededTask,
});
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(result.supersededTask).toEqual(supersededTask);
expect(db.insertPairedTurnOutput).toHaveBeenCalledWith(
expect.any(String),
0,
'owner',
'[Carried forward context from the previous task: latest owner final]\nDONE_WITH_CONCERNS\n이전 task owner final',
'2026-03-28T00:01:00.000Z',
);
});
it('uses room role context agent overrides when creating a paired task', () => {
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-room-overrides',
roomRoleContext: {
...ownerContext,
ownerAgentType: 'codex',
reviewerAgentType: 'codex',
reviewerServiceId: config.CODEX_REVIEW_SERVICE_ID,
arbiterAgentType: 'claude-code',
},
hasHumanMessage: true,
});
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.CODEX_REVIEW_SERVICE_ID,
owner_agent_type: 'codex',
reviewer_agent_type: 'codex',
arbiter_agent_type: 'claude-code',
}),
);
});
it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
preparePairedExecutionContext({
group,
@@ -559,6 +637,51 @@ describe('paired execution context', () => {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});
it('honors room-level reviewer agent overrides for unsafe host reviewer runtime env', () => {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
reviewer_agent_type: 'codex',
}),
);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
reviewer_agent_type: 'codex',
}),
);
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
autoRefreshed: false,
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-host-reviewer-room-override',
roomRoleContext: {
...reviewerContext,
reviewerAgentType: 'codex',
},
});
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
});
expect(
result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY,
).toBeUndefined();
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});
it('completePairedExecutionContext logs without error', () => {
completePairedExecutionContext({
taskId: 'task-1',
@@ -978,7 +1101,7 @@ describe('paired execution context', () => {
);
});
it('still resets owner tasks to active after failed execution', () => {
it('increments owner failure count and resets owner tasks to active after failed execution', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
@@ -996,11 +1119,39 @@ describe('paired execution context', () => {
'task-1',
expect.objectContaining({
status: 'active',
owner_failure_count: 1,
updated_at: expect.any(String),
}),
);
});
it('requests arbiter after repeated owner execution failures without a visible verdict', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
owner_failure_count: 1,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'failed',
summary:
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
owner_failure_count: 2,
arbiter_requested_at: expect.any(String),
}),
);
});
it('releases the execution lease even when a completion handler throws', () => {
const transitionError = new Error('transition failed');
vi.mocked(db.getPairedTaskById).mockReturnValue(

View File

@@ -18,12 +18,16 @@ import {
REVIEWER_AGENT_TYPE,
} from './config.js';
import {
cancelPairedTurn,
createPairedTask,
getLatestPairedTaskForChat,
getLatestOpenPairedTaskForChat,
getPairedTaskById,
getPairedTurnById,
getPairedTurnOutputs,
getPairedWorkspace,
hasActiveCiWatcherForChat,
insertPairedTurnOutput,
releasePairedTaskExecutionLease,
upsertPairedProject,
} from './db.js';
@@ -52,11 +56,15 @@ import {
prepareReviewerWorkspaceForExecution,
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import {
buildPairedTurnIdentity,
type PairedTurnIdentity,
} from './paired-turn-identity.js';
import type {
AgentType,
PairedRoomRole,
PairedTask,
PairedTurnOutput,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
@@ -97,38 +105,20 @@ function resolvePairedTaskServiceShadow(
return role === 'owner' ? CODEX_MAIN_SERVICE_ID : CODEX_REVIEW_SERVICE_ID;
}
// ---------------------------------------------------------------------------
// ensureActiveTask
// ---------------------------------------------------------------------------
function ensureActiveTask(
group: RegisteredGroup,
chatJid: string,
roomRoleContext: RoomRoleContext,
hasHumanMessage?: boolean,
): PairedTask | null {
const canonicalWorkDir = ensurePairedProject(group, chatJid);
if (!canonicalWorkDir) {
return null;
}
const existing = getLatestOpenPairedTaskForChat(chatJid);
if (existing) {
return existing;
}
// Don't create a new task for bot-only messages — prevents
// ESCALATE → completed → bot message triggers new task → loop.
if (!hasHumanMessage) {
return null;
}
function createActiveTaskForRoom(args: {
group: RegisteredGroup;
chatJid: string;
canonicalWorkDir: string;
roomRoleContext?: RoomRoleContext;
}): PairedTask {
const { group, chatJid, canonicalWorkDir, roomRoleContext } = args;
const now = new Date().toISOString();
const rolePlan = resolveRoleAgentPlan({
paired: true,
groupAgentType: group.agentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType,
configuredReviewer:
roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
configuredArbiter: roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
});
const ownerServiceShadow = resolvePairedTaskServiceShadow(
'owner',
@@ -152,6 +142,7 @@ function ensureActiveTask(
plan_notes: null,
review_requested_at: null,
round_trip_count: 0,
owner_failure_count: 0,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
@@ -172,6 +163,189 @@ function ensureActiveTask(
return task;
}
function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void {
const turnIdentity = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'finalize-owner-turn',
role: 'owner',
});
const existingTurn = getPairedTurnById(turnIdentity.turnId);
if (
!existingTurn ||
(existingTurn.state !== 'running' && existingTurn.state !== 'delegated')
) {
return;
}
cancelPairedTurn({
turnIdentity,
error:
'Superseded by a newer human message before owner finalize delivery.',
});
logger.info(
{
taskId: task.id,
turnId: turnIdentity.turnId,
turnState: existingTurn.state,
},
'Cancelled stale finalize-owner turn after a new human message superseded the merge_ready task',
);
}
function getLatestTurnOutputByRole(
taskId: string,
role: PairedRoomRole,
): PairedTurnOutput | null {
return (
[...getPairedTurnOutputs(taskId)]
.reverse()
.find((output) => output.role === role) ?? null
);
}
function carryForwardLatestOwnerFinal(args: {
sourceTask: PairedTask;
targetTask: PairedTask;
}): void {
const latestOwnerFinal = getLatestTurnOutputByRole(
args.sourceTask.id,
'owner',
);
if (!latestOwnerFinal) {
return;
}
insertPairedTurnOutput(
args.targetTask.id,
0,
'owner',
`[Carried forward context from the previous task: latest owner final]\n${latestOwnerFinal.output_text}`,
latestOwnerFinal.created_at,
);
logger.info(
{
sourceTaskId: args.sourceTask.id,
targetTaskId: args.targetTask.id,
carriedChars: latestOwnerFinal.output_text.length,
},
'Carried forward latest owner final into superseding paired task',
);
}
export interface ResolvedOwnerHumanTask {
task: PairedTask | null;
supersededTask: PairedTask | null;
}
export function resolveOwnerTaskForHumanMessage(args: {
group: RegisteredGroup;
chatJid: string;
roomRoleContext?: RoomRoleContext;
existingTask?: PairedTask | null;
}): ResolvedOwnerHumanTask {
const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid);
const existing =
args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null;
if (!existing) {
return {
task: canonicalWorkDir
? createActiveTaskForRoom({
group: args.group,
chatJid: args.chatJid,
canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
})
: null,
supersededTask: null,
};
}
if (existing.status !== 'merge_ready' || !canonicalWorkDir) {
return {
task: existing,
supersededTask: null,
};
}
const now = new Date().toISOString();
const superseded = transitionPairedTaskStatus({
taskId: existing.id,
currentStatus: existing.status,
nextStatus: 'completed',
expectedUpdatedAt: existing.updated_at,
updatedAt: now,
patch: {
completion_reason: 'superseded',
},
});
if (!superseded) {
return {
task: getLatestOpenPairedTaskForChat(args.chatJid) ?? existing,
supersededTask: null,
};
}
cancelOutstandingFinalizeOwnerTurn(existing);
const newTask = createActiveTaskForRoom({
group: args.group,
chatJid: args.chatJid,
canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
});
carryForwardLatestOwnerFinal({
sourceTask: existing,
targetTask: newTask,
});
return {
task: newTask,
supersededTask: existing,
};
}
// ---------------------------------------------------------------------------
// ensureActiveTask
// ---------------------------------------------------------------------------
function ensureActiveTask(
group: RegisteredGroup,
chatJid: string,
roomRoleContext: RoomRoleContext,
hasHumanMessage?: boolean,
): PairedTask | null {
const existing = getLatestOpenPairedTaskForChat(chatJid);
if (roomRoleContext.role === 'owner' && hasHumanMessage) {
return resolveOwnerTaskForHumanMessage({
group,
chatJid,
roomRoleContext,
existingTask: existing ?? null,
}).task;
}
if (existing) {
return existing;
}
// Don't create a new task for bot-only messages — prevents
// ESCALATE → completed → bot message triggers new task → loop.
if (!hasHumanMessage) {
return null;
}
const canonicalWorkDir = ensurePairedProject(group, chatJid);
if (!canonicalWorkDir) {
return null;
}
return createActiveTaskForRoom({
group,
chatJid,
canonicalWorkDir,
roomRoleContext,
});
}
// ---------------------------------------------------------------------------
// preparePairedExecutionContext
// ---------------------------------------------------------------------------
@@ -236,19 +410,13 @@ export function preparePairedExecutionContext(args: {
const now = new Date().toISOString();
if (roomRoleContext.role === 'owner') {
// New human message → new ping-pong cycle. Reset round trip counter
// AND status so the owner turn is not treated as a finalize turn.
// Reset status on new human message so the owner gets a fresh working
// turn. merge_ready is only reset when a human message is present —
// without it, this is a finalize turn after reviewer approval and
// resetting would prevent task completion.
// New human message keeps the same task only for active review loops.
// merge_ready is split into a fresh task before this function runs.
// Only reset round_trip_count when a human message is present —
// bot-only ping-pong must accumulate the counter for loop detection.
const hasHuman = args.hasHumanMessage === true;
const needsStatusReset =
(latestTask.status === 'merge_ready' && hasHuman) ||
latestTask.status === 'review_ready' ||
latestTask.status === 'in_review';
latestTask.status === 'review_ready' || latestTask.status === 'in_review';
if (hasHuman || needsStatusReset) {
if (needsStatusReset) {
transitionPairedTaskStatus({
@@ -258,7 +426,9 @@ export function preparePairedExecutionContext(args: {
expectedUpdatedAt: latestTask.updated_at,
updatedAt: now,
patch: {
...(hasHuman ? { round_trip_count: 0 } : {}),
...(hasHuman
? { round_trip_count: 0, owner_failure_count: 0 }
: {}),
},
});
} else {
@@ -267,7 +437,9 @@ export function preparePairedExecutionContext(args: {
expectedUpdatedAt: latestTask.updated_at,
updatedAt: now,
patch: {
...(hasHuman ? { round_trip_count: 0 } : {}),
...(hasHuman
? { round_trip_count: 0, owner_failure_count: 0 }
: {}),
},
});
}
@@ -354,7 +526,7 @@ export function preparePairedExecutionContext(args: {
envOverrides,
buildPairedReadonlyRuntimeEnvOverrides({
role: 'reviewer',
agentType: REVIEWER_AGENT_TYPE,
agentType: roomRoleContext.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
unsafeHostPairedMode,
}),
);
@@ -373,7 +545,10 @@ export function preparePairedExecutionContext(args: {
envOverrides,
buildPairedReadonlyRuntimeEnvOverrides({
role: 'arbiter',
agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE,
agentType:
roomRoleContext.arbiterAgentType ??
ARBITER_AGENT_TYPE ??
REVIEWER_AGENT_TYPE,
unsafeHostPairedMode,
}),
);
@@ -440,7 +615,7 @@ export function completePairedExecutionContext(args: {
handleFailedArbiterExecution({ task, taskId });
return;
}
handleFailedOwnerExecution({ task, taskId });
handleFailedOwnerExecution({ task, taskId, summary: args.summary });
return;
}

View File

@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
SERVICE_SESSION_SCOPE: 'codex-main',
}));
vi.mock('./db.js', () => ({
claimServiceHandoff: vi.fn(() => true),
completeServiceHandoffAndAdvanceTargetCursor: vi.fn(
() => '2026-04-15T00:00:09.000Z',
),
failServiceHandoff: vi.fn(),
getPendingServiceHandoffs: vi.fn(() => []),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import * as db from './db.js';
import { processClaimedHandoff } from './message-runtime-handoffs.js';
import {
buildPairedTurnIdentity,
resolveRuntimePairedTurnIdentity,
} from './paired-turn-identity.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
return {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2026-04-15T00:00:00.000Z',
requiresTrigger: false,
agentType: 'codex',
};
}
function makeChannel(name: string, ownsChatJid = false): Channel {
return {
name,
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn(() => true),
ownsJid: vi.fn((jid: string) => ownsChatJid && jid === 'group@test'),
sendMessage: vi.fn(),
} as unknown as Channel;
}
describe('paired turn invariants', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.completeServiceHandoffAndAdvanceTargetCursor).mockReturnValue(
'2026-04-15T00:00:09.000Z',
);
});
it('rejects a persisted turn identity when the explicit role conflicts with its intent kind', () => {
expect(() =>
buildPairedTurnIdentity({
taskId: 'task-review',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'owner',
}),
).toThrow('paired turn identity role mismatch');
expect(() =>
buildPairedTurnIdentity({
taskId: 'task-arbiter',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
intentKind: 'arbiter-turn',
role: 'reviewer',
}),
).toThrow('paired turn identity role mismatch');
});
it('keeps reviewer and arbiter runtime identities fixed even when task status suggests owner work', () => {
expect(
resolveRuntimePairedTurnIdentity({
taskId: 'task-review',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
role: 'reviewer',
taskStatus: 'merge_ready',
hasHumanMessage: true,
}),
).toEqual({
turnId: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn',
taskId: 'task-review',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
});
expect(
resolveRuntimePairedTurnIdentity({
taskId: 'task-arbiter',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
role: 'arbiter',
taskStatus: 'active',
hasHumanMessage: true,
}),
).toEqual({
turnId: 'task-arbiter:2026-04-15T00:00:00.000Z:arbiter-turn',
taskId: 'task-arbiter',
taskUpdatedAt: '2026-04-15T00:00:00.000Z',
intentKind: 'arbiter-turn',
role: 'arbiter',
});
});
it('fails closed when a claimed handoff resolves to a different logical role than the stored turn', async () => {
const executeTurn = vi.fn();
await processClaimedHandoff({
handoff: {
id: 13,
chat_jid: 'group@test',
group_folder: 'test-group',
turn_id: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn',
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_service_id: 'claude',
target_service_id: 'codex-main',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: 'owner',
target_agent_type: 'codex',
prompt: 'review retry',
status: 'claimed',
start_seq: 3,
end_seq: 4,
reason: 'reviewer-auth-failure',
intended_role: 'owner',
created_at: '2026-04-15T00:00:00.000Z',
claimed_at: '2026-04-15T00:00:01.000Z',
completed_at: null,
last_error: null,
},
getRoomBindings: () => ({
'group@test': makeGroup(),
}),
channels: [makeChannel('discord-main', true)],
executeTurn,
lastAgentTimestamps: {},
saveState: vi.fn(),
});
expect(db.failServiceHandoff).toHaveBeenCalledWith(
13,
'Stored handoff turn_role reviewer conflicts with resolved role owner',
);
expect(executeTurn).not.toHaveBeenCalled();
});
it('advances reviewer handoff cursors in the reviewer-scoped namespace', async () => {
const executeTurn = vi.fn(async () => ({
outputStatus: 'success' as const,
deliverySucceeded: true,
visiblePhase: 'final',
}));
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
await processClaimedHandoff({
handoff: {
id: 17,
chat_jid: 'group@test',
group_folder: 'test-group',
paired_task_id: 'task-reviewer-handoff',
paired_task_updated_at: '2026-04-15T00:00:00.000Z',
turn_id: 'task-reviewer-handoff:2026-04-15T00:00:00.000Z:reviewer-turn',
turn_intent_kind: 'reviewer-turn',
turn_role: 'reviewer',
source_service_id: 'claude',
target_service_id: 'codex-review',
source_role: 'reviewer',
source_agent_type: 'claude-code',
target_role: 'reviewer',
target_agent_type: 'codex',
prompt: 'review retry',
status: 'claimed',
start_seq: 5,
end_seq: 6,
reason: 'reviewer-auth-failure',
intended_role: 'reviewer',
created_at: '2026-04-15T00:00:00.000Z',
claimed_at: '2026-04-15T00:00:01.000Z',
completed_at: null,
last_error: null,
},
getRoomBindings: () => ({
'group@test': makeGroup(),
}),
channels: [
makeChannel('discord-main', true),
makeChannel('discord-review'),
],
executeTurn,
lastAgentTimestamps,
saveState,
});
expect(
db.completeServiceHandoffAndAdvanceTargetCursor,
).toHaveBeenCalledWith({
id: 17,
chat_jid: 'group@test',
cursor_key: 'group@test:reviewer',
end_seq: 6,
});
expect(lastAgentTimestamps).toEqual({
'group@test:reviewer': '2026-04-15T00:00:09.000Z',
});
expect(saveState).toHaveBeenCalledTimes(1);
});
});

View File

@@ -77,6 +77,12 @@ describe('platform-prompts', () => {
expect(codexPairedPrompt).toContain(
'canonical verification root for this turn',
);
expect(codexPairedPrompt).toContain(
'suggest 1-2 better alternatives with the reason and tradeoff for each',
);
expect(codexPairedPrompt).toContain(
'Separate correctness issues from improvement ideas',
);
expect(codexPairedPrompt).not.toContain('owner-side paired agent');
const failoverPlatformPrompt = fs.readFileSync(

View File

@@ -5,6 +5,7 @@ import {
_setRegisteredGroupForTests,
_setStoredRoomOwnerAgentTypeForTests,
_initTestDatabase,
assignRoom,
setChannelOwnerLease,
setExplicitRoomMode,
} from './db.js';
@@ -236,6 +237,29 @@ describe('service-routing global failover', () => {
});
});
it('uses room-level reviewer and arbiter agent overrides from assign_room', () => {
assignRoom('dc:room-role-overrides', {
name: 'Room Role Overrides',
roomMode: 'tribunal',
ownerAgentType: 'claude-code',
reviewerAgentType: 'codex',
arbiterAgentType: 'claude-code',
folder: 'room-role-overrides',
});
expect(getEffectiveChannelLease('dc:room-role-overrides')).toMatchObject({
chat_jid: 'dc:room-role-overrides',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: 'claude-code',
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
arbiter_service_id: 'claude',
owner_failover_active: false,
explicit: false,
});
});
it('defaults to the configured owner service for chats without canonical room settings', () => {
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
chat_jid: 'dc:unregistered',

View File

@@ -12,6 +12,7 @@ import {
clearChannelOwnerLease,
getAllChannelOwnerLeases,
getEffectiveRuntimeRoomMode,
getStoredRoomRoleAgentPlan,
getStoredRoomSettings,
type ChannelOwnerLeaseRow,
} from './db.js';
@@ -46,8 +47,10 @@ function normalizeLeaseRow(
row: ChannelOwnerLeaseRow,
explicit: boolean,
): EffectiveChannelLease {
const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid);
const ownerAgentType =
row.owner_agent_type ??
storedRolePlan?.ownerAgentType ??
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
inferAgentTypeFromServiceShadow(row.owner_service_id) ??
'claude-code';
@@ -56,6 +59,7 @@ function normalizeLeaseRow(
? null
: (row.reviewer_agent_type ??
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
storedRolePlan?.reviewerAgentType ??
resolveRoleAgentPlan({
paired: true,
groupAgentType: ownerAgentType,
@@ -67,6 +71,7 @@ function normalizeLeaseRow(
(row.arbiter_service_id
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
: undefined) ??
storedRolePlan?.arbiterAgentType ??
null;
return {
@@ -102,20 +107,21 @@ function normalizeLeaseRow(
};
}
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
return getStoredRoomSettings(chatJid)?.ownerAgentType;
}
function getDefaultLease(chatJid: string): EffectiveChannelLease {
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid);
const ownerAgentType =
resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE;
const rolePlan = resolveRoleAgentPlan({
paired: roomMode === 'tribunal',
groupAgentType: ownerAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
});
storedRolePlan?.ownerAgentType ??
getStoredRoomSettings(chatJid)?.ownerAgentType ??
OWNER_AGENT_TYPE;
const rolePlan =
storedRolePlan ??
resolveRoleAgentPlan({
paired: roomMode === 'tribunal',
groupAgentType: ownerAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
});
return {
chat_jid: chatJid,

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
shouldResetCodexSessionOnAgentFailure,
shouldResetSessionOnAgentFailure,
shouldRetryFreshCodexSessionOnAgentFailure,
shouldRetryFreshSessionOnAgentFailure,
} from './session-recovery.js';
@@ -99,3 +101,36 @@ describe('shouldRetryFreshSessionOnAgentFailure', () => {
).toBe(false);
});
});
describe('shouldResetCodexSessionOnAgentFailure', () => {
it('matches remote compact task failures that mention prompt_cache_retention', () => {
expect(
shouldResetCodexSessionOnAgentFailure({
result: null,
error:
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
}),
).toBe(true);
});
it('does not match unrelated Codex failures', () => {
expect(
shouldResetCodexSessionOnAgentFailure({
result: null,
error: 'Codex process exited with code 1',
}),
).toBe(false);
});
});
describe('shouldRetryFreshCodexSessionOnAgentFailure', () => {
it('retries the same remote compact task failure with a fresh session', () => {
expect(
shouldRetryFreshCodexSessionOnAgentFailure({
result: null,
error:
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
}),
).toBe(true);
});
});

View File

@@ -17,6 +17,11 @@ const SESSION_RETRY_PATTERNS = [
/invalid[^\n]*signature[^\n]*thinking/i,
];
const CODEX_SESSION_RESET_PATTERNS = [
/Error running remote compact task/i,
/prompt_cache_retention/i,
];
function toText(value: string | object | null | undefined): string[] {
if (!value) return [];
if (typeof value === 'string') return [value];
@@ -51,3 +56,21 @@ export function shouldRetryFreshSessionOnAgentFailure(
SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)),
);
}
export function shouldResetCodexSessionOnAgentFailure(
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
): boolean {
const texts = [
...toText(getAgentOutputText(output)),
...toText(output.error),
];
return texts.some((text) =>
CODEX_SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
);
}
export function shouldRetryFreshCodexSessionOnAgentFailure(
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
): boolean {
return shouldResetCodexSessionOnAgentFailure(output);
}

View File

@@ -21,6 +21,7 @@ vi.mock('./codex-token-rotation.js', () => ({
}));
vi.mock('./session-recovery.js', () => ({
shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false),
shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false),
}));
@@ -34,7 +35,10 @@ import {
isClaudeAuthError,
} from './agent-error-detection.js';
import { detectCodexRotationTrigger } from './codex-token-rotation.js';
import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js';
import {
shouldRetryFreshCodexSessionOnAgentFailure,
shouldRetryFreshSessionOnAgentFailure,
} from './session-recovery.js';
import {
evaluateStreamedOutput,
@@ -91,6 +95,7 @@ beforeEach(() => {
shouldRotate: false,
reason: '',
});
vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue(false);
vi.mocked(shouldRetryFreshSessionOnAgentFailure).mockReturnValue(false);
});
@@ -252,6 +257,28 @@ describe('evaluateStreamedOutput', () => {
});
});
describe('Codex retryable session failure suppression', () => {
it('suppresses remote compact failures before any visible output', () => {
vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue(
true,
);
const result = evaluateStreamedOutput(
successOutput(
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
'intermediate',
),
freshState(),
codexOpts,
);
expect(result.shouldForwardOutput).toBe(false);
expect(result.suppressedRetryableSessionFailure).toBe(true);
expect(result.state.retryableSessionFailureDetected).toBe(true);
expect(result.state.sawOutput).toBe(false);
});
});
describe('Claude auth-expired banner', () => {
it('suppresses output and returns newTrigger', () => {
vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(true);

View File

@@ -15,7 +15,10 @@ import {
} from './agent-error-detection.js';
import type { AgentOutput } from './agent-runner.js';
import { detectCodexRotationTrigger } from './codex-token-rotation.js';
import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js';
import {
shouldRetryFreshCodexSessionOnAgentFailure,
shouldRetryFreshSessionOnAgentFailure,
} from './session-recovery.js';
export interface StreamedTriggerReason {
reason: AgentTriggerReason;
@@ -74,6 +77,19 @@ export function evaluateStreamedOutput(
};
}
if (
isPrimaryCodex &&
!state.sawOutput &&
shouldRetryFreshCodexSessionOnAgentFailure(output)
) {
nextState.retryableSessionFailureDetected = true;
return {
state: nextState,
shouldForwardOutput: false,
suppressedRetryableSessionFailure: true,
};
}
if (
isPrimaryClaude &&
output.status === 'success' &&

View File

@@ -82,6 +82,7 @@ export interface PairedTask {
plan_notes: string | null;
review_requested_at: string | null;
round_trip_count: number;
owner_failure_count?: number | null;
status: PairedTaskStatus;
arbiter_verdict: string | null;
arbiter_requested_at: string | null;
@@ -157,6 +158,13 @@ export interface RegisteredGroup {
workDir?: string; // Working directory for the agent (defaults to group folder)
}
export type MessageSourceKind =
| 'human'
| 'bot'
| 'trusted_external_bot'
| 'ipc_injected_human'
| 'ipc_injected_bot';
export interface NewMessage {
id: string;
chat_jid: string;
@@ -167,6 +175,7 @@ export interface NewMessage {
seq?: number;
is_from_me?: boolean;
is_bot_message?: boolean;
message_source_kind?: MessageSourceKind;
}
export interface ScheduledTask {
@@ -226,8 +235,9 @@ export interface Channel {
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Optional: edit/delete messages (used by status dashboard).
// Optional: edit/delete messages (used by status dashboard and tracked progress cleanup).
editMessage?(jid: string, messageId: string, text: string): Promise<void>;
deleteMessage?(jid: string, messageId: string): Promise<void>;
sendAndTrack?(jid: string, text: string): Promise<string | null>;
// Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>;