refactor: decompose runtime coordination and runner internals (PR6)
This commit is contained in:
@@ -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=="],
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
protectedPaths
|
...new Set(
|
||||||
.filter((value): value is string => Boolean(value))
|
protectedPaths
|
||||||
.map((value) => path.resolve(value)),
|
.filter((value): value is string => Boolean(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))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
20
runners/shared/bun.lock
Normal 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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
15
runners/shared/package.json
Normal file
15
runners/shared/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
runners/shared/src/index.ts
Normal file
9
runners/shared/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
prependRoomRoleHeader,
|
||||||
|
type RoomRoleContext,
|
||||||
|
} from './room-role-context.js';
|
||||||
|
export {
|
||||||
|
assertReadonlyWorkspaceRepoConnectivity,
|
||||||
|
buildReviewerGitGuardEnv,
|
||||||
|
isReviewerRuntime,
|
||||||
|
} from './reviewer-git-guard.js';
|
||||||
254
runners/shared/src/reviewer-git-guard.ts
Normal file
254
runners/shared/src/reviewer-git-guard.ts
Normal 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}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
runners/shared/src/room-role-context.ts
Normal file
23
runners/shared/src/room-role-context.ts
Normal 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}`;
|
||||||
|
}
|
||||||
15
runners/shared/tsconfig.json
Normal file
15
runners/shared/tsconfig.json
Normal 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
428
src/agent-runner-process.ts
Normal 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}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
processName,
|
||||||
proc.stdout.on('data', (data) => {
|
logsDir,
|
||||||
const chunk = data.toString();
|
startTime,
|
||||||
|
onOutput,
|
||||||
if (!stdoutTruncated) {
|
}).then(resolve);
|
||||||
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,
|
|
||||||
},
|
|
||||||
'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,
|
|
||||||
`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',
|
|
||||||
);
|
|
||||||
// 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
469
src/db.ts
@@ -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 ---
|
||||||
|
|||||||
83
src/db/database-lifecycle.ts
Normal file
83
src/db/database-lifecycle.ts
Normal 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
499
src/db/rooms.ts
Normal 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);
|
||||||
|
}
|
||||||
142
src/index.ts
142
src/index.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
267
src/message-runtime-turns.ts
Normal file
267
src/message-runtime-turns.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,230 +137,115 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
return isDuplicateOfLastBotFinal(chatJid, text);
|
return isDuplicateOfLastBotFinal(chatJid, text);
|
||||||
};
|
};
|
||||||
|
const runAgent = createRunAgent({
|
||||||
const runAgent = async (
|
assistantName: deps.assistantName,
|
||||||
group: RegisteredGroup,
|
queue: deps.queue,
|
||||||
prompt: string,
|
getRoomBindings: deps.getRoomBindings,
|
||||||
chatJid: string,
|
getSessions: deps.getSessions,
|
||||||
runId: string,
|
persistSession: deps.persistSession,
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
clearSession: deps.clearSession,
|
||||||
options?: {
|
});
|
||||||
startSeq?: number | null;
|
const executeTurn = createExecuteTurn({
|
||||||
endSeq?: number | null;
|
runAgent,
|
||||||
hasHumanMessage?: boolean;
|
assistantName: deps.assistantName,
|
||||||
forcedRole?: PairedRoomRole;
|
idleTimeout: deps.idleTimeout,
|
||||||
forcedAgentType?: AgentType;
|
failureFinalText: FAILURE_FINAL_TEXT,
|
||||||
pairedTurnIdentity?: PairedTurnIdentity;
|
channels: deps.channels,
|
||||||
},
|
queue: deps.queue,
|
||||||
): Promise<'success' | 'error'> =>
|
getRoomBindings: deps.getRoomBindings,
|
||||||
runAgentForGroup(
|
getSessions: deps.getSessions,
|
||||||
{
|
persistSession: deps.persistSession,
|
||||||
assistantName: deps.assistantName,
|
clearSession: deps.clearSession,
|
||||||
queue: deps.queue,
|
deliverFinalText: async ({
|
||||||
getRoomBindings: deps.getRoomBindings,
|
text,
|
||||||
getSessions: deps.getSessions,
|
|
||||||
persistSession: deps.persistSession,
|
|
||||||
clearSession: deps.clearSession,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
prompt,
|
|
||||||
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,
|
chatJid,
|
||||||
group,
|
|
||||||
runId,
|
runId,
|
||||||
channel,
|
channel,
|
||||||
idleTimeout: deps.idleTimeout,
|
group,
|
||||||
failureFinalText: FAILURE_FINAL_TEXT,
|
startSeq,
|
||||||
isClaudeCodeAgent,
|
endSeq,
|
||||||
clearSession: () => deps.clearSession(group.folder),
|
forcedAgentType,
|
||||||
requestClose: (reason) =>
|
deliveryRole,
|
||||||
deps.queue.closeStdin(chatJid, { runId, reason }),
|
deliveryServiceId,
|
||||||
allowProgressReplayWithoutFinal,
|
}) => {
|
||||||
deliveryRole: resolvedDeliveryRole,
|
if (
|
||||||
deliveryServiceId: resolvedDeliveryServiceId,
|
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
|
||||||
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
|
deps.queue.hasDirectTerminalDeliveryForRun?.(
|
||||||
deliverFinalText: async (text) => {
|
chatJid,
|
||||||
try {
|
runId,
|
||||||
const persistedDeliveryRole = resolvedDeliveryRole;
|
deliveryRole,
|
||||||
const persistedDeliveryServiceId = resolvedDeliveryServiceId;
|
)
|
||||||
if (
|
) {
|
||||||
(persistedDeliveryRole === 'reviewer' ||
|
logger.info(
|
||||||
persistedDeliveryRole === 'arbiter') &&
|
{
|
||||||
deps.queue.hasDirectTerminalDeliveryForRun?.(
|
chatJid,
|
||||||
chatJid,
|
runId,
|
||||||
runId,
|
deliveryRole,
|
||||||
persistedDeliveryRole,
|
},
|
||||||
)
|
'Skipping final work item delivery because this run already sent a direct terminal IPC message',
|
||||||
) {
|
);
|
||||||
logger.info(
|
return true;
|
||||||
{
|
}
|
||||||
chatJid,
|
const workItem = createProducedWorkItem({
|
||||||
runId,
|
group_folder: group.folder,
|
||||||
deliveryRole: persistedDeliveryRole,
|
chat_jid: chatJid,
|
||||||
},
|
agent_type: forcedAgentType ?? group.agentType ?? 'claude-code',
|
||||||
'Skipping final work item delivery because this run already sent a direct terminal IPC message',
|
service_id: deliveryServiceId ?? undefined,
|
||||||
);
|
delivery_role: deliveryRole,
|
||||||
return true;
|
start_seq: startSeq,
|
||||||
}
|
end_seq: endSeq,
|
||||||
const workItem = createProducedWorkItem({
|
result_payload: text,
|
||||||
group_folder: group.folder,
|
});
|
||||||
chat_jid: chatJid,
|
return deliverOpenWorkItem({
|
||||||
agent_type:
|
channel,
|
||||||
args.forcedAgentType ?? group.agentType ?? 'claude-code',
|
item: workItem,
|
||||||
service_id: persistedDeliveryServiceId ?? undefined,
|
log: logger,
|
||||||
delivery_role: persistedDeliveryRole,
|
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||||
start_seq: startSeq,
|
openContinuation: (targetChatJid) =>
|
||||||
end_seq: endSeq,
|
continuationTracker.open(targetChatJid),
|
||||||
result_payload: text,
|
});
|
||||||
});
|
},
|
||||||
return deliverOpenWorkItem({
|
afterDeliverySuccess: async ({
|
||||||
channel,
|
chatJid,
|
||||||
item: workItem,
|
runId,
|
||||||
log: logger,
|
deliveryRole,
|
||||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
pairedRoom,
|
||||||
openContinuation: (targetChatJid) =>
|
}) => {
|
||||||
continuationTracker.open(targetChatJid),
|
if (!deliveryRole || !pairedRoom) {
|
||||||
});
|
return;
|
||||||
} catch (err) {
|
}
|
||||||
logger.warn(
|
const pendingTaskAfterDelivery = getLatestOpenPairedTaskForChat(chatJid);
|
||||||
{ group: group.name, chatJid, runId, err },
|
const followUpResult = enqueuePairedFollowUpAfterEvent({
|
||||||
'Failed to persist produced output for delivery',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await turnController.start();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const outputStatus = await runAgent(
|
|
||||||
group,
|
|
||||||
prompt,
|
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
async (result) => {
|
task: pendingTaskAfterDelivery,
|
||||||
await turnController.handleOutput(result);
|
source: 'delivery-success',
|
||||||
},
|
completedRole: deliveryRole,
|
||||||
{
|
fallbackLastTurnOutputRole: deliveryRole,
|
||||||
startSeq,
|
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
|
||||||
endSeq,
|
});
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
if (followUpResult.kind === 'paired-follow-up') {
|
||||||
forcedRole: args.forcedRole,
|
logger.info(
|
||||||
forcedAgentType: args.forcedAgentType,
|
{
|
||||||
pairedTurnIdentity: args.pairedTurnIdentity,
|
chatJid,
|
||||||
},
|
runId,
|
||||||
);
|
completedRole: deliveryRole,
|
||||||
|
taskId: followUpResult.taskId,
|
||||||
const { deliverySucceeded, visiblePhase } =
|
taskStatus: followUpResult.taskStatus,
|
||||||
await turnController.finish(outputStatus);
|
intentKind: followUpResult.intentKind,
|
||||||
|
scheduled: followUpResult.scheduled,
|
||||||
if (deliverySucceeded && pairedRoom && resolvedDeliveryRole) {
|
},
|
||||||
const pendingTaskAfterDelivery =
|
followUpResult.scheduled
|
||||||
getLatestOpenPairedTaskForChat(chatJid);
|
? deliveryRole === 'owner'
|
||||||
const followUpResult = enqueuePairedFollowUpAfterEvent({
|
? 'Queued paired follow-up after successful owner delivery'
|
||||||
chatJid,
|
: 'Queued paired follow-up after successful reviewer/arbiter delivery'
|
||||||
runId,
|
: deliveryRole === 'owner'
|
||||||
task: pendingTaskAfterDelivery,
|
? 'Skipped duplicate paired follow-up after successful owner delivery while task state was unchanged'
|
||||||
source: 'delivery-success',
|
: 'Skipped duplicate paired follow-up after successful reviewer/arbiter delivery while task state was unchanged',
|
||||||
completedRole: resolvedDeliveryRole,
|
);
|
||||||
fallbackLastTurnOutputRole: resolvedDeliveryRole,
|
|
||||||
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
|
|
||||||
});
|
|
||||||
if (followUpResult.kind === 'paired-follow-up') {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
chatJid,
|
|
||||||
runId,
|
|
||||||
completedRole: resolvedDeliveryRole,
|
|
||||||
taskId: followUpResult.taskId,
|
|
||||||
taskStatus: followUpResult.taskStatus,
|
|
||||||
intentKind: followUpResult.intentKind,
|
|
||||||
scheduled: followUpResult.scheduled,
|
|
||||||
},
|
|
||||||
followUpResult.scheduled
|
|
||||||
? resolvedDeliveryRole === 'owner'
|
|
||||||
? 'Queued paired follow-up after successful owner delivery'
|
|
||||||
: 'Queued paired follow-up after successful reviewer/arbiter delivery'
|
|
||||||
: resolvedDeliveryRole === 'owner'
|
|
||||||
? '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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
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
133
src/runtime-state.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user