580 lines
19 KiB
JavaScript
Executable File
580 lines
19 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (!arg.startsWith('--')) {
|
|
out._ = out._ || [];
|
|
out._.push(arg);
|
|
continue;
|
|
}
|
|
const key = arg.slice(2);
|
|
if (key === 'dry-run') {
|
|
out[key] = true;
|
|
continue;
|
|
}
|
|
const value = argv[i + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`Missing value for --${key}`);
|
|
}
|
|
out[key] = value;
|
|
i += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function usage() {
|
|
return `Usage:
|
|
node development/_reference/ai/scripts/run-pi-worker.mjs \\
|
|
--version v0.1.0 \\
|
|
--name "Add calm reset flow" \\
|
|
--task "Implement only the concrete worker task described by Codex."
|
|
|
|
Options:
|
|
--version Required development version, for example v0.6.47.
|
|
--name Optional run label. Defaults to a slug from --task.
|
|
--task Worker assignment text. Required unless --prompt-file is used.
|
|
--prompt-file Optional file with a longer Codex-authored assignment.
|
|
--session-id Optional Pi session id. Defaults to a stable version/name id.
|
|
--model Optional Pi model. Defaults to zai/glm-5.2.
|
|
--thinking Optional Pi thinking level. Defaults to xhigh, which Z.ai maps to reasoning_effort=max.
|
|
--pi Optional pi binary path. Defaults to PI_BIN or pi.
|
|
--pi-mode Optional Pi mode: json or text. Defaults to json.
|
|
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
|
|
--event-idle-timeout-ms
|
|
Optional watchdog timeout without worker output. Defaults to 600000 (10 minutes). Use 0 to disable.
|
|
--dry-run Write prompt/meta, print command, but do not launch Pi.
|
|
`;
|
|
}
|
|
|
|
function requireArg(args, name) {
|
|
if (!args[name]) throw new Error(`Missing required --${name}\n\n${usage()}`);
|
|
return args[name];
|
|
}
|
|
|
|
function slugify(value) {
|
|
return String(value || 'worker')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80) || 'worker';
|
|
}
|
|
|
|
function timestamp() {
|
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
}
|
|
|
|
function readOptionalFile(path) {
|
|
if (!path) return '';
|
|
return readFileSync(resolve(path), 'utf8').trim();
|
|
}
|
|
|
|
function parseNonNegativeInt(value, fallback) {
|
|
if (value === undefined) return fallback;
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
throw new Error(`Expected a non-negative integer, got: ${value}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function getGitStatus(cwd) {
|
|
try {
|
|
return execFileSync('git', ['status', '--short'], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
} catch (error) {
|
|
return `GIT_STATUS_ERROR: ${error.message || error}`;
|
|
}
|
|
}
|
|
|
|
function textFromContent(value) {
|
|
if (!value) return '';
|
|
if (typeof value === 'string') return value;
|
|
if (Array.isArray(value)) return value.map(textFromContent).filter(Boolean).join('');
|
|
if (typeof value !== 'object') return '';
|
|
|
|
if (typeof value.text === 'string') return value.text;
|
|
if (typeof value.delta === 'string') return value.delta;
|
|
if (typeof value.content === 'string') return value.content;
|
|
if (Array.isArray(value.content)) return textFromContent(value.content);
|
|
if (value.type === 'text' && typeof value.value === 'string') return value.value;
|
|
|
|
return '';
|
|
}
|
|
|
|
function textFromMessage(message) {
|
|
if (!message || message.role !== 'assistant') return '';
|
|
return textFromContent(message.content).trim();
|
|
}
|
|
|
|
function extractTextDelta(event) {
|
|
const update = event?.assistantMessageEvent;
|
|
if (!update || typeof update !== 'object') return '';
|
|
if (typeof update.delta === 'string') return update.delta;
|
|
if (typeof update.text === 'string') return update.text;
|
|
return '';
|
|
}
|
|
|
|
function findLastAssistantTextFromMessages(messages) {
|
|
if (!Array.isArray(messages)) return '';
|
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
const text = textFromMessage(messages[i]);
|
|
if (text) return text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function findFinalStatus(text) {
|
|
const matches = [...String(text || '').matchAll(/(?:^|\n)\s*WORKER_STATUS:\s*(WORKER_DONE|BLOCKED|NEEDS_DISCUSSION)\b/g)];
|
|
return matches.at(-1)?.[1] || null;
|
|
}
|
|
|
|
function summarizeOrchestratorStatus({ finalStatus, exitCode, signal, killedByIdle, gitStatusAfter }) {
|
|
if (killedByIdle) {
|
|
return gitStatusAfter ? 'WORKER_STALLED_WITH_DIFF' : 'WORKER_STALLED_NO_DIFF';
|
|
}
|
|
if (signal) return `PROCESS_SIGNAL_${signal}`;
|
|
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'PROCESS_FAILED';
|
|
if (finalStatus === 'WORKER_DONE') return 'WORKER_DONE_PENDING_CODEX_GATE';
|
|
if (finalStatus === 'BLOCKED') return 'WORKER_BLOCKED';
|
|
if (finalStatus === 'NEEDS_DISCUSSION') return 'WORKER_NEEDS_DISCUSSION';
|
|
if (exitCode === null || exitCode === undefined) return 'RUNNING_AWAITING_STATUS';
|
|
return 'NO_STRUCTURED_STATUS';
|
|
}
|
|
|
|
function renderEventForConsole(event) {
|
|
if (!event || typeof event !== 'object') return null;
|
|
if (event.type === 'message_update') return extractTextDelta(event) || null;
|
|
if (event.type === 'tool_execution_start') return `\n[pi] tool start: ${event.toolName || 'unknown'}\n`;
|
|
if (event.type === 'tool_execution_end') {
|
|
const status = event.isError ? 'error' : 'ok';
|
|
return `\n[pi] tool end: ${event.toolName || 'unknown'} (${status})\n`;
|
|
}
|
|
if (
|
|
event.type === 'agent_start' ||
|
|
event.type === 'agent_end' ||
|
|
event.type === 'turn_start' ||
|
|
event.type === 'turn_end' ||
|
|
event.type === 'compaction_start' ||
|
|
event.type === 'compaction_end' ||
|
|
event.type === 'auto_retry_start' ||
|
|
event.type === 'auto_retry_end' ||
|
|
event.type === 'extension_error'
|
|
) {
|
|
return `\n[pi] ${event.type}\n`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function assertRequiredFiles(cwd, version) {
|
|
const required = [
|
|
'AGENTS.md',
|
|
'development/_reference/ai/autonomous-agent-harness.md',
|
|
];
|
|
|
|
const missing = required.filter((file) => !existsSync(resolve(cwd, file)));
|
|
if (missing.length > 0) {
|
|
throw new Error(`Missing required orchestration files:\n${missing.map((file) => `- ${file}`).join('\n')}`);
|
|
}
|
|
}
|
|
|
|
function existingContextFiles(cwd, version) {
|
|
return [
|
|
'README.md',
|
|
`development/${version}/prd.md`,
|
|
`development/${version}/plan.md`,
|
|
`development/${version}/execution-log.md`,
|
|
'development/_reference/ai/autonomous-agent-harness.md',
|
|
].filter((file) => existsSync(resolve(cwd, file)));
|
|
}
|
|
|
|
function buildPrompt({ contextFiles, name, taskText, version }) {
|
|
const contextLine = contextFiles.length > 0
|
|
? `Read these context files when relevant: ${contextFiles.join(', ')}.`
|
|
: 'No version planning files were found; use the direct Codex assignment as the scoped contract.';
|
|
|
|
return `You are the Pi worker launched by Codex-Orchestrator for this project.
|
|
|
|
Codex remains the orchestrator, verifier, reviewer, and committer. You are only the bounded implementation worker.
|
|
|
|
Task label:
|
|
${name}
|
|
|
|
Worker assignment:
|
|
${taskText}
|
|
|
|
Repository contract:
|
|
- Read AGENTS.md before editing.
|
|
- ${contextLine}
|
|
- The direct Codex assignment is the binding scope. Do not broaden it into adjacent cleanup, dependent tasks, or a full release pass.
|
|
- If the assignment is too large to finish safely, implement a coherent safe subset and report the recommended next worker task.
|
|
- Do not edit planning or reference docs unless the assignment explicitly asks.
|
|
- Do not create or update execution logs unless the assignment explicitly asks. Durable progress is captured by Codex through reviewed git checkpoint commits.
|
|
- Do not commit, push, merge, reset, clean, or rewrite git history.
|
|
- Do not deploy, publish, or touch external services unless the assignment explicitly says so.
|
|
- Do not run destructive cleanup or wipe commands.
|
|
- Do not edit .pi/**, .agents/**, AGENTS.md, or development/_reference/ai/**.
|
|
- If unrelated uncommitted changes exist, work around them. Stop with NEEDS_DISCUSSION if they block the task.
|
|
- If credentials, external access, clean local stand setup, or human business choice is required, stop with BLOCKED instead of asking a question.
|
|
- The ask_question tool is disabled. Do not request interactive confirmation.
|
|
|
|
Implementation rules:
|
|
- Prefer existing repo patterns and narrow diffs.
|
|
- This is a SvelteKit + TypeScript app. Keep UI behavior calm, readable, and consistent with the existing design.
|
|
- Keep game data, game state, and UI components separated according to the current structure.
|
|
- Keep user-facing copy consistent with the app language and tone already present in the touched files.
|
|
- Prefer existing npm scripts from package.json. Do not invent scripts.
|
|
- For UI behavior changes, describe the local browser smoke path Codex should verify.
|
|
|
|
Worker output contract:
|
|
- Run only relevant local checks that exist in package.json / repo docs.
|
|
- If UI behavior changed and local browser smoke is required, use only a local dev server.
|
|
- Finish with one of: WORKER_DONE, BLOCKED, or NEEDS_DISCUSSION.
|
|
- End your final response with a standalone final status line in this exact shape:
|
|
WORKER_STATUS: WORKER_DONE
|
|
or WORKER_STATUS: BLOCKED
|
|
or WORKER_STATUS: NEEDS_DISCUSSION
|
|
- List changed files and observed command exit codes.
|
|
- If there is meaningful follow-up work, propose the next concrete worker task.
|
|
- Do not claim review approval or human acceptance. Codex will verify, review, and commit if appropriate.
|
|
`;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const cwd = process.cwd();
|
|
const version = requireArg(args, 'version');
|
|
const model = args.model || 'zai/glm-5.2';
|
|
const thinking = args.thinking || 'xhigh';
|
|
const piBin = args.pi || process.env.PI_BIN || 'pi';
|
|
const piMode = args['pi-mode'] || 'json';
|
|
const eventIdleTimeoutMs = parseNonNegativeInt(args['event-idle-timeout-ms'], 600000);
|
|
const taskText = [args.task || '', readOptionalFile(args['prompt-file'])]
|
|
.filter(Boolean)
|
|
.join('\n\nAdditional Codex assignment details:\n')
|
|
.trim();
|
|
|
|
if (!taskText) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
|
|
if (!['json', 'text'].includes(piMode)) {
|
|
throw new Error(`Unsupported --pi-mode ${piMode}. Expected json or text.`);
|
|
}
|
|
|
|
assertRequiredFiles(cwd, version);
|
|
const name = args.name || slugify(taskText);
|
|
const contextFiles = existingContextFiles(cwd, version);
|
|
const runsBaseDir = resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
|
|
|
|
const runSlug = slugify(`${name}`);
|
|
const runDir = resolve(runsBaseDir, version, `${timestamp()}-pi-worker-${runSlug}`);
|
|
mkdirSync(runDir, { recursive: true });
|
|
|
|
const prompt = buildPrompt({ contextFiles, name, taskText, version });
|
|
const promptPath = join(runDir, 'prompt.txt');
|
|
const stdoutPath = join(runDir, 'stdout.txt');
|
|
const stderrPath = join(runDir, 'stderr.txt');
|
|
const metaPath = join(runDir, 'meta.json');
|
|
const eventsPath = join(runDir, 'events.jsonl');
|
|
const summaryPath = join(runDir, 'summary.json');
|
|
const lastMessagePath = join(runDir, 'last-message.txt');
|
|
|
|
const sessionId = args['session-id'] || `calm-${version.replace(/[^a-zA-Z0-9]+/g, '-')}-${runSlug}`;
|
|
const piArgs = [
|
|
...(piMode === 'json' ? ['--mode', 'json'] : ['-p']),
|
|
'--model',
|
|
model,
|
|
'--thinking',
|
|
thinking,
|
|
'--approve',
|
|
'--exclude-tools',
|
|
'ask_question',
|
|
'--session-id',
|
|
sessionId,
|
|
prompt,
|
|
];
|
|
|
|
writeFileSync(promptPath, prompt);
|
|
const startedAt = new Date().toISOString();
|
|
const baseMeta = {
|
|
tool: 'pi',
|
|
piBin,
|
|
args: piArgs.slice(0, -1).concat(['<prompt>']),
|
|
cwd,
|
|
version,
|
|
name,
|
|
model,
|
|
thinking,
|
|
piMode,
|
|
eventIdleTimeoutMs,
|
|
sessionId,
|
|
runDir,
|
|
promptPath,
|
|
stdoutPath,
|
|
stderrPath,
|
|
eventsPath,
|
|
summaryPath,
|
|
lastMessagePath,
|
|
startedAt,
|
|
dryRun: Boolean(args['dry-run']),
|
|
};
|
|
|
|
writeFileSync(metaPath, `${JSON.stringify(baseMeta, null, 2)}\n`);
|
|
writeFileSync(summaryPath, `${JSON.stringify({
|
|
...baseMeta,
|
|
status: 'NOT_STARTED',
|
|
finalStatus: null,
|
|
orchestratorStatus: 'NOT_STARTED',
|
|
eventCounts: {},
|
|
eventCount: 0,
|
|
parseErrors: [],
|
|
gitStatusAfter: null,
|
|
}, null, 2)}\n`);
|
|
|
|
console.log(`Run directory: ${runDir}`);
|
|
console.log(`Prompt: ${promptPath}`);
|
|
console.log(`Events: ${eventsPath}`);
|
|
console.log(`Summary: ${summaryPath}`);
|
|
console.log(`Command: ${piBin} ${piArgs.slice(0, -1).join(' ')} <prompt>`);
|
|
|
|
if (args['dry-run']) {
|
|
console.log('Dry run: Pi was not launched.');
|
|
return;
|
|
}
|
|
|
|
const stdout = createWriteStream(stdoutPath);
|
|
const stderr = createWriteStream(stderrPath);
|
|
const events = createWriteStream(eventsPath);
|
|
let stdoutRemainder = '';
|
|
let stdoutTail = '';
|
|
let lastAssistantText = '';
|
|
let lastEventAt = null;
|
|
let lastEventType = null;
|
|
let parentSignal = null;
|
|
let killedByIdle = false;
|
|
let idleTimer = null;
|
|
const parseErrors = [];
|
|
const eventCounts = {};
|
|
let eventCount = 0;
|
|
|
|
const child = spawn(piBin, piArgs, {
|
|
cwd,
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
function writeRunningSummary(extra = {}) {
|
|
const gitStatusAfter = extra.gitStatusAfter ?? null;
|
|
const finalStatus = findFinalStatus(lastAssistantText);
|
|
const orchestratorStatus = summarizeOrchestratorStatus({
|
|
finalStatus,
|
|
exitCode: extra.exitCode ?? null,
|
|
signal: extra.signal ?? null,
|
|
killedByIdle,
|
|
gitStatusAfter,
|
|
});
|
|
|
|
writeFileSync(summaryPath, `${JSON.stringify({
|
|
...baseMeta,
|
|
status: extra.status || 'RUNNING',
|
|
finalStatus,
|
|
orchestratorStatus,
|
|
eventCounts,
|
|
eventCount,
|
|
parseErrors,
|
|
lastEventAt,
|
|
lastEventType,
|
|
parentSignal,
|
|
killedByIdle,
|
|
gitStatusAfter,
|
|
stdoutTail,
|
|
lastAssistantTextPath: lastMessagePath,
|
|
...extra,
|
|
}, null, 2)}\n`);
|
|
}
|
|
|
|
function resetIdleTimer() {
|
|
if (eventIdleTimeoutMs === 0) return;
|
|
clearTimeout(idleTimer);
|
|
idleTimer = setTimeout(() => {
|
|
killedByIdle = true;
|
|
console.error(`\nPi worker produced no output for ${eventIdleTimeoutMs}ms; sending SIGTERM.`);
|
|
child.kill('SIGTERM');
|
|
setTimeout(() => {
|
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
}, 10000).unref();
|
|
}, eventIdleTimeoutMs);
|
|
idleTimer.unref();
|
|
}
|
|
|
|
function handleEvent(event) {
|
|
eventCount += 1;
|
|
eventCounts[event.type || 'unknown'] = (eventCounts[event.type || 'unknown'] || 0) + 1;
|
|
lastEventAt = new Date().toISOString();
|
|
lastEventType = event.type || 'unknown';
|
|
events.write(`${JSON.stringify(event)}\n`);
|
|
|
|
if (event.type === 'message_end') {
|
|
const text = textFromMessage(event.message);
|
|
if (text) lastAssistantText = text;
|
|
} else if (event.type === 'agent_end') {
|
|
const text = findLastAssistantTextFromMessages(event.messages);
|
|
if (text) lastAssistantText = text;
|
|
}
|
|
|
|
if (lastAssistantText) writeFileSync(lastMessagePath, `${lastAssistantText}\n`);
|
|
|
|
const rendered = renderEventForConsole(event);
|
|
if (rendered) process.stdout.write(rendered);
|
|
}
|
|
|
|
function handleStdoutChunk(chunk) {
|
|
const text = chunk.toString('utf8');
|
|
stdoutTail = `${stdoutTail}${text}`.slice(-12000);
|
|
stdout.write(chunk);
|
|
resetIdleTimer();
|
|
|
|
if (piMode !== 'json') {
|
|
process.stdout.write(chunk);
|
|
return;
|
|
}
|
|
|
|
stdoutRemainder += text;
|
|
const lines = stdoutRemainder.split('\n');
|
|
stdoutRemainder = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
handleEvent(JSON.parse(trimmed));
|
|
} catch (error) {
|
|
parseErrors.push({
|
|
at: new Date().toISOString(),
|
|
error: error.message || String(error),
|
|
line: trimmed.slice(0, 500),
|
|
});
|
|
process.stdout.write(`${line}\n`);
|
|
}
|
|
}
|
|
|
|
writeRunningSummary();
|
|
}
|
|
|
|
function handleParentSignal(signal) {
|
|
parentSignal = signal;
|
|
console.error(`\nReceived ${signal}; forwarding to Pi worker.`);
|
|
child.kill(signal);
|
|
}
|
|
|
|
process.once('SIGINT', handleParentSignal);
|
|
process.once('SIGTERM', handleParentSignal);
|
|
|
|
child.on('error', (error) => {
|
|
parseErrors.push({
|
|
at: new Date().toISOString(),
|
|
error: error.message || String(error),
|
|
line: 'spawn',
|
|
});
|
|
});
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
handleStdoutChunk(chunk);
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
resetIdleTimer();
|
|
process.stderr.write(chunk);
|
|
stderr.write(chunk);
|
|
});
|
|
|
|
resetIdleTimer();
|
|
writeRunningSummary();
|
|
|
|
const { exitCode, signal } = await new Promise((resolveExit) => {
|
|
child.on('close', (code, closeSignal) => resolveExit({ exitCode: code, signal: closeSignal }));
|
|
});
|
|
clearTimeout(idleTimer);
|
|
|
|
if (piMode === 'json' && stdoutRemainder.trim()) {
|
|
try {
|
|
handleEvent(JSON.parse(stdoutRemainder.trim()));
|
|
} catch (error) {
|
|
parseErrors.push({
|
|
at: new Date().toISOString(),
|
|
error: error.message || String(error),
|
|
line: stdoutRemainder.trim().slice(0, 500),
|
|
});
|
|
}
|
|
}
|
|
|
|
stdout.end();
|
|
stderr.end();
|
|
events.end();
|
|
|
|
const finishedAt = new Date().toISOString();
|
|
const gitStatusAfter = getGitStatus(cwd);
|
|
const finalStatus = piMode === 'json' ? findFinalStatus(lastAssistantText) : findFinalStatus(stdoutTail);
|
|
const orchestratorStatus = summarizeOrchestratorStatus({
|
|
finalStatus,
|
|
exitCode,
|
|
signal,
|
|
killedByIdle,
|
|
gitStatusAfter,
|
|
});
|
|
|
|
writeFileSync(
|
|
metaPath,
|
|
`${JSON.stringify({ ...baseMeta, finishedAt, exitCode, signal }, null, 2)}\n`,
|
|
);
|
|
writeFileSync(
|
|
summaryPath,
|
|
`${JSON.stringify({
|
|
...baseMeta,
|
|
status: 'FINISHED',
|
|
finalStatus,
|
|
orchestratorStatus,
|
|
eventCounts,
|
|
eventCount,
|
|
parseErrors,
|
|
lastEventAt,
|
|
lastEventType,
|
|
parentSignal,
|
|
killedByIdle,
|
|
gitStatusAfter,
|
|
stdoutTail,
|
|
lastAssistantTextPath: lastMessagePath,
|
|
finishedAt,
|
|
exitCode,
|
|
signal,
|
|
}, null, 2)}\n`,
|
|
);
|
|
|
|
console.log(`\nPi worker finished with ${orchestratorStatus}. See ${runDir}`);
|
|
|
|
if (parentSignal === 'SIGINT') process.exit(130);
|
|
if (parentSignal === 'SIGTERM') process.exit(143);
|
|
if (killedByIdle) process.exit(124);
|
|
if (exitCode !== 0 || signal) {
|
|
console.error(`Pi worker exited with code ${exitCode ?? 'null'} signal ${signal ?? 'null'}. See ${runDir}`);
|
|
process.exit(exitCode || 1);
|
|
}
|
|
if (!finalStatus) {
|
|
console.error(`Pi worker exited without WORKER_STATUS marker. See ${summaryPath}`);
|
|
process.exit(3);
|
|
}
|
|
|
|
console.log(`Pi worker completed. Structured summary: ${summaryPath}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.message || error);
|
|
process.exit(1);
|
|
});
|