Files
EJClaw/runners/agent-runner/src/index.ts

228 lines
7.4 KiB
TypeScript

/**
* EJClaw Agent Runner
* Runs as a child process, receives config via stdin, outputs result to stdout
*
* Input protocol:
* Stdin: Full RunnerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to $EJCLAW_IPC_DIR/input/
* Files: {type:"message", text:"..."}.json — polled and consumed
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
* Multiple results may be emitted (one per agent teams result).
* Final marker after loop ends signals completion.
*/
import fs from 'fs';
import path from 'path';
import {
EJCLAW_ENV,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
} from 'ejclaw-runners-shared';
import { fileURLToPath } from 'url';
import { prependRoomRoleHeader } from './room-role-context.js';
import {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
getClaudeReadonlySandboxMode,
isArbiterRuntimeEnvEnabled,
isClaudeReadonlyReviewerRuntime,
isReviewerRuntime,
isReviewerRuntimeEnvEnabled,
} from './reviewer-runtime.js';
import { drainIpcInput } from './ipc-input.js';
import {
buildCompactionOutput,
readStdin,
writeOutput,
} from './output-protocol.js';
import { buildClaudeSdkEnv } from './sdk-env.js';
import { runSessionCommand } from './session-command.js';
import { runClaudeQuery } from './claude-query-runner.js';
import type { RunnerInput } from './runner-input.js';
// Paths configurable via env vars.
const GROUP_DIR = process.env[EJCLAW_ENV.groupDir] || '/workspace/group';
const IPC_DIR = process.env[EJCLAW_ENV.ipcDir] || '/workspace/ipc';
const HOST_IPC_DIR = process.env[EJCLAW_ENV.hostIpcDir] || IPC_DIR;
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env[EJCLAW_ENV.workDir] || '';
const GROUP_FOLDER = process.env[EJCLAW_ENV.groupFolder] || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
// Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup
const agentAbortController = new AbortController();
process.on('SIGTERM', () => {
log('Received SIGTERM, aborting agent query...');
agentAbortController.abort();
});
async function main(): Promise<void> {
let runnerInput: RunnerInput;
try {
const stdinData = await readStdin();
runnerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${runnerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
});
process.exit(1);
}
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv = buildClaudeSdkEnv(process.env, runnerInput.secrets || {});
const reviewerRuntime =
isReviewerRuntimeEnvEnabled(process.env) ||
isReviewerRuntime(runnerInput.roomRoleContext);
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
runnerInput.roomRoleContext,
);
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
? getClaudeReadonlySandboxMode()
: null;
const readonlyRuntime =
reviewerRuntime ||
claudeReadonlyReviewerRuntime ||
isArbiterRuntimeEnvEnabled(process.env);
const guardedSdkEnv = buildReviewerGitGuardEnv(
sdkEnv,
reviewerRuntime || claudeReadonlyReviewerRuntime,
);
assertReadonlyWorkspaceRepoConnectivity(guardedSdkEnv, readonlyRuntime);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
let sessionId = runnerInput.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous runner sessions
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
// Effective working directory (WORK_DIR overrides GROUP_DIR)
const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
// Build initial prompt (drain any pending IPC messages too)
let prompt = runnerInput.prompt;
if (runnerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
}
const pending = drainIpcInput(IPC_INPUT_DIR, log);
if (pending.length > 0) {
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
prompt += '\n' + pending.join('\n');
}
// --- Slash command handling ---
// Check BEFORE prepending room role header so /compact isn't masked.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(
runnerInput.prompt.trim(),
);
if (!isSessionSlashCommand) {
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
}
const trimmedPrompt = prompt.trim();
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
await runSessionCommand({
prompt: trimmedPrompt,
sessionId,
cwd: mainEffectiveCwd,
sdkEnv: guardedSdkEnv,
abortController: agentAbortController,
assistantName: runnerInput.assistantName,
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
log,
});
return;
}
// --- End slash command handling ---
try {
log(`Starting query (session: ${sessionId || 'new'})...`);
const queryResult = await runClaudeQuery({
prompt,
sessionId,
mcpServerPath,
runnerInput,
sdkEnv: guardedSdkEnv,
reviewerRuntime,
claudeReadonlyReviewerRuntime,
claudeReadonlySandboxMode,
abortController: agentAbortController,
paths: {
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
ipcInputCloseSentinel: IPC_INPUT_CLOSE_SENTINEL,
ipcInputDir: IPC_INPUT_DIR,
workDir: WORK_DIR,
},
log,
});
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) {
writeOutput({
status: 'success',
result: null,
newSessionId: sessionId,
...buildCompactionOutput(queryResult.compaction),
});
} else if (queryResult.terminalResultObserved) {
log('Terminal result already emitted, exiting single-turn runtime');
} else {
log('Close sentinel consumed during query, exiting');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack : undefined;
const errorCause =
err instanceof Error && err.cause ? String(err.cause) : undefined;
log(`Agent error: ${errorMessage}`);
if (errorStack) log(`Stack: ${errorStack}`);
if (errorCause) log(`Cause: ${errorCause}`);
writeOutput({
status: 'error',
result: null,
newSessionId: sessionId,
error: errorMessage,
});
process.exit(1);
}
}
main();