refactor: decompose runtime coordination and runner internals (PR6)

This commit is contained in:
ejclaw
2026-04-10 22:48:49 +09:00
parent e5bd721ac4
commit 497e6dec74
23 changed files with 1987 additions and 1804 deletions

View File

@@ -8,6 +8,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.87", "@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0", "cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"zod": "^4.0.0", "zod": "^4.0.0",
}, },
"devDependencies": { "devDependencies": {
@@ -97,6 +98,8 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:../shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],

View File

@@ -13,6 +13,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.87", "@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0", "cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,47 +1,22 @@
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import type { RoomRoleContext } from './room-role-context.js'; import type { RoomRoleContext } from './room-role-context.js';
export {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from 'ejclaw-runners-shared';
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort'; export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
const MUTATING_SHELL_PATTERNS = [ const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i, /\bsed\s+-i\b/i,
/\bperl\s+-i\b/i, /\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i, /(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
]; ];
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
return false;
}
return roomRoleContext?.role === 'reviewer';
}
export function isClaudeReadonlyReviewerRuntime( export function isClaudeReadonlyReviewerRuntime(
roomRoleContext?: RoomRoleContext, roomRoleContext?: RoomRoleContext,
): boolean { ): boolean {
@@ -107,11 +82,13 @@ export function buildClaudeReadonlySandboxSettings(
platform, platform,
), ),
) { ) {
const normalizedPaths = [...new Set( const normalizedPaths = [
...new Set(
protectedPaths protectedPaths
.filter((value): value is string => Boolean(value)) .filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value)), .map((value) => path.resolve(value)),
)]; ),
];
return { return {
enabled: true, enabled: true,
@@ -119,236 +96,11 @@ export function buildClaudeReadonlySandboxSettings(
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: filesystem:
normalizedPaths.length > 0 normalizedPaths.length > 0 ? { denyWrite: normalizedPaths } : undefined,
? { denyWrite: normalizedPaths }
: undefined,
}; };
} }
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
protected_work_dir=${JSON.stringify(protectedWorkDir)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
target_dir="$(pwd -P)"
capture_next_dir=0
for arg in "$@"; do
if [[ "$capture_next_dir" == "1" ]]; then
if [[ "$arg" == /* ]]; then
target_dir="$arg"
else
target_dir="$target_dir/$arg"
fi
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
capture_next_dir=0
continue
fi
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-C)
capture_next_dir=1
continue
;;
-C*)
target_dir="\${arg#-C}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
--work-tree)
capture_next_dir=1
continue
;;
--work-tree=*)
target_dir="\${arg#--work-tree=}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
is_protected_target=0
if [[ -n "$protected_work_dir" ]]; then
protected_real="$(cd "$protected_work_dir" 2>/dev/null && pwd -P || printf '%s' "$protected_work_dir")"
case "$target_dir" in
"$protected_real"|"$protected_real"/*)
is_protected_target=1
;;
esac
fi
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$is_protected_target" == "1" && "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
EJCLAW_PROTECTED_WORK_DIR: protectedWorkDir,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
function readGitOutput(
args: string[],
baseEnv: NodeJS.ProcessEnv,
cwd: string,
): string {
return execFileSync('git', args, {
cwd,
env: baseEnv,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function isRemoteGitOrigin(originUrl: string): boolean {
return (
/^(?:https?|ssh|git):\/\//i.test(originUrl) ||
/^[^/\\]+@[^:]+:.+/.test(originUrl)
);
}
export function assertReadonlyWorkspaceRepoConnectivity(
baseEnv: NodeJS.ProcessEnv,
enabled: boolean,
): void {
if (!enabled) {
return;
}
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
if (!protectedWorkDir) {
return;
}
let originUrl = '';
try {
originUrl = readGitOutput(
['config', '--get', 'remote.origin.url'],
baseEnv,
protectedWorkDir,
);
} catch {
return;
}
if (isRemoteGitOrigin(originUrl)) {
return;
}
if (!path.isAbsolute(originUrl) || !fs.existsSync(originUrl)) {
throw new Error(
`EJClaw readonly runtime cannot access local git origin path: ${originUrl || '(missing)'}`,
);
}
try {
readGitOutput(['rev-parse', '--git-dir'], baseEnv, originUrl);
} catch {
throw new Error(
`EJClaw readonly runtime origin path is not mounted as a git repository: ${originUrl}`,
);
}
try {
readGitOutput(['ls-remote', 'origin', 'HEAD'], baseEnv, protectedWorkDir);
} catch {
throw new Error(
`EJClaw readonly runtime cannot resolve local git origin from ${protectedWorkDir}. Check canonical repo mount for ${originUrl}.`,
);
}
}
export function isReviewerMutatingShellCommand(command: string): boolean { export function isReviewerMutatingShellCommand(command: string): boolean {
const normalized = command.trim(); const normalized = command.trim();
return ( return MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized));
MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized))
);
} }

View File

@@ -1,23 +1,4 @@
export interface RoomRoleContext { export {
serviceId: string; prependRoomRoleHeader,
role: 'owner' | 'reviewer'; type RoomRoleContext,
ownerServiceId: string; } from 'ejclaw-runners-shared';
reviewerServiceId: string;
failoverOwner: boolean;
}
export function prependRoomRoleHeader(
prompt: string,
roomRoleContext?: RoomRoleContext,
): string {
if (!roomRoleContext) {
return prompt;
}
const header =
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
return `${header}\n\n${prompt}`;
}

View File

@@ -6,6 +6,7 @@
"name": "ejclaw-codex-runner", "name": "ejclaw-codex-runner",
"dependencies": { "dependencies": {
"@openai/codex": "^0.117.0", "@openai/codex": "^0.117.0",
"ejclaw-runners-shared": "file:../shared",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
@@ -30,6 +31,8 @@
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:../shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View File

@@ -10,6 +10,7 @@
"start": "bun dist/index.js" "start": "bun dist/index.js"
}, },
"dependencies": { "dependencies": {
"ejclaw-runners-shared": "file:../shared",
"@openai/codex": "^0.117.0" "@openai/codex": "^0.117.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,259 +1,9 @@
import { execFileSync } from 'child_process'; export {
import fs from 'fs'; assertReadonlyWorkspaceRepoConnectivity,
import os from 'os'; buildReviewerGitGuardEnv,
import path from 'path'; isReviewerRuntime,
} from 'ejclaw-runners-shared';
import type { RoomRoleContext } from './room-role-context.js';
// Codex app-server does not expose a BashTool-style pre-use hook, so reviewer // Codex app-server does not expose a BashTool-style pre-use hook, so reviewer
// mode can only hard-block mutating git via PATH interception here. Non-git // mode can only hard-block mutating git via PATH interception here. Non-git
// shell mutation commands remain a known gap when REVIEWER_AGENT_TYPE=codex. // shell mutation commands remain a known gap when REVIEWER_AGENT_TYPE=codex.
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
return false;
}
return roomRoleContext?.role === 'reviewer';
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
protected_work_dir=${JSON.stringify(protectedWorkDir)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
target_dir="$(pwd -P)"
capture_next_dir=0
for arg in "$@"; do
if [[ "$capture_next_dir" == "1" ]]; then
if [[ "$arg" == /* ]]; then
target_dir="$arg"
else
target_dir="$target_dir/$arg"
fi
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
capture_next_dir=0
continue
fi
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-C)
capture_next_dir=1
continue
;;
-C*)
target_dir="\${arg#-C}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
--work-tree)
capture_next_dir=1
continue
;;
--work-tree=*)
target_dir="\${arg#--work-tree=}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
is_protected_target=0
if [[ -n "$protected_work_dir" ]]; then
protected_real="$(cd "$protected_work_dir" 2>/dev/null && pwd -P || printf '%s' "$protected_work_dir")"
case "$target_dir" in
"$protected_real"|"$protected_real"/*)
is_protected_target=1
;;
esac
fi
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$is_protected_target" == "1" && "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
EJCLAW_PROTECTED_WORK_DIR: protectedWorkDir,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
function readGitOutput(
args: string[],
baseEnv: NodeJS.ProcessEnv,
cwd: string,
): string {
return execFileSync('git', args, {
cwd,
env: baseEnv,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function isRemoteGitOrigin(originUrl: string): boolean {
return (
/^(?:https?|ssh|git):\/\//i.test(originUrl) ||
/^[^/\\]+@[^:]+:.+/.test(originUrl)
);
}
export function assertReadonlyWorkspaceRepoConnectivity(
baseEnv: NodeJS.ProcessEnv,
enabled: boolean,
): void {
if (!enabled) {
return;
}
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
if (!protectedWorkDir) {
return;
}
let originUrl = '';
try {
originUrl = readGitOutput(
['config', '--get', 'remote.origin.url'],
baseEnv,
protectedWorkDir,
);
} catch {
return;
}
if (isRemoteGitOrigin(originUrl)) {
return;
}
if (!path.isAbsolute(originUrl) || !fs.existsSync(originUrl)) {
throw new Error(
`EJClaw readonly runtime cannot access local git origin path: ${originUrl || '(missing)'}`,
);
}
try {
readGitOutput(['rev-parse', '--git-dir'], baseEnv, originUrl);
} catch {
throw new Error(
`EJClaw readonly runtime origin path is not mounted as a git repository: ${originUrl}`,
);
}
try {
readGitOutput(['ls-remote', 'origin', 'HEAD'], baseEnv, protectedWorkDir);
} catch {
throw new Error(
`EJClaw readonly runtime cannot resolve local git origin from ${protectedWorkDir}. Check canonical repo mount for ${originUrl}.`,
);
}
}

View File

@@ -1,23 +1,4 @@
export interface RoomRoleContext { export {
serviceId: string; prependRoomRoleHeader,
role: 'owner' | 'reviewer'; type RoomRoleContext,
ownerServiceId: string; } from 'ejclaw-runners-shared';
reviewerServiceId: string;
failoverOwner: boolean;
}
export function prependRoomRoleHeader(
prompt: string,
roomRoleContext?: RoomRoleContext,
): string {
if (!roomRoleContext) {
return prompt;
}
const header =
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
return `${header}\n\n${prompt}`;
}

20
runners/shared/bun.lock Normal file
View File

@@ -0,0 +1,20 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ejclaw-runners-shared",
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "ejclaw-runners-shared",
"version": "1.0.0",
"type": "module",
"description": "Shared runner utilities for EJClaw",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,9 @@
export {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
export {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from './reviewer-git-guard.js';

View File

@@ -0,0 +1,254 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
export function isReviewerRuntime(roomRoleContext?: RoomRoleContext): boolean {
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
return false;
}
return roomRoleContext?.role === 'reviewer';
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
protected_work_dir=${JSON.stringify(protectedWorkDir)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
target_dir="$(pwd -P)"
capture_next_dir=0
for arg in "$@"; do
if [[ "$capture_next_dir" == "1" ]]; then
if [[ "$arg" == /* ]]; then
target_dir="$arg"
else
target_dir="$target_dir/$arg"
fi
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
capture_next_dir=0
continue
fi
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-C)
capture_next_dir=1
continue
;;
-C*)
target_dir="\${arg#-C}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
--work-tree)
capture_next_dir=1
continue
;;
--work-tree=*)
target_dir="\${arg#--work-tree=}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
is_protected_target=0
if [[ -n "$protected_work_dir" ]]; then
protected_real="$(cd "$protected_work_dir" 2>/dev/null && pwd -P || printf '%s' "$protected_work_dir")"
case "$target_dir" in
"$protected_real"|"$protected_real"/*)
is_protected_target=1
;;
esac
fi
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$is_protected_target" == "1" && "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
EJCLAW_PROTECTED_WORK_DIR: protectedWorkDir,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
function readGitOutput(
args: string[],
baseEnv: NodeJS.ProcessEnv,
cwd: string,
): string {
return execFileSync('git', args, {
cwd,
env: baseEnv,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function isRemoteGitOrigin(originUrl: string): boolean {
return (
/^(?:https?|ssh|git):\/\//i.test(originUrl) ||
/^[^/\\]+@[^:]+:.+/.test(originUrl)
);
}
export function assertReadonlyWorkspaceRepoConnectivity(
baseEnv: NodeJS.ProcessEnv,
enabled: boolean,
): void {
if (!enabled) {
return;
}
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
if (!protectedWorkDir) {
return;
}
let originUrl = '';
try {
originUrl = readGitOutput(
['config', '--get', 'remote.origin.url'],
baseEnv,
protectedWorkDir,
);
} catch {
return;
}
if (isRemoteGitOrigin(originUrl)) {
return;
}
if (!path.isAbsolute(originUrl) || !fs.existsSync(originUrl)) {
throw new Error(
`EJClaw readonly runtime cannot access local git origin path: ${originUrl || '(missing)'}`,
);
}
try {
readGitOutput(['rev-parse', '--git-dir'], baseEnv, originUrl);
} catch {
throw new Error(
`EJClaw readonly runtime origin path is not mounted as a git repository: ${originUrl}`,
);
}
try {
readGitOutput(['ls-remote', 'origin', 'HEAD'], baseEnv, protectedWorkDir);
} catch {
throw new Error(
`EJClaw readonly runtime cannot resolve local git origin from ${protectedWorkDir}. Check canonical repo mount for ${originUrl}.`,
);
}
}

View File

@@ -0,0 +1,23 @@
export interface RoomRoleContext {
serviceId: string;
role: 'owner' | 'reviewer';
ownerServiceId: string;
reviewerServiceId: string;
failoverOwner: boolean;
}
export function prependRoomRoleHeader(
prompt: string,
roomRoleContext?: RoomRoleContext,
): string {
if (!roomRoleContext) {
return prompt;
}
const header =
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
return `${header}\n\n${prompt}`;
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

428
src/agent-runner-process.ts Normal file
View File

@@ -0,0 +1,428 @@
import type { ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
AGENT_MAX_OUTPUT_SIZE,
AGENT_TIMEOUT,
IDLE_TIMEOUT,
LOG_LEVEL,
} from './config.js';
import { logger } from './logger.js';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
import type { AgentInput, AgentOutput } from './agent-runner.js';
import type { RegisteredGroup } from './types.js';
import { getErrorMessage } from './utils.js';
interface RunSpawnedAgentProcessArgs {
proc: ChildProcess;
group: RegisteredGroup;
input: AgentInput;
processName: string;
logsDir: string;
startTime: number;
onOutput?: (output: AgentOutput) => Promise<void>;
}
function parseLegacyAgentOutput(stdout: string): AgentOutput {
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
let jsonLine: string;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonLine = stdout
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
} else {
const lines = stdout.trim().split('\n');
jsonLine = lines[lines.length - 1];
}
return JSON.parse(jsonLine) as AgentOutput;
}
export function runSpawnedAgentProcess(
args: RunSpawnedAgentProcessArgs,
): Promise<AgentOutput> {
const { proc, group, input, processName, logsDir, startTime, onOutput } =
args;
return new Promise((resolve) => {
const stdoutStream = proc.stdout;
const stderrStream = proc.stderr;
if (!stdoutStream || !stderrStream) {
resolve({
status: 'error',
result: null,
error: 'Agent process stdio pipes are unavailable',
});
return;
}
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive.
let parseBuffer = '';
let newSessionId: string | undefined;
let outputChain = Promise.resolve();
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const killOnTimeout = () => {
timedOut = true;
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
},
'Agent timeout, sending SIGTERM',
);
proc.kill('SIGTERM');
setTimeout(() => {
if (!proc.killed) proc.kill('SIGKILL');
}, 15000);
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
};
stdoutStream.on('data', (data) => {
const chunk = data.toString();
if (!stdoutTruncated) {
const remaining = AGENT_MAX_OUTPUT_SIZE - stdout.length;
if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
size: stdout.length,
},
'Agent stdout truncated due to size limit',
);
} else {
stdout += chunk;
}
}
if (!onOutput) {
return;
}
parseBuffer += chunk;
let startIdx: number;
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break;
const jsonStr = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed: AgentOutput = JSON.parse(jsonStr);
if (parsed.newSessionId) {
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
resetTimeout();
if (parsed.status === 'error') {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: parsed.error,
newSessionId: parsed.newSessionId,
},
'Streamed agent error output',
);
}
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse streamed output chunk',
);
}
}
});
stderrStream.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (!line) continue;
if (
line.includes('Turn in progress') ||
line.includes('Subagent') ||
line.includes('Intermediate assistant') ||
line.includes('Promoting') ||
line.includes('Flushing') ||
line.includes('Result #') ||
line.includes('Query done') ||
line.includes('Terminal') ||
line.includes('Assistant: stop=') ||
line.includes('Close sentinel')
) {
logger.info(
{ group: group.name, chatJid: input.chatJid, runId: input.runId },
line.replace(/^\[.*?\]\s*/, ''),
);
} else {
logger.debug(
{ agent: group.folder, chatJid: input.chatJid, runId: input.runId },
line,
);
}
}
resetTimeout();
if (stderrTruncated) return;
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
} else {
stderr += chunk;
}
});
proc.on('close', (code, signal) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
[
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`RunId: ${input.runId || 'n/a'}`,
`Process: ${processName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Signal: ${signal}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'),
);
if (hadStreamingOutput) {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
code,
signal,
},
'Agent timed out after output (idle cleanup)',
);
outputChain.then(() => {
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
resolve({
status: 'error',
result: null,
error: `Agent timed out after ${configTimeout}ms`,
});
return;
}
if (code === null && signal) {
if (hadStreamingOutput) {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
signal,
},
'Agent terminated by signal after output delivery (normal cleanup)',
);
outputChain.then(() => {
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
signal,
},
'Agent killed by signal before producing output',
);
outputChain.then(() => {
resolve({
status: 'error',
result: null,
error: `Agent killed by ${signal} before producing output`,
});
});
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(
logsDir,
`agent-${input.runId || 'adhoc'}-${timestamp}.log`,
);
const isVerbose = LOG_LEVEL === 'debug' || LOG_LEVEL === 'trace';
const logLines = [
`=== Agent Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`GroupFolder: ${input.groupFolder}`,
`RunId: ${input.runId || 'n/a'}`,
`IsMain: ${input.isMain}`,
`AgentType: ${group.agentType || 'claude-code'}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Signal: ${signal}`,
``,
];
const isError = code !== 0;
if (isVerbose || isError) {
logLines.push(
`=== Input ===`,
JSON.stringify(input, null, 2),
``,
`=== Stderr ===`,
stderr,
``,
`=== Stdout ===`,
stdout,
);
} else {
logLines.push(
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
);
}
fs.writeFileSync(logFile, logLines.join('\n'));
if (code !== 0) {
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
code,
duration,
logFile,
},
'Agent exited with error',
);
outputChain.then(() => {
resolve({
status: 'error',
result: null,
error: `Agent exited with code ${code}: ${stderr.slice(-200)}`,
});
});
return;
}
if (onOutput) {
outputChain.then(() => {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
newSessionId,
},
'Agent completed (streaming mode)',
);
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
try {
const output = parseLegacyAgentOutput(stdout);
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
status: output.status,
},
'Agent completed',
);
resolve(output);
} catch (err) {
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse agent output',
);
resolve({
status: 'error',
result: null,
error: `Failed to parse agent output: ${getErrorMessage(err)}`,
});
}
});
proc.on('error', (err) => {
clearTimeout(timeout);
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
error: err,
},
'Agent spawn error',
);
resolve({
status: 'error',
result: null,
error: `Agent spawn error: ${err.message}`,
});
});
});
}

View File

@@ -1,22 +1,16 @@
/**
* Agent Process Runner for EJClaw
* Spawns agent execution as direct host processes and handles IPC
*/
import { ChildProcess, spawn } from 'child_process'; import { ChildProcess, spawn } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { getErrorMessage } from './utils.js'; /**
import { * Agent Process Runner for EJClaw
AGENT_MAX_OUTPUT_SIZE, * Spawns agent execution as direct host processes and handles IPC.
AGENT_TIMEOUT, */
IDLE_TIMEOUT,
LOG_LEVEL,
} from './config.js';
import { import {
prepareReadonlySessionEnvironment, prepareReadonlySessionEnvironment,
prepareGroupEnvironment, prepareGroupEnvironment,
} from './agent-runner-environment.js'; } from './agent-runner-environment.js';
import { runSpawnedAgentProcess } from './agent-runner-process.js';
import { getEnv } from './env.js'; import { getEnv } from './env.js';
export { export {
type AvailableGroup, type AvailableGroup,
@@ -24,14 +18,7 @@ export {
writeTasksSnapshot, writeTasksSnapshot,
} from './agent-runner-snapshot.js'; } from './agent-runner-snapshot.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js'; import { AgentType, RegisteredGroup, RoomRoleContext } from './types.js';
import {
AgentOutputPhase,
AgentType,
RegisteredGroup,
RoomRoleContext,
StructuredAgentOutput,
} from './types.js';
export interface AgentInput { export interface AgentInput {
prompt: string; prompt: string;
@@ -52,8 +39,8 @@ export interface AgentInput {
export interface AgentOutput { export interface AgentOutput {
status: 'success' | 'error'; status: 'success' | 'error';
result: string | null; result: string | null;
output?: StructuredAgentOutput; output?: import('./types.js').StructuredAgentOutput;
phase?: AgentOutputPhase; phase?: import('./types.js').AgentOutputPhase;
agentId?: string; agentId?: string;
agentLabel?: string; agentLabel?: string;
agentDone?: boolean; agentDone?: boolean;
@@ -163,395 +150,17 @@ export async function runAgentProcess(
onProcess(proc, processName, env.EJCLAW_IPC_DIR); onProcess(proc, processName, env.EJCLAW_IPC_DIR);
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
proc.stdin.write(JSON.stringify(input)); proc.stdin.write(JSON.stringify(input));
proc.stdin.end(); proc.stdin.end();
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive runSpawnedAgentProcess({
let parseBuffer = ''; proc,
let newSessionId: string | undefined; group,
let outputChain = Promise.resolve(); input,
proc.stdout.on('data', (data) => {
const chunk = data.toString();
if (!stdoutTruncated) {
const remaining = AGENT_MAX_OUTPUT_SIZE - stdout.length;
if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
size: stdout.length,
},
'Agent stdout truncated due to size limit',
);
} else {
stdout += chunk;
}
}
if (onOutput) {
parseBuffer += chunk;
let startIdx: number;
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break;
const jsonStr = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed: AgentOutput = JSON.parse(jsonStr);
if (parsed.newSessionId) {
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
resetTimeout();
if (parsed.status === 'error') {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: parsed.error,
newSessionId: parsed.newSessionId,
},
'Streamed agent error output',
);
}
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse streamed output chunk',
);
}
}
}
});
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const killOnTimeout = () => {
timedOut = true;
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName, processName,
},
'Agent timeout, sending SIGTERM',
);
proc.kill('SIGTERM');
// Force kill after 15s if still alive
setTimeout(() => {
if (!proc.killed) proc.kill('SIGKILL');
}, 15000);
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
};
proc.stderr.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (!line) continue;
if (
line.includes('Turn in progress') ||
line.includes('Subagent') ||
line.includes('Intermediate assistant') ||
line.includes('Promoting') ||
line.includes('Flushing') ||
line.includes('Result #') ||
line.includes('Query done') ||
line.includes('Terminal') ||
line.includes('Assistant: stop=') ||
line.includes('Close sentinel')
) {
logger.info(
{ group: group.name, chatJid: input.chatJid, runId: input.runId },
line.replace(/^\[.*?\]\s*/, ''),
);
} else {
logger.debug(
{ agent: group.folder, chatJid: input.chatJid, runId: input.runId },
line,
);
}
}
// Stderr activity means agent is alive — reset timeout
resetTimeout();
if (stderrTruncated) return;
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
} else {
stderr += chunk;
}
});
proc.on('close', (code, signal) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
[
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`RunId: ${input.runId || 'n/a'}`,
`Process: ${processName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Signal: ${signal}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'),
);
if (hadStreamingOutput) {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
code,
signal,
},
'Agent timed out after output (idle cleanup)',
);
outputChain.then(() => {
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
resolve({
status: 'error',
result: null,
error: `Agent timed out after ${configTimeout}ms`,
});
return;
}
// Signal kill (SIGTERM/SIGKILL) from post-close cleanup or service
// restart. When the agent already delivered streaming output this is
// normal lifecycle — not an error.
if (code === null && signal) {
if (hadStreamingOutput) {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
signal,
},
'Agent terminated by signal after output delivery (normal cleanup)',
);
outputChain.then(() => {
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
// No output delivered before signal kill — genuine error
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
signal,
},
'Agent killed by signal before producing output',
);
outputChain.then(() => {
resolve({
status: 'error',
result: null,
error: `Agent killed by ${signal} before producing output`,
});
});
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(
logsDir, logsDir,
`agent-${input.runId || 'adhoc'}-${timestamp}.log`, startTime,
); onOutput,
const isVerbose = LOG_LEVEL === 'debug' || LOG_LEVEL === 'trace'; }).then(resolve);
const logLines = [
`=== Agent Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`GroupFolder: ${input.groupFolder}`,
`RunId: ${input.runId || 'n/a'}`,
`IsMain: ${input.isMain}`,
`AgentType: ${group.agentType || 'claude-code'}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Signal: ${signal}`,
``,
];
const isError = code !== 0;
if (isVerbose || isError) {
logLines.push(
`=== Input ===`,
JSON.stringify(input, null, 2),
``,
`=== Stderr ===`,
stderr,
``,
`=== Stdout ===`,
stdout,
);
} else {
logLines.push(
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
);
}
fs.writeFileSync(logFile, logLines.join('\n'));
if (code !== 0) {
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
code,
duration,
logFile,
},
'Agent exited with error',
);
// Wait for any queued streamed-output handlers to finish so a late
// newSessionId cannot be persisted after the caller clears a poisoned
// session.
outputChain.then(() => {
resolve({
status: 'error',
result: null,
error: `Agent exited with code ${code}: ${stderr.slice(-200)}`,
});
});
return;
}
if (onOutput) {
outputChain.then(() => {
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
newSessionId,
},
'Agent completed (streaming mode)',
);
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
// Legacy mode: parse output from stdout
try {
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
let jsonLine: string;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonLine = stdout
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
} else {
const lines = stdout.trim().split('\n');
jsonLine = lines[lines.length - 1];
}
const output: AgentOutput = JSON.parse(jsonLine);
logger.info(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
status: output.status,
},
'Agent completed',
);
resolve(output);
} catch (err) {
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse agent output',
);
resolve({
status: 'error',
result: null,
error: `Failed to parse agent output: ${getErrorMessage(err)}`,
});
}
});
proc.on('error', (err) => {
clearTimeout(timeout);
logger.error(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
error: err,
},
'Agent spawn error',
);
resolve({
status: 'error',
result: null,
error: `Agent spawn error: ${err.message}`,
});
});
}); });
} }

469
src/db.ts
View File

@@ -7,53 +7,24 @@ import {
CLAUDE_SERVICE_ID, CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID, CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
DATA_DIR,
normalizeServiceId,
OWNER_AGENT_TYPE,
SERVICE_ID, SERVICE_ID,
SERVICE_SESSION_SCOPE, SERVICE_SESSION_SCOPE,
} from './config.js'; } from './config.js';
import { listUnexpectedDataStateFiles } from './data-state-files.js';
import { import {
isValidGroupFolder,
resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup, resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup,
resolveServiceTaskSessionsPath as resolveServiceTaskSessionsPathFromGroup, resolveServiceTaskSessionsPath as resolveServiceTaskSessionsPathFromGroup,
resolveTaskSessionsPath as resolveTaskSessionsPathFromGroup, resolveTaskSessionsPath as resolveTaskSessionsPathFromGroup,
} from './group-folder.js'; } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
type RoomModeSource,
type RoomRegistrationSnapshot,
countPendingLegacyRegisteredGroupRows,
type StoredRoomSettings, type StoredRoomSettings,
buildRegisteredGroupFromStoredSettings,
buildLegacyRoomMigrationPlan,
collectRegisteredAgentTypes,
collectRegisteredAgentTypesForFolder,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase,
inferStoredRoomCapabilityTypes,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes, inferRoomModeFromRegisteredAgentTypes,
insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom,
normalizeRoomModeSource,
normalizeStoredAgentType,
resolveStoredRoomCapabilityTypes,
resolveAssignedRoomFolder,
syncRoomRoleOverridesForRoom,
upsertRoomRoleOverride,
updateStoredRoomMetadata,
} from './db/room-registration.js'; } from './db/room-registration.js';
import { import {
initializeDatabaseSchema, openInitializedDatabaseFromFile,
openDatabaseFromFile, openInitializedInMemoryDatabase,
openInMemoryDatabase, openInitializedPersistentDatabase,
openPersistentDatabase, } from './db/database-lifecycle.js';
} from './db/bootstrap.js';
import { import {
clearChannelOwnerLeaseInDatabase, clearChannelOwnerLeaseInDatabase,
getAllChannelOwnerLeasesFromDatabase, getAllChannelOwnerLeasesFromDatabase,
@@ -102,8 +73,6 @@ import {
} from './db/work-items.js'; } from './db/work-items.js';
import { import {
claimPairedTurnReservationInDatabase, claimPairedTurnReservationInDatabase,
clearPairedTaskExecutionLeasesForServiceInDatabase,
clearExpiredPairedTaskExecutionLeasesInDatabase,
clearPairedTaskExecutionLeasesInDatabase, clearPairedTaskExecutionLeasesInDatabase,
clearPairedTurnReservationsInDatabase, clearPairedTurnReservationsInDatabase,
type PairedTaskUpdates, type PairedTaskUpdates,
@@ -156,12 +125,29 @@ import {
} from './db/service-handoffs.js'; } from './db/service-handoffs.js';
import { import {
getLastRespondingAgentTypeFromDatabase, getLastRespondingAgentTypeFromDatabase,
getUnsupportedRouterStateKeysFromDatabase,
getRouterStateForServiceFromDatabase, getRouterStateForServiceFromDatabase,
getRouterStateFromDatabase, getRouterStateFromDatabase,
setRouterStateForServiceInDatabase, setRouterStateForServiceInDatabase,
setRouterStateInDatabase, setRouterStateInDatabase,
} from './db/router-state.js'; } from './db/router-state.js';
import {
type AssignRoomInput,
assignRoomInDatabase,
clearExplicitRoomModeInDatabase,
deleteStoredRoomSettingsForTestsInDatabase,
getAllRoomBindingsFromDatabase,
getEffectiveRoomModeFromDatabase,
getEffectiveRuntimeRoomModeFromDatabase,
getExplicitRoomModeFromDatabase,
getRegisteredAgentTypesForJidFromDatabase,
getRegisteredGroupFromDatabase,
getStoredRoomSettingsFromDatabase,
migrateLegacyRoomRegistrationsForTestsInDatabase,
setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase,
updateRegisteredGroupNameInDatabase,
} from './db/rooms.js';
import { import {
deleteAllSessionsForGroupFromDatabase, deleteAllSessionsForGroupFromDatabase,
deleteSessionFromDatabase, deleteSessionFromDatabase,
@@ -169,7 +155,6 @@ import {
getSessionFromDatabase, getSessionFromDatabase,
setSessionInDatabase, setSessionInDatabase,
} from './db/sessions.js'; } from './db/sessions.js';
import { tableHasColumn } from './db/schema.js';
import { import {
type CreateScheduledTaskInput, type CreateScheduledTaskInput,
type ScheduledTaskStatusTrackingUpdates, type ScheduledTaskStatusTrackingUpdates,
@@ -187,10 +172,7 @@ import {
updateTaskInDatabase, updateTaskInDatabase,
updateTaskStatusTrackingInDatabase, updateTaskStatusTrackingInDatabase,
} from './db/tasks.js'; } from './db/tasks.js';
import { import { inferAgentTypeFromServiceShadow } from './role-service-shadow.js';
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from './role-service-shadow.js';
import { getTaskRuntimeTaskId } from './task-watch-status.js'; import { getTaskRuntimeTaskId } from './task-watch-status.js';
import { import {
NewMessage, NewMessage,
@@ -210,22 +192,7 @@ import {
export { inferRoomModeFromRegisteredAgentTypes }; export { inferRoomModeFromRegisteredAgentTypes };
let db: Database; let db: Database;
export type { AssignRoomInput } from './db/rooms.js';
interface StoredRoomModeRow {
roomMode: RoomMode;
source: RoomModeSource;
}
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
ownerAgentType?: AgentType;
folder?: string;
isMain?: boolean;
workDir?: string;
addedAt?: string;
ownerAgentConfig?: RegisteredGroup['agentConfig'];
}
export type { export type {
MemoryRecord, MemoryRecord,
MemoryScopeKind, MemoryScopeKind,
@@ -240,40 +207,17 @@ export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.js';
export type { ServiceHandoff } from './db/service-handoffs.js'; export type { ServiceHandoff } from './db/service-handoffs.js';
export function initDatabase(): void { export function initDatabase(): void {
db = openPersistentDatabase(); db = openInitializedPersistentDatabase();
initializeDatabaseSchema(db);
assertNoPendingLegacyRoomMigration();
assertNoUnexpectedDataStateFiles();
assertNoUnsupportedRouterStateDbKeys();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
);
clearExpiredPairedTaskExecutionLeasesInDatabase(db);
} }
/** @internal - for tests only. Creates a fresh in-memory database. */ /** @internal - for tests only. Creates a fresh in-memory database. */
export function _initTestDatabase(): void { export function _initTestDatabase(): void {
db = openInMemoryDatabase(); db = openInitializedInMemoryDatabase();
initializeDatabaseSchema(db);
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
);
clearExpiredPairedTaskExecutionLeasesInDatabase(db);
} }
/** @internal - for tests only. Opens an existing database file and runs schema/migrations. */ /** @internal - for tests only. Opens an existing database file and runs schema/migrations. */
export function _initTestDatabaseFromFile(dbPath: string): void { export function _initTestDatabaseFromFile(dbPath: string): void {
db = openDatabaseFromFile(dbPath); db = openInitializedDatabaseFromFile(dbPath);
initializeDatabaseSchema(db);
assertNoPendingLegacyRoomMigration();
assertNoUnsupportedRouterStateDbKeys();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
);
clearExpiredPairedTaskExecutionLeasesInDatabase(db);
} }
/** @internal - for tests only. */ /** @internal - for tests only. */
@@ -284,21 +228,7 @@ export function _setStoredRoomOwnerAgentTypeForTests(
if (!db) { if (!db) {
throw new Error('Database not initialized'); throw new Error('Database not initialized');
} }
db.prepare( setStoredRoomOwnerAgentTypeForTestsInDatabase(db, chatJid, ownerAgentType);
`UPDATE room_settings
SET owner_agent_type = ?,
updated_at = ?
WHERE chat_jid = ?`,
).run(ownerAgentType, new Date().toISOString(), chatJid);
if (ownerAgentType) {
const now = new Date().toISOString();
upsertRoomRoleOverride(db, chatJid, {
role: 'owner',
agentType: ownerAgentType,
createdAt: now,
updatedAt: now,
});
}
} }
/** @internal - for tests only. */ /** @internal - for tests only. */
@@ -306,7 +236,7 @@ export function _deleteStoredRoomSettingsForTests(chatJid: string): void {
if (!db) { if (!db) {
throw new Error('Database not initialized'); throw new Error('Database not initialized');
} }
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); deleteStoredRoomSettingsForTestsInDatabase(db, chatJid);
} }
/** @internal - for tests only. */ /** @internal - for tests only. */
@@ -745,84 +675,7 @@ export function getRegisteredGroup(
jid: string, jid: string,
agentType?: string, agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const requestedAgentType = normalizeStoredAgentType(agentType); return getRegisteredGroupFromDatabase(db, jid, agentType);
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
if (stored) {
return buildRegisteredGroupFromStoredSettings(
db,
stored,
requestedAgentType,
);
}
return undefined;
}
function writeLegacyRegisteredGroupAndSyncRoomSettings(
jid: string,
group: RegisteredGroup,
): void {
const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid);
const existingRoomMode = getStoredRoomModeRow(jid);
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
const tx = db.transaction(() => {
const seededAgentType = group.agentType || 'claude-code';
const agentTypes = new Set<AgentType>(collectRegisteredAgentTypes(db, jid));
agentTypes.add(seededAgentType);
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
...agentTypes,
]);
const roomMode =
existingRoomMode?.source === 'explicit'
? existingRoomMode.roomMode
: inferredRoomMode;
const ownerAgentType =
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
? existingStored.ownerAgentType
: inferOwnerAgentTypeFromRegisteredAgentTypes([...agentTypes]);
const snapshot: RoomRegistrationSnapshot = {
name: group.name,
folder: group.folder,
triggerPattern:
existingStored?.modeSource === 'explicit' && existingStored.trigger
? existingStored.trigger
: (group.trigger ?? ''),
requiresTrigger:
group.requiresTrigger ?? existingStored?.requiresTrigger ?? false,
isMain: group.isMain ?? existingStored?.isMain ?? false,
ownerAgentType,
workDir: group.workDir ?? existingStored?.workDir ?? null,
};
if (existingStored) {
updateStoredRoomMetadata(db, jid, snapshot);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
upsertStoredRoomMode(jid, roomMode, 'inferred');
}
} else {
insertStoredRoomSettings(db, jid, roomMode, 'inferred', snapshot);
}
materializeRegisteredGroupsForRoom(
db,
jid,
snapshot,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
syncRoomRoleOverridesForRoom(
db,
jid,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
});
tx();
} }
/** /**
@@ -833,7 +686,7 @@ export function _setRegisteredGroupForTests(
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
): void { ): void {
writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); setRegisteredGroupForTestsInDatabase(db, jid, group);
} }
export function _migrateLegacyRoomRegistrationsForTests(): { export function _migrateLegacyRoomRegistrationsForTests(): {
@@ -843,294 +696,56 @@ export function _migrateLegacyRoomRegistrationsForTests(): {
if (!db) { if (!db) {
throw new Error('Database not initialized'); throw new Error('Database not initialized');
} }
return migrateLegacyRoomRegistrationsForTestsInDatabase(db);
const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(db);
let migratedRooms = 0;
let migratedRoleOverrides = 0;
db.transaction(() => {
for (const jid of jids) {
const plan = buildLegacyRoomMigrationPlan(db, jid);
if (!plan) continue;
insertStoredRoomSettingsFromMigration(db, plan);
for (const override of plan.roleOverrides) {
upsertRoomRoleOverride(db, jid, override);
migratedRoleOverrides += 1;
}
migratedRooms += 1;
}
})();
return { migratedRooms, migratedRoleOverrides };
} }
export function assignRoom( export function assignRoom(
chatJid: string, chatJid: string,
input: AssignRoomInput, input: AssignRoomInput,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const existing = getStoredRoomSettingsRowFromDatabase(db, chatJid); return assignRoomInDatabase(db, chatJid, input);
const roomMode = input.roomMode || existing?.roomMode || 'single';
const ownerAgentType =
input.ownerAgentType || existing?.ownerAgentType || OWNER_AGENT_TYPE;
const folder = resolveAssignedRoomFolder(
db,
chatJid,
input.name,
input.folder,
);
const snapshot: RoomRegistrationSnapshot = {
name: input.name,
folder,
triggerPattern: existing?.trigger ?? '',
requiresTrigger: existing?.requiresTrigger ?? false,
isMain: input.isMain ?? existing?.isMain ?? false,
ownerAgentType,
workDir: input.workDir ?? existing?.workDir ?? null,
};
const now = new Date().toISOString();
db.transaction(() => {
if (existing) {
db.prepare(
`UPDATE room_settings
SET room_mode = ?,
mode_source = 'explicit',
name = ?,
folder = ?,
trigger_pattern = ?,
requires_trigger = ?,
is_main = ?,
owner_agent_type = ?,
work_dir = ?,
updated_at = ?
WHERE chat_jid = ?`,
).run(
roomMode,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
now,
chatJid,
);
} else {
insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot);
}
syncRoomRoleOverridesForRoom(
db,
chatJid,
roomMode,
ownerAgentType,
input.ownerAgentConfig,
input.addedAt ?? now,
);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
})();
return getRegisteredGroup(chatJid);
} }
export function updateRegisteredGroupName(jid: string, name: string): void { export function updateRegisteredGroupName(jid: string, name: string): void {
const plan = buildRoomRegistrationPlanForJid(jid, { name }); updateRegisteredGroupNameInDatabase(db, jid, name);
if (!plan) {
return;
}
db.transaction(() => {
if (plan.hasStoredRoom) {
updateStoredRoomMetadata(db, jid, plan.snapshot);
} else {
insertStoredRoomSettings(
db,
jid,
plan.roomMode,
'inferred',
plan.snapshot,
);
}
deleteLegacyRegisteredGroupRowsForJid(db, jid);
})();
} }
export function getAllRoomBindings( export function getAllRoomBindings(
agentTypeFilter?: string, agentTypeFilter?: string,
): Record<string, RegisteredGroup> { ): Record<string, RegisteredGroup> {
const result: Record<string, RegisteredGroup> = {}; return getAllRoomBindingsFromDatabase(db, agentTypeFilter);
const requestedAgentType = normalizeStoredAgentType(agentTypeFilter);
const storedRows = getStoredRoomRowsFromDatabase(db);
for (const stored of storedRows) {
const group = buildRegisteredGroupFromStoredSettings(
db,
stored,
requestedAgentType,
);
if (group) {
const { jid, ...rest } = group;
result[jid] = rest;
}
}
return result;
} }
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] { export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return []; if (!db) return [];
const stored = getStoredRoomSettingsRowFromDatabase(db, jid); return getRegisteredAgentTypesForJidFromDatabase(db, jid);
return stored ? resolveStoredRoomCapabilityTypes(db, stored) : [];
} }
export function getStoredRoomSettings( export function getStoredRoomSettings(
chatJid: string, chatJid: string,
): StoredRoomSettings | undefined { ): StoredRoomSettings | undefined {
if (!db) return undefined; if (!db) return undefined;
return getStoredRoomSettingsRowFromDatabase(db, chatJid); return getStoredRoomSettingsFromDatabase(db, chatJid);
}
function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
const row = getStoredRoomSettings(chatJid);
return row
? {
roomMode: row.roomMode,
source: row.modeSource,
}
: undefined;
}
function assertNoPendingLegacyRoomMigration(): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
if (pendingLegacyRows === 0) {
return;
}
throw new Error(
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows})`,
);
}
function assertNoUnexpectedDataStateFiles(): void {
const pendingFiles = listUnexpectedDataStateFiles(DATA_DIR);
if (pendingFiles.length === 0) {
return;
}
throw new Error(
`Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`,
);
}
function assertNoUnsupportedRouterStateDbKeys(): void {
const legacyKeys = getUnsupportedRouterStateKeysFromDatabase(db);
if (legacyKeys.length === 0) {
return;
}
throw new Error(
`Unsupported router_state DB keys remain before startup (keys=${legacyKeys.join(',')})`,
);
}
function buildRoomRegistrationSnapshotFromStoredRoom(
stored: StoredRoomSettings,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
): RoomRegistrationSnapshot {
const capabilityTypes = resolveStoredRoomCapabilityTypes(db, stored);
const ownerAgentType =
stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: OWNER_AGENT_TYPE);
const name = overrides?.name ?? stored.name ?? stored.chatJid;
return {
name,
folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder),
triggerPattern: stored.trigger ?? '',
requiresTrigger: stored.requiresTrigger ?? false,
isMain: stored.isMain ?? false,
ownerAgentType,
workDir: stored.workDir ?? null,
};
}
function buildRoomRegistrationPlanForJid(
chatJid: string,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
):
| {
snapshot: RoomRegistrationSnapshot;
roomMode: RoomMode;
hasStoredRoom: boolean;
}
| undefined {
const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid);
if (stored) {
return {
snapshot: buildRoomRegistrationSnapshotFromStoredRoom(stored, overrides),
roomMode: stored.roomMode,
hasStoredRoom: true,
};
}
return undefined;
}
function upsertStoredRoomMode(
chatJid: string,
roomMode: RoomMode,
source: RoomModeSource,
): void {
db.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(chat_jid) DO UPDATE SET
room_mode = excluded.room_mode,
mode_source = excluded.mode_source,
updated_at = excluded.updated_at`,
).run(chatJid, roomMode, source, new Date().toISOString());
} }
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
const row = getStoredRoomModeRow(chatJid); return getExplicitRoomModeFromDatabase(db, chatJid);
return row?.source === 'explicit' ? row.roomMode : undefined;
} }
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
upsertStoredRoomMode(chatJid, roomMode, 'explicit'); setExplicitRoomModeInDatabase(db, chatJid, roomMode);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
} }
export function clearExplicitRoomMode(chatJid: string): void { export function clearExplicitRoomMode(chatJid: string): void {
const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid); clearExplicitRoomModeInDatabase(db, chatJid);
const agentTypes = stored
? inferStoredRoomCapabilityTypes(db, stored)
: collectRegisteredAgentTypes(db, chatJid);
if (agentTypes.length === 0) {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
return;
}
upsertStoredRoomMode(
chatJid,
inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred',
);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
} }
export function getEffectiveRoomMode(chatJid: string): RoomMode { export function getEffectiveRoomMode(chatJid: string): RoomMode {
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single'; return getEffectiveRoomModeFromDatabase(db, chatJid);
} }
export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode { export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
return getStoredRoomSettings(chatJid)?.roomMode ?? 'single'; return getEffectiveRuntimeRoomModeFromDatabase(db, chatJid);
} }
// --- Paired task/project/workspace state --- // --- Paired task/project/workspace state ---

View File

@@ -0,0 +1,83 @@
import { Database } from 'bun:sqlite';
import { DATA_DIR, normalizeServiceId, SERVICE_ID } from '../config.js';
import { listUnexpectedDataStateFiles } from '../data-state-files.js';
import {
openDatabaseFromFile,
openInMemoryDatabase,
openPersistentDatabase,
initializeDatabaseSchema,
} from './bootstrap.js';
import { countPendingLegacyRegisteredGroupRows } from './room-registration.js';
import {
clearExpiredPairedTaskExecutionLeasesInDatabase,
clearPairedTaskExecutionLeasesForServiceInDatabase,
} from './paired-state.js';
import { getUnsupportedRouterStateKeysFromDatabase } from './router-state.js';
function finalizeDatabaseInitialization(database: Database): void {
clearPairedTaskExecutionLeasesForServiceInDatabase(
database,
normalizeServiceId(SERVICE_ID),
);
clearExpiredPairedTaskExecutionLeasesInDatabase(database);
}
function assertNoPendingLegacyRoomMigration(database: Database): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(database);
if (pendingLegacyRows === 0) {
return;
}
throw new Error(
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows})`,
);
}
function assertNoUnexpectedDataStateFiles(): void {
const pendingFiles = listUnexpectedDataStateFiles(DATA_DIR);
if (pendingFiles.length === 0) {
return;
}
throw new Error(
`Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`,
);
}
function assertNoUnsupportedRouterStateDbKeys(database: Database): void {
const unsupportedKeys = getUnsupportedRouterStateKeysFromDatabase(database);
if (unsupportedKeys.length === 0) {
return;
}
throw new Error(
`Unsupported router_state DB keys remain before startup (keys=${unsupportedKeys.join(',')})`,
);
}
export function openInitializedPersistentDatabase(): Database {
const database = openPersistentDatabase();
initializeDatabaseSchema(database);
assertNoPendingLegacyRoomMigration(database);
assertNoUnexpectedDataStateFiles();
assertNoUnsupportedRouterStateDbKeys(database);
finalizeDatabaseInitialization(database);
return database;
}
export function openInitializedInMemoryDatabase(): Database {
const database = openInMemoryDatabase();
initializeDatabaseSchema(database);
finalizeDatabaseInitialization(database);
return database;
}
export function openInitializedDatabaseFromFile(dbPath: string): Database {
const database = openDatabaseFromFile(dbPath);
initializeDatabaseSchema(database);
assertNoPendingLegacyRoomMigration(database);
assertNoUnsupportedRouterStateDbKeys(database);
finalizeDatabaseInitialization(database);
return database;
}

499
src/db/rooms.ts Normal file
View File

@@ -0,0 +1,499 @@
import { Database } from 'bun:sqlite';
import { OWNER_AGENT_TYPE } from '../config.js';
import { isValidGroupFolder } from '../group-folder.js';
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
import {
type RoomModeSource,
type RoomRegistrationSnapshot,
type StoredRoomSettings,
buildLegacyRoomMigrationPlan,
buildRegisteredGroupFromStoredSettings,
collectRegisteredAgentTypes,
countPendingLegacyRegisteredGroupRows,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
inferStoredRoomCapabilityTypes,
insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom,
normalizeStoredAgentType,
resolveAssignedRoomFolder,
resolveStoredRoomCapabilityTypes,
syncRoomRoleOverridesForRoom,
updateStoredRoomMetadata,
upsertRoomRoleOverride,
} from './room-registration.js';
interface StoredRoomModeRow {
roomMode: RoomMode;
source: RoomModeSource;
}
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
ownerAgentType?: AgentType;
folder?: string;
isMain?: boolean;
workDir?: string;
addedAt?: string;
ownerAgentConfig?: RegisteredGroup['agentConfig'];
}
export function setStoredRoomOwnerAgentTypeForTestsInDatabase(
database: Database,
chatJid: string,
ownerAgentType: AgentType | null,
): void {
database
.prepare(
`UPDATE room_settings
SET owner_agent_type = ?,
updated_at = ?
WHERE chat_jid = ?`,
)
.run(ownerAgentType, new Date().toISOString(), chatJid);
if (ownerAgentType) {
const now = new Date().toISOString();
upsertRoomRoleOverride(database, chatJid, {
role: 'owner',
agentType: ownerAgentType,
createdAt: now,
updatedAt: now,
});
}
}
export function deleteStoredRoomSettingsForTestsInDatabase(
database: Database,
chatJid: string,
): void {
database.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
}
export function getRegisteredGroupFromDatabase(
database: Database,
jid: string,
agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined {
const requestedAgentType = normalizeStoredAgentType(agentType);
const stored = getStoredRoomSettingsRowFromDatabase(database, jid);
if (!stored) {
return undefined;
}
return buildRegisteredGroupFromStoredSettings(
database,
stored,
requestedAgentType,
);
}
function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
database: Database,
jid: string,
group: RegisteredGroup,
): void {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const existingRoomMode = getStoredRoomModeRowFromDatabase(database, jid);
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
database.transaction(() => {
const seededAgentType = group.agentType || 'claude-code';
const agentTypes = new Set<AgentType>(
collectRegisteredAgentTypes(database, jid),
);
agentTypes.add(seededAgentType);
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
...agentTypes,
]);
const roomMode =
existingRoomMode?.source === 'explicit'
? existingRoomMode.roomMode
: inferredRoomMode;
const ownerAgentType =
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
? existingStored.ownerAgentType
: inferOwnerAgentTypeFromRegisteredAgentTypes([...agentTypes]);
const snapshot: RoomRegistrationSnapshot = {
name: group.name,
folder: group.folder,
triggerPattern:
existingStored?.modeSource === 'explicit' && existingStored.trigger
? existingStored.trigger
: (group.trigger ?? ''),
requiresTrigger:
group.requiresTrigger ?? existingStored?.requiresTrigger ?? false,
isMain: group.isMain ?? existingStored?.isMain ?? false,
ownerAgentType,
workDir: group.workDir ?? existingStored?.workDir ?? null,
};
if (existingStored) {
updateStoredRoomMetadata(database, jid, snapshot);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
upsertStoredRoomModeInDatabase(database, jid, roomMode, 'inferred');
}
} else {
insertStoredRoomSettings(database, jid, roomMode, 'inferred', snapshot);
}
materializeRegisteredGroupsForRoom(
database,
jid,
snapshot,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
syncRoomRoleOverridesForRoom(
database,
jid,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
})();
}
export function setRegisteredGroupForTestsInDatabase(
database: Database,
jid: string,
group: RegisteredGroup,
): void {
writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(database, jid, group);
}
export function migrateLegacyRoomRegistrationsForTestsInDatabase(
database: Database,
): {
migratedRooms: number;
migratedRoleOverrides: number;
} {
const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(database);
let migratedRooms = 0;
let migratedRoleOverrides = 0;
database.transaction(() => {
for (const jid of jids) {
const plan = buildLegacyRoomMigrationPlan(database, jid);
if (!plan) continue;
insertStoredRoomSettingsFromMigration(database, plan);
for (const override of plan.roleOverrides) {
upsertRoomRoleOverride(database, jid, override);
migratedRoleOverrides += 1;
}
migratedRooms += 1;
}
})();
return { migratedRooms, migratedRoleOverrides };
}
export function assignRoomInDatabase(
database: Database,
chatJid: string,
input: AssignRoomInput,
): (RegisteredGroup & { jid: string }) | undefined {
const existing = getStoredRoomSettingsRowFromDatabase(database, chatJid);
const roomMode = input.roomMode || existing?.roomMode || 'single';
const ownerAgentType =
input.ownerAgentType || existing?.ownerAgentType || OWNER_AGENT_TYPE;
const folder = resolveAssignedRoomFolder(
database,
chatJid,
input.name,
input.folder,
);
const snapshot: RoomRegistrationSnapshot = {
name: input.name,
folder,
triggerPattern: existing?.trigger ?? '',
requiresTrigger: existing?.requiresTrigger ?? false,
isMain: input.isMain ?? existing?.isMain ?? false,
ownerAgentType,
workDir: input.workDir ?? existing?.workDir ?? null,
};
const now = new Date().toISOString();
database.transaction(() => {
if (existing) {
database
.prepare(
`UPDATE room_settings
SET room_mode = ?,
mode_source = 'explicit',
name = ?,
folder = ?,
trigger_pattern = ?,
requires_trigger = ?,
is_main = ?,
owner_agent_type = ?,
work_dir = ?,
updated_at = ?
WHERE chat_jid = ?`,
)
.run(
roomMode,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
now,
chatJid,
);
} else {
insertStoredRoomSettings(
database,
chatJid,
roomMode,
'explicit',
snapshot,
);
}
syncRoomRoleOverridesForRoom(
database,
chatJid,
roomMode,
ownerAgentType,
input.ownerAgentConfig,
input.addedAt ?? now,
);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
})();
return getRegisteredGroupFromDatabase(database, chatJid);
}
export function updateRegisteredGroupNameInDatabase(
database: Database,
jid: string,
name: string,
): void {
const plan = buildRoomRegistrationPlanForJid(database, jid, { name });
if (!plan) {
return;
}
database.transaction(() => {
if (plan.hasStoredRoom) {
updateStoredRoomMetadata(database, jid, plan.snapshot);
} else {
insertStoredRoomSettings(
database,
jid,
plan.roomMode,
'inferred',
plan.snapshot,
);
}
deleteLegacyRegisteredGroupRowsForJid(database, jid);
})();
}
export function getAllRoomBindingsFromDatabase(
database: Database,
agentTypeFilter?: string,
): Record<string, RegisteredGroup> {
const result: Record<string, RegisteredGroup> = {};
const requestedAgentType = normalizeStoredAgentType(agentTypeFilter);
const storedRows = getStoredRoomRowsFromDatabase(database);
for (const stored of storedRows) {
const group = buildRegisteredGroupFromStoredSettings(
database,
stored,
requestedAgentType,
);
if (!group) continue;
const { jid, ...rest } = group;
result[jid] = rest;
}
return result;
}
export function getRegisteredAgentTypesForJidFromDatabase(
database: Database,
jid: string,
): AgentType[] {
const stored = getStoredRoomSettingsRowFromDatabase(database, jid);
return stored ? resolveStoredRoomCapabilityTypes(database, stored) : [];
}
export function getStoredRoomSettingsFromDatabase(
database: Database,
chatJid: string,
): StoredRoomSettings | undefined {
return getStoredRoomSettingsRowFromDatabase(database, chatJid);
}
function getStoredRoomModeRowFromDatabase(
database: Database,
chatJid: string,
): StoredRoomModeRow | undefined {
const row = getStoredRoomSettingsFromDatabase(database, chatJid);
return row
? {
roomMode: row.roomMode,
source: row.modeSource,
}
: undefined;
}
function buildRoomRegistrationSnapshotFromStoredRoom(
database: Database,
stored: StoredRoomSettings,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
): RoomRegistrationSnapshot {
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const ownerAgentType =
stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: OWNER_AGENT_TYPE);
const name = overrides?.name ?? stored.name ?? stored.chatJid;
return {
name,
folder: resolveAssignedRoomFolder(
database,
stored.chatJid,
name,
stored.folder,
),
triggerPattern: stored.trigger ?? '',
requiresTrigger: stored.requiresTrigger ?? false,
isMain: stored.isMain ?? false,
ownerAgentType,
workDir: stored.workDir ?? null,
};
}
function buildRoomRegistrationPlanForJid(
database: Database,
chatJid: string,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
):
| {
snapshot: RoomRegistrationSnapshot;
roomMode: RoomMode;
hasStoredRoom: boolean;
}
| undefined {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
if (!stored) {
return undefined;
}
return {
snapshot: buildRoomRegistrationSnapshotFromStoredRoom(
database,
stored,
overrides,
),
roomMode: stored.roomMode,
hasStoredRoom: true,
};
}
function upsertStoredRoomModeInDatabase(
database: Database,
chatJid: string,
roomMode: RoomMode,
source: RoomModeSource,
): void {
database
.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(chat_jid) DO UPDATE SET
room_mode = excluded.room_mode,
mode_source = excluded.mode_source,
updated_at = excluded.updated_at`,
)
.run(chatJid, roomMode, source, new Date().toISOString());
}
export function getExplicitRoomModeFromDatabase(
database: Database,
chatJid: string,
): RoomMode | undefined {
const row = getStoredRoomModeRowFromDatabase(database, chatJid);
return row?.source === 'explicit' ? row.roomMode : undefined;
}
export function setExplicitRoomModeInDatabase(
database: Database,
chatJid: string,
roomMode: RoomMode,
): void {
upsertStoredRoomModeInDatabase(database, chatJid, roomMode, 'explicit');
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
}
export function clearExplicitRoomModeInDatabase(
database: Database,
chatJid: string,
): void {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
const agentTypes = stored
? inferStoredRoomCapabilityTypes(database, stored)
: collectRegisteredAgentTypes(database, chatJid);
if (agentTypes.length === 0) {
database
.prepare('DELETE FROM room_settings WHERE chat_jid = ?')
.run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
return;
}
upsertStoredRoomModeInDatabase(
database,
chatJid,
inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred',
);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
}
export function getEffectiveRoomModeFromDatabase(
database: Database,
chatJid: string,
): RoomMode {
return (
getStoredRoomModeRowFromDatabase(database, chatJid)?.roomMode ?? 'single'
);
}
export function getEffectiveRuntimeRoomModeFromDatabase(
database: Database,
chatJid: string,
): RoomMode {
return (
getStoredRoomSettingsFromDatabase(database, chatJid)?.roomMode ?? 'single'
);
}
export function countPendingLegacyRegisteredGroupRowsInDatabase(
database: Database,
): number {
return countPendingLegacyRegisteredGroupRows(database);
}

View File

@@ -1,6 +1,3 @@
import fs from 'fs';
import path from 'path';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
DATA_DIR, DATA_DIR,
@@ -20,27 +17,17 @@ import {
getRegisteredChannelNames, getRegisteredChannelNames,
} from './channels/registry.js'; } from './channels/registry.js';
import { writeGroupsSnapshot } from './agent-runner.js'; import { writeGroupsSnapshot } from './agent-runner.js';
import { listAvailableGroups } from './available-groups.js';
import { import {
type AssignRoomInput, type AssignRoomInput,
assignRoom,
getAllRoomBindings,
getAllSessions,
getAllTasks, getAllTasks,
getLatestMessageSeqAtOrBefore,
hasRecentRestartAnnouncement, hasRecentRestartAnnouncement,
getRouterState,
initDatabase, initDatabase,
setRouterState,
deleteAllSessionsForGroup,
deleteSession,
setSession,
storeChatMetadata, storeChatMetadata,
storeMessage, storeMessage,
} from './db.js'; } from './db.js';
import { composeDashboardContent } from './dashboard-render.js'; import { composeDashboardContent } from './dashboard-render.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { resolveGroupIpcPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js'; import { startIpcWatcher } from './ipc.js';
import { import {
findChannel, findChannel,
@@ -69,7 +56,6 @@ import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
import { startUnifiedDashboard } from './unified-dashboard.js'; import { startUnifiedDashboard } from './unified-dashboard.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { normalizeStoredSeqCursor } from './message-cursor.js';
import { initCodexTokenRotation } from './codex-token-rotation.js'; import { initCodexTokenRotation } from './codex-token-rotation.js';
import { import {
hasAvailableClaudeToken, hasAvailableClaudeToken,
@@ -88,6 +74,7 @@ import {
clearGlobalFailover, clearGlobalFailover,
getGlobalFailoverInfo, getGlobalFailoverInfo,
} from './service-routing.js'; } from './service-routing.js';
import { createRuntimeState } from './runtime-state.js';
import { FAILOVER_MIN_DURATION_MS } from './config.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js';
// Token rotation is initialized lazily on first use or at startup below // Token rotation is initialized lazily on first use or at startup below
@@ -137,13 +124,9 @@ export async function editFormattedTrackedChannelMessage(
await channel.editMessage(jid, messageId, text); await channel.editMessage(jid, messageId, text);
} }
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let roomBindings: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
const channels: Channel[] = []; const channels: Channel[] = [];
const queue = new GroupQueue(); const queue = new GroupQueue();
const runtimeState = createRuntimeState();
const runtime = createMessageRuntime({ const runtime = createMessageRuntime({
assistantName: ASSISTANT_NAME, assistantName: ASSISTANT_NAME,
idleTimeout: IDLE_TIMEOUT, idleTimeout: IDLE_TIMEOUT,
@@ -152,109 +135,36 @@ const runtime = createMessageRuntime({
triggerPattern: TRIGGER_PATTERN, triggerPattern: TRIGGER_PATTERN,
channels, channels,
queue, queue,
getRoomBindings: () => roomBindings, getRoomBindings: runtimeState.getRoomBindings,
getSessions: () => sessions, getSessions: runtimeState.getSessions,
getLastTimestamp: () => lastTimestamp, getLastTimestamp: runtimeState.getLastTimestamp,
setLastTimestamp: (timestamp) => { setLastTimestamp: runtimeState.setLastTimestamp,
lastTimestamp = timestamp; getLastAgentTimestamps: runtimeState.getLastAgentTimestamps,
}, saveState: runtimeState.saveState,
getLastAgentTimestamps: () => lastAgentTimestamp, persistSession: runtimeState.persistSession,
saveState, clearSession: runtimeState.clearSession,
persistSession: (groupFolder, sessionId) => {
sessions[groupFolder] = sessionId;
setSession(groupFolder, sessionId);
},
clearSession,
}); });
function loadState(): void {
lastTimestamp = normalizeStoredSeqCursor(getRouterState('last_seq'));
const agentTs = getRouterState('last_agent_seq');
try {
const parsed = agentTs
? (JSON.parse(agentTs) as Record<string, string>)
: {};
lastAgentTimestamp = Object.fromEntries(
Object.entries(parsed).map(([chatJid, cursor]) => [
chatJid,
normalizeStoredSeqCursor(cursor, chatJid),
]),
);
} catch {
logger.warn('Corrupted last_agent_seq in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
roomBindings = getAllRoomBindings();
logger.info(
{
groupCount: Object.keys(roomBindings).length,
agentType: 'unified',
},
'State loaded',
);
}
function saveState(): void {
setRouterState('last_seq', lastTimestamp);
setRouterState('last_agent_seq', JSON.stringify(lastAgentTimestamp));
}
function clearSession(
groupFolder: string,
opts?: { allRoles?: boolean },
): void {
delete sessions[groupFolder];
if (opts?.allRoles) {
deleteAllSessionsForGroup(groupFolder);
} else {
deleteSession(groupFolder);
}
}
function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
const assignedGroup = assignRoom(jid, input);
if (!assignedGroup) {
logger.warn({ jid }, 'Failed to assign room from IPC');
return;
}
let groupDir: string;
try {
groupDir = resolveGroupFolderPath(assignedGroup.folder);
} catch (err) {
logger.warn(
{ jid, folder: assignedGroup.folder, err },
'Rejecting room assignment with invalid folder',
);
return;
}
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
roomBindings[jid] = storedGroup;
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
}
/** /**
* Get available groups list for the agent. * Get available groups list for the agent.
* Returns groups ordered by most recent activity. * Returns groups ordered by most recent activity.
*/ */
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] { export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
return listAvailableGroups(roomBindings); return runtimeState.getAvailableGroups();
} }
/** @internal - exported for testing */ /** @internal - exported for testing */
export function _setRegisteredGroups( export function _setRegisteredGroups(
groups: Record<string, RegisteredGroup>, groups: Record<string, RegisteredGroup>,
): void { ): void {
roomBindings = groups; runtimeState.setRoomBindings(groups);
} }
/** @internal - exported for testing */ /** @internal - exported for testing */
export function _setRoomBindings( export function _setRoomBindings(
groups: Record<string, RegisteredGroup>, groups: Record<string, RegisteredGroup>,
): void { ): void {
roomBindings = groups; runtimeState.setRoomBindings(groups);
} }
async function announceRestartRecovery( async function announceRestartRecovery(
@@ -295,7 +205,10 @@ async function announceRestartRecovery(
return explicitContext; return explicitContext;
} }
const inferred = inferRecentRestartContext(roomBindings, processStartedAtMs); const inferred = inferRecentRestartContext(
runtimeState.getRoomBindings(),
processStartedAtMs,
);
if (!inferred) return null; if (!inferred) return null;
if (hasRecentRestartAnnouncement(inferred.chatJid, dedupeSince)) { if (hasRecentRestartAnnouncement(inferred.chatJid, dedupeSince)) {
@@ -326,7 +239,7 @@ async function main(): Promise<void> {
initCodexTokenRotation(); initCodexTokenRotation();
startTokenRefreshLoop(); startTokenRefreshLoop();
loadState(); runtimeState.loadState();
// Graceful shutdown handlers // Graceful shutdown handlers
let leaseRecoveryTimer: ReturnType<typeof setInterval> | null = null; let leaseRecoveryTimer: ReturnType<typeof setInterval> | null = null;
@@ -337,6 +250,7 @@ async function main(): Promise<void> {
clearInterval(leaseRecoveryTimer); clearInterval(leaseRecoveryTimer);
leaseRecoveryTimer = null; leaseRecoveryTimer = null;
} }
const roomBindings = runtimeState.getRoomBindings();
const interruptedGroups = queue const interruptedGroups = queue
.getStatuses(Object.keys(roomBindings)) .getStatuses(Object.keys(roomBindings))
.filter( .filter(
@@ -380,6 +294,7 @@ async function main(): Promise<void> {
const channelOpts = { const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => { onMessage: (chatJid: string, msg: NewMessage) => {
// Sender allowlist drop mode: discard messages from denied senders before storing // Sender allowlist drop mode: discard messages from denied senders before storing
const roomBindings = runtimeState.getRoomBindings();
if (!msg.is_from_me && !msg.is_bot_message && roomBindings[chatJid]) { if (!msg.is_from_me && !msg.is_bot_message && roomBindings[chatJid]) {
const cfg = loadSenderAllowlist(); const cfg = loadSenderAllowlist();
if ( if (
@@ -404,7 +319,7 @@ async function main(): Promise<void> {
channel?: string, channel?: string,
isGroup?: boolean, isGroup?: boolean,
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
roomBindings: () => roomBindings, roomBindings: runtimeState.getRoomBindings,
}; };
// Create and connect all registered channels. // Create and connect all registered channels.
@@ -437,8 +352,8 @@ async function main(): Promise<void> {
); );
startSchedulerLoop({ startSchedulerLoop({
roomBindings: () => roomBindings, roomBindings: runtimeState.getRoomBindings,
getSessions: () => sessions, getSessions: runtimeState.getSessions,
queue, queue,
onProcess: (groupJid, proc, processName, ipcDir) => onProcess: (groupJid, proc, processName, ipcDir) =>
queue.registerProcess(groupJid, proc, processName, ipcDir), queue.registerProcess(groupJid, proc, processName, ipcDir),
@@ -480,8 +395,8 @@ async function main(): Promise<void> {
} }
}, },
nudgeScheduler: nudgeSchedulerLoop, nudgeScheduler: nudgeSchedulerLoop,
roomBindings: () => roomBindings, roomBindings: runtimeState.getRoomBindings,
assignRoom: assignRoomForIpc, assignRoom: runtimeState.assignRoomForIpc,
syncGroups: async (force: boolean) => { syncGroups: async (force: boolean) => {
await Promise.all( await Promise.all(
channels channels
@@ -496,6 +411,7 @@ async function main(): Promise<void> {
queue.enterRecoveryMode(); queue.enterRecoveryMode();
runtime.recoverPendingMessages(); runtime.recoverPendingMessages();
const restartContext = await announceRestartRecovery(processStartedAtMs); const restartContext = await announceRestartRecovery(processStartedAtMs);
const roomBindings = runtimeState.getRoomBindings();
for (const candidate of getInterruptedRecoveryCandidates( for (const candidate of getInterruptedRecoveryCandidates(
restartContext, restartContext,
roomBindings, roomBindings,
@@ -524,9 +440,9 @@ async function main(): Promise<void> {
usageUpdateInterval: USAGE_UPDATE_INTERVAL, usageUpdateInterval: USAGE_UPDATE_INTERVAL,
channels, channels,
queue, queue,
roomBindings: () => roomBindings, roomBindings: runtimeState.getRoomBindings,
onGroupNameSynced: (jid, name) => { onGroupNameSynced: (jid, name) => {
const group = roomBindings[jid]; const group = runtimeState.getRoomBindings()[jid];
if (group) { if (group) {
group.name = name; group.name = name;
} }

View File

@@ -0,0 +1,267 @@
import { type AgentOutput } from './agent-runner.js';
import { getLastBotFinalMessage } from './db.js';
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import {
getEffectiveChannelLease,
hasReviewerLease,
resolveLeaseServiceId,
} from './service-routing.js';
import { normalizeMessageForDedupe } from './router.js';
import type { ExecuteTurnFn } from './message-runtime-types.js';
import type {
Channel,
NewMessage,
AgentType,
PairedRoomRole,
RegisteredGroup,
} from './types.js';
import type { GroupQueue } from './group-queue.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { logger } from './logger.js';
export function isDuplicateOfLastBotFinal(
chatJid: string,
text: string,
): boolean {
if (!hasReviewerLease(chatJid)) {
return false;
}
const lastMessages = getLastBotFinalMessage(chatJid, 'claude-code', 1);
if (lastMessages.length === 0) {
return false;
}
const lastMessage = lastMessages[0];
const normalizedLast = normalizeMessageForDedupe(lastMessage.content);
const normalizedCurrent = normalizeMessageForDedupe(text);
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
}
export function labelPairedSenders(
channels: Channel[],
chatJid: string,
messages: NewMessage[],
): NewMessage[] {
if (!hasReviewerLease(chatJid)) return messages;
const botIdToChannelName = new Map<string, string>();
for (const ch of channels) {
if (!ch.isConnected()) continue;
for (const msg of messages) {
if (msg.is_bot_message && ch.isOwnMessage?.(msg)) {
botIdToChannelName.set(msg.sender, ch.name);
}
}
}
const channelToRole: Record<string, PairedRoomRole> = {
discord: 'owner',
'discord-review': 'reviewer',
'discord-arbiter': 'arbiter',
};
return messages.map((msg) => {
if (!msg.is_bot_message) return msg;
const channelName = botIdToChannelName.get(msg.sender);
if (!channelName) return msg;
const role = channelToRole[channelName];
return role ? { ...msg, sender_name: role } : msg;
});
}
interface CreateExecuteTurnDeps {
runAgent: (
group: RegisteredGroup,
prompt: string,
chatJid: string,
runId: string,
onOutput?: (output: AgentOutput) => Promise<void>,
options?: {
startSeq?: number | null;
endSeq?: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
},
) => Promise<'success' | 'error'>;
assistantName: string;
idleTimeout: number;
failureFinalText: string;
channels: Channel[];
queue: GroupQueue;
getRoomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
persistSession: (groupFolder: string, sessionId: string) => void;
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
deliverFinalText: (args: {
text: string;
chatJid: string;
runId: string;
channel: Channel;
group: RegisteredGroup;
startSeq: number | null;
endSeq: number | null;
forcedAgentType?: AgentType;
deliveryRole: PairedRoomRole | null;
deliveryServiceId: string | null;
}) => Promise<boolean>;
afterDeliverySuccess?: (args: {
chatJid: string;
runId: string;
deliveryRole: PairedRoomRole | null;
pairedRoom: boolean;
}) => Promise<void>;
}
export function createRunAgent(deps: {
assistantName: string;
queue: GroupQueue;
getRoomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
persistSession: (groupFolder: string, sessionId: string) => void;
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
}) {
return async (
group: RegisteredGroup,
prompt: string,
chatJid: string,
runId: string,
onOutput?: (output: AgentOutput) => Promise<void>,
options?: {
startSeq?: number | null;
endSeq?: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
},
): Promise<'success' | 'error'> =>
runAgentForGroup(deps, {
group,
prompt,
chatJid,
runId,
startSeq: options?.startSeq,
endSeq: options?.endSeq,
hasHumanMessage: options?.hasHumanMessage,
forcedRole: options?.forcedRole,
forcedAgentType: options?.forcedAgentType,
pairedTurnIdentity: options?.pairedTurnIdentity,
onOutput,
});
}
export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
return async (args) => {
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
const isClaudeCodeAgent =
(args.forcedAgentType ?? group.agentType ?? 'claude-code') ===
'claude-code';
const pairedRoom = hasReviewerLease(chatJid);
const resolvedDeliveryRole =
args.deliveryRole ?? args.forcedRole ?? (pairedRoom ? 'owner' : null);
const resolvedDeliveryServiceId = resolveLeaseServiceId(
getEffectiveChannelLease(chatJid),
resolvedDeliveryRole ?? 'owner',
);
const allowProgressReplayWithoutFinal =
args.pairedTurnIdentity?.role !== 'reviewer' &&
args.pairedTurnIdentity?.role !== 'arbiter';
const turnController = new MessageTurnController({
chatJid,
group,
runId,
channel,
idleTimeout: deps.idleTimeout,
failureFinalText: deps.failureFinalText,
isClaudeCodeAgent,
clearSession: () => deps.clearSession(group.folder),
requestClose: (reason) =>
deps.queue.closeStdin(chatJid, { runId, reason }),
allowProgressReplayWithoutFinal,
deliveryRole: resolvedDeliveryRole,
deliveryServiceId: resolvedDeliveryServiceId,
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
deliverFinalText: async (text) => {
try {
return await deps.deliverFinalText({
text,
chatJid,
runId,
channel,
group,
startSeq,
endSeq,
forcedAgentType: args.forcedAgentType,
deliveryRole: resolvedDeliveryRole,
deliveryServiceId: resolvedDeliveryServiceId,
});
} catch (err) {
logger.warn(
{ group: group.name, chatJid, runId, err },
'Failed to persist produced output for delivery',
);
return false;
}
},
});
await turnController.start();
try {
const outputStatus = await deps.runAgent(
group,
prompt,
chatJid,
runId,
async (result) => {
await turnController.handleOutput(result);
},
{
startSeq,
endSeq,
hasHumanMessage: args.hasHumanMessage,
forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType,
pairedTurnIdentity: args.pairedTurnIdentity,
},
);
const { deliverySucceeded, visiblePhase } =
await turnController.finish(outputStatus);
if (deliverySucceeded) {
await deps.afterDeliverySuccess?.({
chatJid,
runId,
deliveryRole: resolvedDeliveryRole,
pairedRoom,
});
}
return {
outputStatus,
deliverySucceeded,
visiblePhase,
};
} finally {
turnController.cancelPendingTypingDelay();
logger.debug(
{
transition: 'typing:off',
source: 'message-runtime:safety-net',
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
},
'Typing indicator transition',
);
await channel.setTyping?.(chatJid, false);
}
};
}

View File

@@ -1,10 +1,7 @@
import { AgentOutput } from './agent-runner.js';
import { getAgentOutputText } from './agent-output.js';
import { import {
createProducedWorkItem, createProducedWorkItem,
getOpenWorkItemForChat, getOpenWorkItemForChat,
getMessagesSinceSeq, getMessagesSinceSeq,
getLastBotFinalMessage,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getPairedTaskById, getPairedTaskById,
} from './db.js'; } from './db.js';
@@ -13,12 +10,7 @@ import {
SERVICE_SESSION_SCOPE, SERVICE_SESSION_SCOPE,
} from './config.js'; } from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js'; import { GroupQueue, GroupRunContext } from './group-queue.js';
import { import { findChannel, findChannelByName, formatMessages } from './router.js';
findChannel,
findChannelByName,
formatMessages,
normalizeMessageForDedupe,
} from './router.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowUp } from './message-runtime-dispatch.js'; import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowUp } from './message-runtime-dispatch.js';
import { import {
@@ -50,25 +42,17 @@ import {
enqueuePairedFollowUpAfterEvent, enqueuePairedFollowUpAfterEvent,
schedulePairedFollowUpWithMessageCheck, schedulePairedFollowUpWithMessageCheck,
} from './message-runtime-follow-up.js'; } from './message-runtime-follow-up.js';
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js'; import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
import { import {
Channel, Channel,
NewMessage, NewMessage,
type AgentType,
type PairedRoomRole, type PairedRoomRole,
RegisteredGroup, RegisteredGroup,
type PairedTask, type PairedTask,
} from './types.js'; } from './types.js';
import { createScopedLogger, logger } from './logger.js'; import { createScopedLogger, logger } from './logger.js';
import { import { hasReviewerLease } from './service-routing.js';
getEffectiveChannelLease,
hasReviewerLease,
resolveLeaseServiceId,
} from './service-routing.js';
export { export {
resolveHandoffCursorKey, resolveHandoffCursorKey,
resolveHandoffRoleOverride, resolveHandoffRoleOverride,
@@ -77,31 +61,14 @@ import {
getFixedRoleChannelName, getFixedRoleChannelName,
getMissingRoleChannelMessage, getMissingRoleChannelMessage,
} from './message-runtime-shared.js'; } from './message-runtime-shared.js';
import {
createRunAgent,
createExecuteTurn,
isDuplicateOfLastBotFinal,
labelPairedSenders,
} from './message-runtime-turns.js';
/** export { isDuplicateOfLastBotFinal };
* Check if a message is a duplicate of the last bot final message in a paired room.
* Exported for testing purposes.
*/
export function isDuplicateOfLastBotFinal(
chatJid: string,
text: string,
): boolean {
if (!hasReviewerLease(chatJid)) {
return false;
}
// Get the last bot final message from DB (any bot, not just this service)
const lastMessages = getLastBotFinalMessage(chatJid, 'claude-code', 1);
if (lastMessages.length === 0) {
return false;
}
const lastMessage = lastMessages[0];
const normalizedLast = normalizeMessageForDedupe(lastMessage.content);
const normalizedCurrent = normalizeMessageForDedupe(text);
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
}
export interface MessageRuntimeDeps { export interface MessageRuntimeDeps {
assistantName: string; assistantName: string;
@@ -134,37 +101,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const continuationTracker = createImplicitContinuationTracker( const continuationTracker = createImplicitContinuationTracker(
deps.idleTimeout, deps.idleTimeout,
); );
// In paired rooms, replace bot sender_name with role label so agents const labelPairedRuntimeSenders = (
// know who is the owner and who is the reviewer regardless of bot nickname.
const labelPairedSenders = (
chatJid: string, chatJid: string,
messages: NewMessage[], messages: NewMessage[],
): NewMessage[] => { ): NewMessage[] => labelPairedSenders(deps.channels, chatJid, messages);
if (!hasReviewerLease(chatJid)) return messages;
// Build bot-user-id → channel-name mapping from connected channels
const botIdToChannelName = new Map<string, string>();
for (const ch of deps.channels) {
if (!ch.isConnected()) continue;
// Probe each bot message to find which channel owns it
for (const msg of messages) {
if (msg.is_bot_message && ch.isOwnMessage?.(msg)) {
botIdToChannelName.set(msg.sender, ch.name);
}
}
}
const channelToRole: Record<string, PairedRoomRole> = {
discord: 'owner',
'discord-review': 'reviewer',
'discord-arbiter': 'arbiter',
};
return messages.map((msg) => {
if (!msg.is_bot_message) return msg;
const channelName = botIdToChannelName.get(msg.sender);
if (!channelName) return msg;
const role = channelToRole[channelName];
return role ? { ...msg, sender_name: role } : msg;
});
};
const enqueueScopedGroupMessageCheck = buildScopedMessageCheckEnqueuer( const enqueueScopedGroupMessageCheck = buildScopedMessageCheckEnqueuer(
deps.queue, deps.queue,
@@ -197,117 +137,50 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
): boolean => { ): boolean => {
return isDuplicateOfLastBotFinal(chatJid, text); return isDuplicateOfLastBotFinal(chatJid, text);
}; };
const runAgent = createRunAgent({
const runAgent = async (
group: RegisteredGroup,
prompt: string,
chatJid: string,
runId: string,
onOutput?: (output: AgentOutput) => Promise<void>,
options?: {
startSeq?: number | null;
endSeq?: number | null;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
},
): Promise<'success' | 'error'> =>
runAgentForGroup(
{
assistantName: deps.assistantName, assistantName: deps.assistantName,
queue: deps.queue, queue: deps.queue,
getRoomBindings: deps.getRoomBindings, getRoomBindings: deps.getRoomBindings,
getSessions: deps.getSessions, getSessions: deps.getSessions,
persistSession: deps.persistSession, persistSession: deps.persistSession,
clearSession: deps.clearSession, clearSession: deps.clearSession,
}, });
{ const executeTurn = createExecuteTurn({
group, runAgent,
prompt, assistantName: deps.assistantName,
chatJid,
runId,
startSeq: options?.startSeq,
endSeq: options?.endSeq,
hasHumanMessage: options?.hasHumanMessage,
forcedRole: options?.forcedRole,
forcedAgentType: options?.forcedAgentType,
pairedTurnIdentity: options?.pairedTurnIdentity,
onOutput,
},
);
const executeTurn = async (args: {
group: RegisteredGroup;
prompt: string;
chatJid: string;
runId: string;
channel: Channel;
startSeq: number | null;
endSeq: number | null;
deliveryRole?: PairedRoomRole;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}): Promise<{
outputStatus: 'success' | 'error';
deliverySucceeded: boolean;
visiblePhase: ReturnType<MessageTurnController['finish']> extends Promise<
infer T
>
? T extends { visiblePhase: infer V }
? V
: never
: never;
}> => {
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
const isClaudeCodeAgent =
(args.forcedAgentType ?? group.agentType ?? 'claude-code') ===
'claude-code';
const pairedRoom = hasReviewerLease(chatJid);
const resolvedDeliveryRole =
args.deliveryRole ?? args.forcedRole ?? (pairedRoom ? 'owner' : null);
const resolvedDeliveryServiceId = resolveLeaseServiceId(
getEffectiveChannelLease(chatJid),
resolvedDeliveryRole ?? 'owner',
);
const allowProgressReplayWithoutFinal =
args.pairedTurnIdentity?.role !== 'reviewer' &&
args.pairedTurnIdentity?.role !== 'arbiter';
const turnController = new MessageTurnController({
chatJid,
group,
runId,
channel,
idleTimeout: deps.idleTimeout, idleTimeout: deps.idleTimeout,
failureFinalText: FAILURE_FINAL_TEXT, failureFinalText: FAILURE_FINAL_TEXT,
isClaudeCodeAgent, channels: deps.channels,
clearSession: () => deps.clearSession(group.folder), queue: deps.queue,
requestClose: (reason) => getRoomBindings: deps.getRoomBindings,
deps.queue.closeStdin(chatJid, { runId, reason }), getSessions: deps.getSessions,
allowProgressReplayWithoutFinal, persistSession: deps.persistSession,
deliveryRole: resolvedDeliveryRole, clearSession: deps.clearSession,
deliveryServiceId: resolvedDeliveryServiceId, deliverFinalText: async ({
pairedTurnIdentity: args.pairedTurnIdentity ?? null, text,
deliverFinalText: async (text) => { chatJid,
try { runId,
const persistedDeliveryRole = resolvedDeliveryRole; channel,
const persistedDeliveryServiceId = resolvedDeliveryServiceId; group,
startSeq,
endSeq,
forcedAgentType,
deliveryRole,
deliveryServiceId,
}) => {
if ( if (
(persistedDeliveryRole === 'reviewer' || (deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
persistedDeliveryRole === 'arbiter') &&
deps.queue.hasDirectTerminalDeliveryForRun?.( deps.queue.hasDirectTerminalDeliveryForRun?.(
chatJid, chatJid,
runId, runId,
persistedDeliveryRole, deliveryRole,
) )
) { ) {
logger.info( logger.info(
{ {
chatJid, chatJid,
runId, runId,
deliveryRole: persistedDeliveryRole, deliveryRole,
}, },
'Skipping final work item delivery because this run already sent a direct terminal IPC message', 'Skipping final work item delivery because this run already sent a direct terminal IPC message',
); );
@@ -316,10 +189,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const workItem = createProducedWorkItem({ const workItem = createProducedWorkItem({
group_folder: group.folder, group_folder: group.folder,
chat_jid: chatJid, chat_jid: chatJid,
agent_type: agent_type: forcedAgentType ?? group.agentType ?? 'claude-code',
args.forcedAgentType ?? group.agentType ?? 'claude-code', service_id: deliveryServiceId ?? undefined,
service_id: persistedDeliveryServiceId ?? undefined, delivery_role: deliveryRole,
delivery_role: persistedDeliveryRole,
start_seq: startSeq, start_seq: startSeq,
end_seq: endSeq, end_seq: endSeq,
result_payload: text, result_payload: text,
@@ -332,50 +204,24 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
openContinuation: (targetChatJid) => openContinuation: (targetChatJid) =>
continuationTracker.open(targetChatJid), continuationTracker.open(targetChatJid),
}); });
} catch (err) {
logger.warn(
{ group: group.name, chatJid, runId, err },
'Failed to persist produced output for delivery',
);
return false;
}
}, },
}); afterDeliverySuccess: async ({
await turnController.start();
try {
const outputStatus = await runAgent(
group,
prompt,
chatJid, chatJid,
runId, runId,
async (result) => { deliveryRole,
await turnController.handleOutput(result); pairedRoom,
}, }) => {
{ if (!deliveryRole || !pairedRoom) {
startSeq, return;
endSeq, }
hasHumanMessage: args.hasHumanMessage, const pendingTaskAfterDelivery = getLatestOpenPairedTaskForChat(chatJid);
forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType,
pairedTurnIdentity: args.pairedTurnIdentity,
},
);
const { deliverySucceeded, visiblePhase } =
await turnController.finish(outputStatus);
if (deliverySucceeded && pairedRoom && resolvedDeliveryRole) {
const pendingTaskAfterDelivery =
getLatestOpenPairedTaskForChat(chatJid);
const followUpResult = enqueuePairedFollowUpAfterEvent({ const followUpResult = enqueuePairedFollowUpAfterEvent({
chatJid, chatJid,
runId, runId,
task: pendingTaskAfterDelivery, task: pendingTaskAfterDelivery,
source: 'delivery-success', source: 'delivery-success',
completedRole: resolvedDeliveryRole, completedRole: deliveryRole,
fallbackLastTurnOutputRole: resolvedDeliveryRole, fallbackLastTurnOutputRole: deliveryRole,
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid), enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
}); });
if (followUpResult.kind === 'paired-follow-up') { if (followUpResult.kind === 'paired-follow-up') {
@@ -383,44 +229,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
{ {
chatJid, chatJid,
runId, runId,
completedRole: resolvedDeliveryRole, completedRole: deliveryRole,
taskId: followUpResult.taskId, taskId: followUpResult.taskId,
taskStatus: followUpResult.taskStatus, taskStatus: followUpResult.taskStatus,
intentKind: followUpResult.intentKind, intentKind: followUpResult.intentKind,
scheduled: followUpResult.scheduled, scheduled: followUpResult.scheduled,
}, },
followUpResult.scheduled followUpResult.scheduled
? resolvedDeliveryRole === 'owner' ? deliveryRole === 'owner'
? 'Queued paired follow-up after successful owner delivery' ? 'Queued paired follow-up after successful owner delivery'
: 'Queued paired follow-up after successful reviewer/arbiter delivery' : 'Queued paired follow-up after successful reviewer/arbiter delivery'
: resolvedDeliveryRole === 'owner' : deliveryRole === 'owner'
? 'Skipped duplicate paired follow-up after successful owner delivery while task state was unchanged' ? 'Skipped duplicate paired follow-up after successful owner delivery while task state was unchanged'
: 'Skipped duplicate paired follow-up after successful reviewer/arbiter delivery while task state was unchanged', : 'Skipped duplicate paired follow-up after successful reviewer/arbiter delivery while task state was unchanged',
); );
} }
}
return {
outputStatus,
deliverySucceeded,
visiblePhase,
};
} finally {
turnController.cancelPendingTypingDelay();
logger.debug(
{
transition: 'typing:off',
source: 'message-runtime:safety-net',
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
}, },
'Typing indicator transition', });
);
await channel.setTyping?.(chatJid, false);
}
};
const enqueuePendingHandoffs = (): void => { const enqueuePendingHandoffs = (): void => {
enqueueClaimedServiceHandoffs({ enqueueClaimedServiceHandoffs({
@@ -568,7 +393,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
executeTurn, executeTurn,
getFixedRoleChannelName, getFixedRoleChannelName,
roleToChannel, roleToChannel,
labelPairedSenders, labelPairedSenders: labelPairedRuntimeSenders,
mode: 'idle', mode: 'idle',
}); });
if (pendingTurnOutcome !== null) { if (pendingTurnOutcome !== null) {
@@ -602,7 +427,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
executeTurn, executeTurn,
getFixedRoleChannelName, getFixedRoleChannelName,
roleToChannel, roleToChannel,
labelPairedSenders, labelPairedSenders: labelPairedRuntimeSenders,
mode: 'bot-only', mode: 'bot-only',
missedMessages, missedMessages,
}); });
@@ -697,7 +522,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
saveState: deps.saveState, saveState: deps.saveState,
executeTurn, executeTurn,
getFixedRoleChannelName, getFixedRoleChannelName,
labelPairedSenders, labelPairedSenders: labelPairedRuntimeSenders,
formatMessages, formatMessages,
}); });
} }
@@ -735,7 +560,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.queue.closeStdin(chatJid, { deps.queue.closeStdin(chatJid, {
reason, reason,
}), }),
labelPairedSenders, labelPairedSenders: labelPairedRuntimeSenders,
}); });
} catch (err) { } catch (err) {
logger.error({ err }, 'Error in message loop'); logger.error({ err }, 'Error in message loop');

133
src/runtime-state.ts Normal file
View File

@@ -0,0 +1,133 @@
import fs from 'fs';
import path from 'path';
import { listAvailableGroups } from './available-groups.js';
import {
type AssignRoomInput,
assignRoom,
deleteAllSessionsForGroup,
deleteSession,
getAllRoomBindings,
getAllSessions,
getRouterState,
setRouterState,
setSession,
} from './db.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { logger } from './logger.js';
import { normalizeStoredSeqCursor } from './message-cursor.js';
import type { AgentType, RegisteredGroup } from './types.js';
export interface RuntimeState {
loadState: () => void;
saveState: () => void;
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
assignRoomForIpc: (jid: string, input: AssignRoomInput) => void;
getAvailableGroups: () => import('./agent-runner.js').AvailableGroup[];
getLastTimestamp: () => string;
setLastTimestamp: (timestamp: string) => void;
getLastAgentTimestamps: () => Record<string, string>;
getSessions: () => Record<string, string>;
persistSession: (groupFolder: string, sessionId: string) => void;
getRoomBindings: () => Record<string, RegisteredGroup>;
setRoomBindings: (groups: Record<string, RegisteredGroup>) => void;
}
export function createRuntimeState(): RuntimeState {
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let roomBindings: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
const saveState = (): void => {
setRouterState('last_seq', lastTimestamp);
setRouterState('last_agent_seq', JSON.stringify(lastAgentTimestamp));
};
const clearSession = (
groupFolder: string,
opts?: { allRoles?: boolean },
): void => {
delete sessions[groupFolder];
if (opts?.allRoles) {
deleteAllSessionsForGroup(groupFolder);
} else {
deleteSession(groupFolder);
}
};
const persistSession = (groupFolder: string, sessionId: string): void => {
sessions[groupFolder] = sessionId;
setSession(groupFolder, sessionId);
};
const loadState = (): void => {
lastTimestamp = normalizeStoredSeqCursor(getRouterState('last_seq'));
const agentTs = getRouterState('last_agent_seq');
try {
const parsed = agentTs
? (JSON.parse(agentTs) as Record<string, string>)
: {};
lastAgentTimestamp = Object.fromEntries(
Object.entries(parsed).map(([chatJid, cursor]) => [
chatJid,
normalizeStoredSeqCursor(cursor, chatJid),
]),
);
} catch {
logger.warn('Corrupted last_agent_seq in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
roomBindings = getAllRoomBindings();
logger.info(
{
groupCount: Object.keys(roomBindings).length,
agentType: 'unified' satisfies AgentType | 'unified',
},
'State loaded',
);
};
const assignRoomForIpc = (jid: string, input: AssignRoomInput): void => {
const assignedGroup = assignRoom(jid, input);
if (!assignedGroup) {
logger.warn({ jid }, 'Failed to assign room from IPC');
return;
}
let groupDir: string;
try {
groupDir = resolveGroupFolderPath(assignedGroup.folder);
} catch (err) {
logger.warn(
{ jid, folder: assignedGroup.folder, err },
'Rejecting room assignment with invalid folder',
);
return;
}
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
roomBindings[jid] = storedGroup;
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
};
return {
loadState,
saveState,
clearSession,
assignRoomForIpc,
getAvailableGroups: () => listAvailableGroups(roomBindings),
getLastTimestamp: () => lastTimestamp,
setLastTimestamp: (timestamp) => {
lastTimestamp = timestamp;
},
getLastAgentTimestamps: () => lastAgentTimestamp,
getSessions: () => sessions,
persistSession,
getRoomBindings: () => roomBindings,
setRoomBindings: (groups) => {
roomBindings = groups;
},
};
}