Merge pull request #5 from phj1081/dev
Stabilize paired runtime and promote dev fixes to main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EJClaw
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
146
runners/agent-runner/src/bundled-cli-path.ts
Normal file
146
runners/agent-runner/src/bundled-cli-path.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
runners/agent-runner/test/bundled-cli-path.test.ts
Normal file
149
runners/agent-runner/test/bundled-cli-path.test.ts
Normal 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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
23
src/db/migrations/011_owner-failure-count.ts
Normal file
23
src/db/migrations/011_owner-failure-count.ts
Normal 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
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -114,6 +114,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
||||
isMain,
|
||||
roomBindings,
|
||||
deps.sendMessage,
|
||||
deps.injectInboundMessage,
|
||||
);
|
||||
if (forwardResult.outcome === 'sent') {
|
||||
logger.info(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
111
src/message-runtime-prompts.test.ts
Normal file
111
src/message-runtime-prompts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
221
src/paired-turn-invariants.test.ts
Normal file
221
src/paired-turn-invariants.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
12
src/types.ts
12
src/types.ts
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user