mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
a9e4416380
Run the unattended lint loop against the green root unit suite and allow only loopback networking for local test fixtures. External network remains denied.
1354 lines
39 KiB
JavaScript
1354 lines
39 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
closeSync,
|
|
createWriteStream,
|
|
existsSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
parseCodexEvents,
|
|
parseFailedTests,
|
|
parseLintSummary,
|
|
parseNumstat,
|
|
parseTestSummary,
|
|
resolveDeadline,
|
|
scanPatchForSecrets,
|
|
validateDifferential,
|
|
validateLintRuleChange,
|
|
validateScope,
|
|
} from "./night-loop-lib.mjs";
|
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const RESULT_SCHEMA = join(SCRIPT_DIR, "night-loop-result.schema.json");
|
|
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
const MAX_PATCH_BYTES = 1024 * 1024;
|
|
const DEFAULT_COMMAND_TIMEOUT_MS = 20 * 60 * 1000;
|
|
const DEFAULT_BASE = "claude/context-loops-bootstrap";
|
|
const VALID_REF = /^[A-Za-z0-9._/-]+$/;
|
|
const NO_EXTERNAL_NETWORK_PROFILE = [
|
|
"(version 1)",
|
|
"(allow default)",
|
|
"(deny network*)",
|
|
'(allow network-bind (local ip "localhost:*"))',
|
|
'(allow network-inbound (local ip "localhost:*"))',
|
|
'(allow network-outbound (remote ip "localhost:*"))',
|
|
].join("");
|
|
const activeChildren = new Set();
|
|
let lockPath;
|
|
let fatalContext;
|
|
|
|
function sanitizedEnvironment() {
|
|
const allowed = [
|
|
"PATH",
|
|
"HOME",
|
|
"CODEX_HOME",
|
|
"TMPDIR",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"USER",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"TERM",
|
|
"TZ",
|
|
];
|
|
return Object.fromEntries(
|
|
allowed.filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]]),
|
|
);
|
|
}
|
|
|
|
function unitTestArgs(worktree) {
|
|
const tests = readdirSync(join(worktree, "tests"), { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts"))
|
|
.map((entry) => join("tests", entry.name))
|
|
.sort();
|
|
if (tests.length === 0) throw new Error("No root unit tests found");
|
|
return ["test", ...tests];
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
deadline: "07:30",
|
|
base: DEFAULT_BASE,
|
|
maxIterations: 8,
|
|
maxTotalTokens: 800_000,
|
|
dryRun: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = () => {
|
|
const value = argv[index + 1];
|
|
if (!value) throw new Error(`Missing value for ${arg}`);
|
|
index += 1;
|
|
return value;
|
|
};
|
|
|
|
if (arg === "--deadline") options.deadline = next();
|
|
else if (arg === "--base") options.base = next();
|
|
else if (arg === "--max-iterations") options.maxIterations = Number(next());
|
|
else if (arg === "--max-total-tokens") options.maxTotalTokens = Number(next());
|
|
else if (arg === "--dry-run") options.dryRun = true;
|
|
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
else throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
|
|
if (!VALID_REF.test(options.base)) throw new Error(`Unsafe base ref: ${options.base}`);
|
|
if (!Number.isInteger(options.maxIterations) || options.maxIterations < 1) {
|
|
throw new Error("--max-iterations must be a positive integer");
|
|
}
|
|
if (!Number.isInteger(options.maxTotalTokens) || options.maxTotalTokens < 1) {
|
|
throw new Error("--max-total-tokens must be a positive integer");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function usage() {
|
|
return `Usage: bun run night-loop -- [options]
|
|
|
|
Options:
|
|
--deadline <HH:MM|ISO> Hard cutoff (default: next 07:30 local time)
|
|
--base <branch> PR base branch (default: ${DEFAULT_BASE})
|
|
--max-iterations <count> Maximum Codex iterations (default: 8)
|
|
--max-total-tokens <count> Reported token ceiling (default: 800000)
|
|
--dry-run Validate configuration without invoking Codex
|
|
`;
|
|
}
|
|
|
|
function runId(now) {
|
|
return now.toISOString().replace(/\D/g, "").slice(0, 14);
|
|
}
|
|
|
|
function appendCapture(current, chunk) {
|
|
if (current.length >= MAX_CAPTURE_BYTES) return current;
|
|
return (current + chunk).slice(0, MAX_CAPTURE_BYTES);
|
|
}
|
|
|
|
function terminate(child) {
|
|
if (!child.pid) return;
|
|
try {
|
|
if (process.platform === "win32") child.kill("SIGTERM");
|
|
else process.kill(-child.pid, "SIGTERM");
|
|
} catch {
|
|
child.kill("SIGTERM");
|
|
}
|
|
setTimeout(() => {
|
|
try {
|
|
if (process.platform === "win32") child.kill("SIGKILL");
|
|
else process.kill(-child.pid, "SIGKILL");
|
|
} catch {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, 5_000).unref();
|
|
}
|
|
|
|
async function runCommand({
|
|
command,
|
|
args,
|
|
cwd,
|
|
deadlineMs,
|
|
timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
stdin,
|
|
stdoutFile,
|
|
stderrFile,
|
|
env,
|
|
inheritEnv = true,
|
|
}) {
|
|
const remaining = deadlineMs - Date.now();
|
|
if (remaining <= 0) {
|
|
return { code: null, signal: "DEADLINE", stdout: "", stderr: "", timedOut: true };
|
|
}
|
|
|
|
const effectiveTimeout = Math.min(timeoutMs, remaining);
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env: { ...(inheritEnv ? process.env : {}), ...env },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
detached: process.platform !== "win32",
|
|
});
|
|
activeChildren.add(child);
|
|
const stdoutStream = stdoutFile ? createWriteStream(stdoutFile, { flags: "a" }) : undefined;
|
|
const stderrStream = stderrFile ? createWriteStream(stderrFile, { flags: "a" }) : undefined;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let timedOut = false;
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
const text = chunk.toString();
|
|
stdout = appendCapture(stdout, text);
|
|
stdoutStream?.write(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
const text = chunk.toString();
|
|
stderr = appendCapture(stderr, text);
|
|
stderrStream?.write(chunk);
|
|
});
|
|
if (stdin !== undefined) child.stdin.end(stdin);
|
|
else child.stdin.end();
|
|
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
terminate(child);
|
|
}, effectiveTimeout);
|
|
|
|
const result = await new Promise((resolveResult) => {
|
|
child.on("error", (error) => {
|
|
stderr = appendCapture(stderr, String(error));
|
|
resolveResult({ code: null, signal: "SPAWN_ERROR" });
|
|
});
|
|
child.on("close", (code, signal) => resolveResult({ code, signal }));
|
|
});
|
|
|
|
clearTimeout(timer);
|
|
activeChildren.delete(child);
|
|
await Promise.all(
|
|
[stdoutStream, stderrStream]
|
|
.filter(Boolean)
|
|
.map(
|
|
(stream) =>
|
|
new Promise((resolveStream) => {
|
|
stream.end(resolveStream);
|
|
}),
|
|
),
|
|
);
|
|
return { ...result, stdout, stderr, timedOut };
|
|
}
|
|
|
|
function combined(result) {
|
|
return `${result.stdout}\n${result.stderr}`;
|
|
}
|
|
|
|
function mustSucceed(result, label) {
|
|
if (result.code !== 0) {
|
|
throw new Error(`${label} failed (exit ${result.code ?? result.signal})`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function writeJsonAtomic(path, value) {
|
|
const temporary = `${path}.tmp`;
|
|
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
renameSync(temporary, path);
|
|
}
|
|
|
|
function releaseLock() {
|
|
if (!lockPath) return;
|
|
try {
|
|
unlinkSync(lockPath);
|
|
} catch {
|
|
// The lock may already have been removed during signal handling.
|
|
}
|
|
lockPath = undefined;
|
|
}
|
|
|
|
function acquireLock(root) {
|
|
mkdirSync(root, { recursive: true });
|
|
const candidate = join(root, "active.lock");
|
|
|
|
const create = () => {
|
|
const descriptor = openSync(candidate, "wx", 0o600);
|
|
writeFileSync(descriptor, `${process.pid}\n`);
|
|
closeSync(descriptor);
|
|
lockPath = candidate;
|
|
};
|
|
|
|
try {
|
|
create();
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") throw error;
|
|
const priorPid = Number(readFileSync(candidate, "utf8").trim());
|
|
let active = Number.isInteger(priorPid) && priorPid > 0;
|
|
if (active) {
|
|
try {
|
|
process.kill(priorPid, 0);
|
|
} catch {
|
|
active = false;
|
|
}
|
|
}
|
|
if (active) throw new Error(`Night loop is already running as PID ${priorPid}`);
|
|
unlinkSync(candidate);
|
|
create();
|
|
}
|
|
}
|
|
|
|
function handleSignal(signal) {
|
|
for (const child of activeChildren) terminate(child);
|
|
if (fatalContext) {
|
|
fatalContext.state.status = "interrupted";
|
|
fatalContext.state.errors = [`Received ${signal}`];
|
|
fatalContext.state.finishedAt = new Date().toISOString();
|
|
writeJsonAtomic(fatalContext.stateFile, fatalContext.state);
|
|
writeFileSync(
|
|
join(fatalContext.stateRoot, "summary.md"),
|
|
finalSummary(fatalContext.state),
|
|
);
|
|
}
|
|
process.stderr.write(`night-loop: received ${signal}; terminating active children\n`);
|
|
setTimeout(() => process.exit(128), 5_500);
|
|
}
|
|
|
|
process.once("SIGINT", () => handleSignal("SIGINT"));
|
|
process.once("SIGTERM", () => handleSignal("SIGTERM"));
|
|
process.on("exit", releaseLock);
|
|
|
|
function formatDuration(milliseconds) {
|
|
const minutes = Math.max(0, Math.floor(milliseconds / 60_000));
|
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
}
|
|
|
|
function tail(value, max = 6_000) {
|
|
return value.length <= max ? value : value.slice(-max);
|
|
}
|
|
|
|
function hash(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
async function captureExactPatch({ worktree, deadlineMs, path }) {
|
|
writeFileSync(path, "", { mode: 0o600 });
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--binary", "HEAD"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
stdoutFile: path,
|
|
}),
|
|
"exact patch capture",
|
|
);
|
|
const bytes = statSync(path).size;
|
|
if (bytes > MAX_PATCH_BYTES) {
|
|
return {
|
|
bytes,
|
|
content: "",
|
|
digest: "",
|
|
errors: [`Patch is ${bytes} bytes; maximum is ${MAX_PATCH_BYTES}`],
|
|
};
|
|
}
|
|
const content = readFileSync(path, "utf8");
|
|
return { bytes, content, digest: hash(content), errors: [] };
|
|
}
|
|
|
|
function promptForIteration({ iteration, deadline, baseline, completed }) {
|
|
return `Use the repository's loop-engineering skill and implement exactly ONE smallest safe
|
|
engineering-loop iteration in this isolated worktree.
|
|
|
|
Hard constraints:
|
|
- Stop work before ${deadline.toISOString()}.
|
|
- Do not commit, push, create PRs, use the network, or modify git configuration.
|
|
- Choose the lint kind. Characterization and examples work are disabled because
|
|
this runner cannot safely execute their proof/config surfaces unattended.
|
|
- Lint: add exactly one already-passing rule in .oxlintrc.json. Do not change src/
|
|
or tests/. Generated context/map files may be refreshed if needed.
|
|
- Never touch secrets, auth, release workflows, dependency versions, generated API
|
|
types, the eight pinned failing tests, or product behavior.
|
|
- Maximum diff: 8 files and 500 changed lines.
|
|
- Read context/map/repo-map.md and context/examples/catalog.md before choosing.
|
|
- Run focused verification. Do not build the compiled binary; the outer runner owns
|
|
the exact differential gate.
|
|
- If no non-duplicative safe target exists, return no_change instead of forcing work.
|
|
|
|
Pinned outer baseline:
|
|
- lint: ${baseline.lint.warnings} warnings / ${baseline.lint.errors} errors
|
|
- tests: ${baseline.tests.pass} pass / ${baseline.tests.fail} fail
|
|
- pinned failures: ${baseline.failedTests.join("; ")}
|
|
|
|
Earlier successful iterations in this run:
|
|
${completed.length ? completed.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
|
|
This is iteration ${iteration}. Your final response must match the provided JSON schema.`;
|
|
}
|
|
|
|
function repairPrompt(errors) {
|
|
return `The outer safety gate rejected the current iteration. Repair only the existing
|
|
single change; do not broaden scope or start a different iteration.
|
|
|
|
Gate failures:
|
|
${errors.map((error) => `- ${error}`).join("\n")}
|
|
|
|
Run focused checks, leave the worktree ready for the outer runner, and return the
|
|
same JSON result schema. If a safe repair is not possible, return status "blocked".`;
|
|
}
|
|
|
|
async function runCodex({
|
|
cwd,
|
|
deadlineMs,
|
|
logDir,
|
|
prompt,
|
|
resultFile,
|
|
attempt,
|
|
threadId,
|
|
}) {
|
|
const stdoutFile = join(logDir, `codex-${attempt}.jsonl`);
|
|
const stderrFile = join(logDir, `codex-${attempt}.stderr.log`);
|
|
const common = [
|
|
"--json",
|
|
"--ignore-user-config",
|
|
"-c",
|
|
'approval_policy="never"',
|
|
"-c",
|
|
'sandbox_mode="workspace-write"',
|
|
"-c",
|
|
"sandbox_workspace_write.network_access=false",
|
|
"--output-schema",
|
|
RESULT_SCHEMA,
|
|
"-o",
|
|
resultFile,
|
|
];
|
|
const args = threadId
|
|
? ["exec", "resume", ...common, threadId, "-"]
|
|
: ["exec", ...common, "--sandbox", "workspace-write", "-C", cwd, "-"];
|
|
|
|
const result = await runCommand({
|
|
command: "codex",
|
|
args,
|
|
cwd,
|
|
deadlineMs,
|
|
timeoutMs: 45 * 60 * 1000,
|
|
stdin: prompt,
|
|
stdoutFile,
|
|
stderrFile,
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
});
|
|
const events = parseCodexEvents(
|
|
existsSync(stdoutFile) ? readFileSync(stdoutFile, "utf8") : result.stdout,
|
|
);
|
|
return { ...result, ...events, stdoutFile, stderrFile };
|
|
}
|
|
|
|
async function captureBaseline({ repoRoot, base, deadlineMs, runRoot }) {
|
|
const baselineDir = join(runRoot, "baseline-worktree");
|
|
const logDir = join(runRoot, "baseline");
|
|
mkdirSync(logDir, { recursive: true });
|
|
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["fetch", "origin", base],
|
|
cwd: repoRoot,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "fetch.log"),
|
|
stderrFile: join(logDir, "fetch.log"),
|
|
}),
|
|
"baseline fetch",
|
|
);
|
|
const baseSha = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", `origin/${base}`],
|
|
cwd: repoRoot,
|
|
deadlineMs,
|
|
}),
|
|
"baseline SHA",
|
|
).stdout.trim();
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["worktree", "add", "--detach", baselineDir, baseSha],
|
|
cwd: repoRoot,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "worktree.log"),
|
|
stderrFile: join(logDir, "worktree.log"),
|
|
}),
|
|
"baseline worktree creation",
|
|
);
|
|
|
|
try {
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "bun",
|
|
args: ["install", "--frozen-lockfile"],
|
|
cwd: baselineDir,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "install.log"),
|
|
stderrFile: join(logDir, "install.log"),
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
}),
|
|
"baseline install",
|
|
);
|
|
const lint = await runCommand({
|
|
command: "sandbox-exec",
|
|
args: ["-p", NO_EXTERNAL_NETWORK_PROFILE, "bun", "run", "lint"],
|
|
cwd: baselineDir,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "lint.log"),
|
|
stderrFile: join(logDir, "lint.log"),
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
});
|
|
const tests = await runCommand({
|
|
command: "sandbox-exec",
|
|
args: ["-p", NO_EXTERNAL_NETWORK_PROFILE, "bun", ...unitTestArgs(baselineDir)],
|
|
cwd: baselineDir,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "test.log"),
|
|
stderrFile: join(logDir, "test.log"),
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
});
|
|
const lintSummary = parseLintSummary(combined(lint));
|
|
const testSummary = parseTestSummary(combined(tests));
|
|
const failedTests = parseFailedTests(combined(tests));
|
|
|
|
if (lintSummary.errors !== 0) throw new Error("Baseline lint contains errors");
|
|
if (testSummary.fail !== failedTests.length) {
|
|
throw new Error("Baseline failing-test names do not match the failure count");
|
|
}
|
|
if (testSummary.fail !== 0) {
|
|
throw new Error(`Expected a green unit-test baseline, found ${testSummary.fail} failures`);
|
|
}
|
|
return { baseSha, lint: lintSummary, tests: testSummary, failedTests };
|
|
} finally {
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["worktree", "remove", "--force", baselineDir],
|
|
cwd: repoRoot,
|
|
deadlineMs: Math.max(deadlineMs, Date.now() + 30_000),
|
|
}),
|
|
"baseline worktree cleanup",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function changedPaths(worktree, deadlineMs) {
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["add", "-N", "--", "."],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"intent-to-add",
|
|
);
|
|
const names = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--name-only", "-z"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"changed-file listing",
|
|
).stdout;
|
|
return names.split("\0").filter(Boolean);
|
|
}
|
|
|
|
async function validateIteration({
|
|
worktree,
|
|
startingSha,
|
|
result,
|
|
baseline,
|
|
deadlineMs,
|
|
logDir,
|
|
}) {
|
|
const errors = [];
|
|
if (!existsSync(join(worktree, ".git"))) errors.push("Iteration worktree disappeared");
|
|
|
|
const currentHead = await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", "HEAD"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
});
|
|
if (currentHead.code !== 0) errors.push("Could not verify the iteration commit boundary");
|
|
else if (currentHead.stdout.trim() !== startingSha) {
|
|
errors.push("Agent changed HEAD; the runner owns commits");
|
|
}
|
|
|
|
const staged = await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--cached", "--quiet"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
});
|
|
if (staged.code === 1) errors.push("Agent staged changes; the runner owns the index");
|
|
else if (staged.code !== 0) errors.push("Could not verify the iteration index");
|
|
|
|
const paths = await changedPaths(worktree, deadlineMs);
|
|
const numstatResult = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--numstat"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"diff statistics",
|
|
);
|
|
const stats = parseNumstat(numstatResult.stdout);
|
|
errors.push(...validateScope(result.kind, paths, stats));
|
|
const patchBefore = await captureExactPatch({
|
|
worktree,
|
|
deadlineMs,
|
|
path: join(logDir, "candidate-before.patch"),
|
|
});
|
|
errors.push(...patchBefore.errors, ...scanPatchForSecrets(patchBefore.content));
|
|
|
|
if (result.kind === "lint") {
|
|
const baseConfig = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["show", `${startingSha}:.oxlintrc.json`],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"base lint config",
|
|
).stdout;
|
|
errors.push(
|
|
...validateLintRuleChange(
|
|
baseConfig,
|
|
readFileSync(join(worktree, ".oxlintrc.json"), "utf8"),
|
|
),
|
|
);
|
|
}
|
|
|
|
const distDir = join(worktree, "dist");
|
|
if (existsSync(distDir)) rmSync(distDir, { recursive: true, force: true });
|
|
|
|
const commands = [
|
|
["diff", "git", ["diff", "--check"], true],
|
|
["typecheck", "bun", ["run", "typecheck"], true],
|
|
["lint", "bun", ["run", "lint"], true],
|
|
["test", "bun", unitTestArgs(worktree), true],
|
|
["build", "bun", ["run", "build"], true],
|
|
["examples", "bun", ["run", "examples:catalog:check"], true],
|
|
["repo-map", "bun", ["run", "repo-map:check"], true],
|
|
];
|
|
const outputs = {};
|
|
|
|
for (const [name, command, args, expectZero] of commands) {
|
|
const commandResult = await runCommand({
|
|
command: "sandbox-exec",
|
|
args: ["-p", NO_EXTERNAL_NETWORK_PROFILE, command, ...args],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, `gate-${name}.log`),
|
|
stderrFile: join(logDir, `gate-${name}.log`),
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
});
|
|
outputs[name] = combined(commandResult);
|
|
if (expectZero && commandResult.code !== 0) {
|
|
errors.push(`${name} gate exited ${commandResult.code ?? commandResult.signal}`);
|
|
}
|
|
if (commandResult.timedOut) errors.push(`${name} gate timed out`);
|
|
}
|
|
|
|
const patchAfter = await captureExactPatch({
|
|
worktree,
|
|
deadlineMs,
|
|
path: join(logDir, "candidate-after.patch"),
|
|
});
|
|
errors.push(...patchAfter.errors);
|
|
if (patchAfter.digest !== patchBefore.digest) {
|
|
errors.push("Diff changed while gates were running");
|
|
}
|
|
|
|
let candidate;
|
|
try {
|
|
candidate = {
|
|
lint: parseLintSummary(outputs.lint),
|
|
tests: parseTestSummary(outputs.test),
|
|
failedTests: parseFailedTests(outputs.test),
|
|
};
|
|
errors.push(...validateDifferential(result.kind, baseline, candidate));
|
|
} catch (error) {
|
|
errors.push(error.message);
|
|
}
|
|
|
|
return {
|
|
errors: [...new Set(errors)],
|
|
paths,
|
|
stats,
|
|
candidate,
|
|
diffHash: patchAfter.digest,
|
|
};
|
|
}
|
|
|
|
async function openEscalationIssue({ repoRoot, repo, base, iteration, errors, worktree, deadlineMs }) {
|
|
const body = [
|
|
"The bounded local night loop stopped after two unsuccessful repair attempts.",
|
|
"",
|
|
`- Base: \`${base}\``,
|
|
`- Iteration: ${iteration}`,
|
|
`- Preserved worktree: \`${worktree}\``,
|
|
"",
|
|
"Gate failures:",
|
|
...errors.map((error) => `- ${error}`),
|
|
"",
|
|
"No PR was opened and nothing was merged.",
|
|
].join("\n");
|
|
return runCommand({
|
|
command: "gh",
|
|
args: [
|
|
"issue",
|
|
"create",
|
|
"--repo",
|
|
repo,
|
|
"--label",
|
|
"automation",
|
|
"--title",
|
|
`automation: night loop iteration ${iteration} blocked`,
|
|
"--body",
|
|
body,
|
|
],
|
|
cwd: repoRoot,
|
|
deadlineMs,
|
|
});
|
|
}
|
|
|
|
async function publishIteration({
|
|
repoRoot,
|
|
repo,
|
|
base,
|
|
branch,
|
|
worktree,
|
|
startingSha,
|
|
result,
|
|
validation,
|
|
deadlineMs,
|
|
logDir,
|
|
}) {
|
|
const currentHead = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", "HEAD"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"pre-commit HEAD verification",
|
|
).stdout.trim();
|
|
if (currentHead !== startingSha) throw new Error("HEAD changed after validation");
|
|
|
|
const staged = await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--cached", "--quiet"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
});
|
|
if (staged.code !== 0) throw new Error("Index changed after validation");
|
|
|
|
const finalPaths = await changedPaths(worktree, deadlineMs);
|
|
const finalNumstat = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--numstat"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"pre-commit diff statistics",
|
|
);
|
|
const finalStats = parseNumstat(finalNumstat.stdout);
|
|
const finalScopeErrors = validateScope(result.kind, finalPaths, finalStats);
|
|
if (finalScopeErrors.length > 0) {
|
|
throw new Error(`Scope changed after validation: ${finalScopeErrors.join("; ")}`);
|
|
}
|
|
if (
|
|
finalPaths.join("\n") !== validation.paths.join("\n") ||
|
|
JSON.stringify(finalStats) !== JSON.stringify(validation.stats)
|
|
) {
|
|
throw new Error("Diff changed while safety gates were running");
|
|
}
|
|
const finalPatch = await captureExactPatch({
|
|
worktree,
|
|
deadlineMs,
|
|
path: join(logDir, "precommit.patch"),
|
|
});
|
|
if (finalPatch.errors.length > 0) throw new Error(finalPatch.errors.join("; "));
|
|
if (finalPatch.digest !== validation.diffHash) {
|
|
throw new Error("Diff content changed after validation");
|
|
}
|
|
const secretErrors = scanPatchForSecrets(finalPatch.content);
|
|
if (secretErrors.length > 0) throw new Error(secretErrors.join("; "));
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["diff", "--check"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"pre-commit whitespace check",
|
|
);
|
|
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["add", "-A"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"git add",
|
|
);
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: [
|
|
"-c",
|
|
"user.name=codex-night-loop",
|
|
"-c",
|
|
"user.email=codex-night-loop@users.noreply.github.com",
|
|
"commit",
|
|
"-m",
|
|
result.commit_message,
|
|
],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "commit.log"),
|
|
stderrFile: join(logDir, "commit.log"),
|
|
}),
|
|
"git commit",
|
|
);
|
|
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["fetch", "origin", base],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
}),
|
|
"pre-push fetch",
|
|
);
|
|
const baseCheck = await runCommand({
|
|
command: "git",
|
|
args: ["merge-base", "--is-ancestor", `origin/${base}`, "HEAD"],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
});
|
|
if (baseCheck.code !== 0) {
|
|
throw new Error("Base branch advanced during the iteration; preserving worktree for review");
|
|
}
|
|
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["push", "--set-upstream", "origin", branch],
|
|
cwd: worktree,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "push.log"),
|
|
stderrFile: join(logDir, "push.log"),
|
|
}),
|
|
"git push",
|
|
);
|
|
|
|
const bodyFile = join(logDir, "pr-body.md");
|
|
writeFileSync(
|
|
bodyFile,
|
|
[
|
|
"## Summary",
|
|
"",
|
|
result.summary,
|
|
"",
|
|
"## Monotonic metric",
|
|
"",
|
|
result.metric,
|
|
"",
|
|
"## Verification evidence",
|
|
"",
|
|
result.evidence || "No additional evidence supplied.",
|
|
"",
|
|
"## Outer safety gate",
|
|
"",
|
|
`- lint: ${validation.candidate.lint.warnings} warnings / ${validation.candidate.lint.errors} errors`,
|
|
`- tests: ${validation.candidate.tests.pass} pass / ${validation.candidate.tests.fail} pinned failures`,
|
|
"- typecheck, build, examples catalog, and repo-map freshness: passed",
|
|
`- scope: ${validation.stats.files} files, ${validation.stats.additions} additions, ${validation.stats.deletions} deletions`,
|
|
"",
|
|
"Generated by the bounded night loop. This PR is intentionally not auto-merged.",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
const pr = mustSucceed(
|
|
await runCommand({
|
|
command: "gh",
|
|
args: [
|
|
"pr",
|
|
"create",
|
|
"--repo",
|
|
repo,
|
|
"--base",
|
|
base,
|
|
"--head",
|
|
branch,
|
|
"--label",
|
|
"automation",
|
|
"--title",
|
|
result.title,
|
|
"--body-file",
|
|
bodyFile,
|
|
],
|
|
cwd: repoRoot,
|
|
deadlineMs,
|
|
stdoutFile: join(logDir, "pr.log"),
|
|
stderrFile: join(logDir, "pr.log"),
|
|
}),
|
|
"PR creation",
|
|
);
|
|
return pr.stdout.trim();
|
|
}
|
|
|
|
function finalSummary(state) {
|
|
const lines = [
|
|
"# Night loop summary",
|
|
"",
|
|
`- Status: ${state.status}`,
|
|
`- Started: ${state.startedAt}`,
|
|
`- Finished: ${state.finishedAt}`,
|
|
`- Deadline: ${state.deadline}`,
|
|
`- Reported tokens: ${state.totalTokens} / ${state.maxTotalTokens}`,
|
|
`- Iterations: ${state.iterations.length} / ${state.maxIterations}`,
|
|
"",
|
|
];
|
|
if (state.errors?.length) {
|
|
lines.push("## Fatal errors", "", ...state.errors.map((error) => `- ${error}`), "");
|
|
}
|
|
for (const iteration of state.iterations) {
|
|
lines.push(
|
|
`## Iteration ${iteration.number}`,
|
|
"",
|
|
`- Status: ${iteration.status}`,
|
|
`- Summary: ${iteration.summary ?? "n/a"}`,
|
|
`- PR: ${iteration.prUrl ?? "none"}`,
|
|
`- Worktree: ${iteration.worktree ?? "removed"}`,
|
|
"",
|
|
);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(usage());
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const deadline = resolveDeadline(now, options.deadline);
|
|
const repoRootResult = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", "--show-toplevel"],
|
|
cwd: process.cwd(),
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"repository lookup",
|
|
);
|
|
const repoRoot = resolve(repoRootResult.stdout.trim());
|
|
if (process.platform !== "darwin") {
|
|
throw new Error("Night loop currently requires macOS sandbox-exec for networkless gates");
|
|
}
|
|
const stateParent = join(repoRoot, ".codex", "night-loop");
|
|
acquireLock(stateParent);
|
|
const repoResult = mustSucceed(
|
|
await runCommand({
|
|
command: "gh",
|
|
args: ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"GitHub repository lookup",
|
|
);
|
|
const repo = repoResult.stdout.trim();
|
|
const identifier = runId(now);
|
|
const stateRoot = join(stateParent, identifier);
|
|
const worktreeRoot = join(tmpdir(), "10x-cli-night-loop", identifier);
|
|
mkdirSync(stateRoot, { recursive: true });
|
|
mkdirSync(worktreeRoot, { recursive: true });
|
|
|
|
const state = {
|
|
version: 1,
|
|
pid: process.pid,
|
|
repo,
|
|
base: options.base,
|
|
startedAt: now.toISOString(),
|
|
deadline: deadline.toISOString(),
|
|
maxIterations: options.maxIterations,
|
|
maxTotalTokens: options.maxTotalTokens,
|
|
totalTokens: 0,
|
|
status: "preflight",
|
|
iterations: [],
|
|
};
|
|
const stateFile = join(stateRoot, "state.json");
|
|
writeJsonAtomic(stateFile, state);
|
|
fatalContext = { state, stateFile, stateRoot };
|
|
|
|
const status = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["status", "--porcelain"],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"git status",
|
|
);
|
|
if (status.stdout.trim()) throw new Error("Runner checkout must be clean");
|
|
|
|
for (const command of ["bun", "codex", "gh", "sandbox-exec"]) {
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "which",
|
|
args: [command],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
`${command} preflight`,
|
|
);
|
|
}
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "gh",
|
|
args: ["auth", "status"],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
stdoutFile: join(stateRoot, "gh-auth.log"),
|
|
stderrFile: join(stateRoot, "gh-auth.log"),
|
|
}),
|
|
"GitHub authentication",
|
|
);
|
|
|
|
if (options.dryRun) {
|
|
state.status = "dry_run_complete";
|
|
state.finishedAt = new Date().toISOString();
|
|
writeJsonAtomic(stateFile, state);
|
|
writeFileSync(join(stateRoot, "summary.md"), finalSummary(state));
|
|
process.stdout.write(
|
|
`Dry run passed. Deadline ${deadline.toISOString()} (${formatDuration(deadline - now)} remaining).\n`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
state.status = "pinning_baseline";
|
|
writeJsonAtomic(stateFile, state);
|
|
const pinned = await captureBaseline({
|
|
repoRoot,
|
|
base: options.base,
|
|
deadlineMs: deadline.getTime(),
|
|
runRoot: stateRoot,
|
|
});
|
|
state.baseSha = pinned.baseSha;
|
|
state.baseline = {
|
|
lint: pinned.lint,
|
|
tests: pinned.tests,
|
|
failedTests: pinned.failedTests,
|
|
};
|
|
state.status = "running";
|
|
writeJsonAtomic(stateFile, state);
|
|
|
|
const completed = [];
|
|
for (let number = 1; number <= options.maxIterations; number += 1) {
|
|
if (Date.now() >= deadline.getTime()) {
|
|
state.status = "deadline_reached";
|
|
break;
|
|
}
|
|
if (state.totalTokens >= options.maxTotalTokens) {
|
|
state.status = "token_budget_reached";
|
|
break;
|
|
}
|
|
|
|
const iterationId = String(number).padStart(2, "0");
|
|
const logDir = join(stateRoot, `iteration-${iterationId}`);
|
|
const worktree = join(worktreeRoot, `iteration-${iterationId}`);
|
|
const branch = `automation/night-loop-${identifier}-${iterationId}`;
|
|
mkdirSync(logDir, { recursive: true });
|
|
const iteration = {
|
|
number,
|
|
branch,
|
|
worktree,
|
|
startedAt: new Date().toISOString(),
|
|
status: "starting",
|
|
attempts: [],
|
|
};
|
|
state.iterations.push(iteration);
|
|
writeJsonAtomic(stateFile, state);
|
|
|
|
try {
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["fetch", "origin", options.base],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
stdoutFile: join(logDir, "fetch.log"),
|
|
stderrFile: join(logDir, "fetch.log"),
|
|
}),
|
|
"iteration fetch",
|
|
);
|
|
const remoteBaseSha = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", `origin/${options.base}`],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"iteration base SHA",
|
|
).stdout.trim();
|
|
if (remoteBaseSha !== state.baseSha) {
|
|
iteration.status = "base_advanced";
|
|
iteration.finishedAt = new Date().toISOString();
|
|
state.status = "base_advanced";
|
|
writeJsonAtomic(stateFile, state);
|
|
break;
|
|
}
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["worktree", "add", "--detach", worktree, state.baseSha],
|
|
cwd: repoRoot,
|
|
deadlineMs: deadline.getTime(),
|
|
stdoutFile: join(logDir, "worktree.log"),
|
|
stderrFile: join(logDir, "worktree.log"),
|
|
}),
|
|
"iteration worktree creation",
|
|
);
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["switch", "-c", branch],
|
|
cwd: worktree,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"iteration branch creation",
|
|
);
|
|
const startingSha = mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["rev-parse", "HEAD"],
|
|
cwd: worktree,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"iteration starting SHA",
|
|
).stdout.trim();
|
|
iteration.startingSha = startingSha;
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "bun",
|
|
args: ["install", "--frozen-lockfile"],
|
|
cwd: worktree,
|
|
deadlineMs: deadline.getTime(),
|
|
stdoutFile: join(logDir, "install.log"),
|
|
stderrFile: join(logDir, "install.log"),
|
|
env: sanitizedEnvironment(),
|
|
inheritEnv: false,
|
|
}),
|
|
"iteration install",
|
|
);
|
|
|
|
const resultFile = join(logDir, "result.json");
|
|
let threadId;
|
|
let result;
|
|
let validation;
|
|
let errors = [];
|
|
|
|
for (let attempt = 0; attempt <= 2; attempt += 1) {
|
|
if (attempt > 0 && state.totalTokens >= options.maxTotalTokens) {
|
|
errors.push("Reported token ceiling reached; refusing to start a repair turn");
|
|
break;
|
|
}
|
|
iteration.status = attempt === 0 ? "agent_running" : "repairing";
|
|
writeJsonAtomic(stateFile, state);
|
|
const agentRun = await runCodex({
|
|
cwd: worktree,
|
|
deadlineMs: deadline.getTime(),
|
|
logDir,
|
|
prompt:
|
|
attempt === 0
|
|
? promptForIteration({
|
|
iteration: number,
|
|
deadline,
|
|
baseline: state.baseline,
|
|
completed,
|
|
})
|
|
: repairPrompt(errors),
|
|
resultFile,
|
|
attempt,
|
|
threadId,
|
|
});
|
|
threadId = threadId ?? agentRun.threadId;
|
|
state.totalTokens += agentRun.totalTokens;
|
|
iteration.attempts.push({
|
|
number: attempt + 1,
|
|
exitCode: agentRun.code,
|
|
timedOut: agentRun.timedOut,
|
|
reportedTokens: agentRun.totalTokens,
|
|
completedTurns: agentRun.completedTurns,
|
|
});
|
|
writeJsonAtomic(stateFile, state);
|
|
|
|
errors = [];
|
|
if (agentRun.code !== 0) {
|
|
errors.push(
|
|
`Codex attempt ${attempt + 1} exited ${agentRun.code ?? agentRun.signal}: ${tail(agentRun.stderr, 1500)}`,
|
|
);
|
|
}
|
|
if (agentRun.code === 0 && agentRun.completedTurns < 1) {
|
|
errors.push("Codex completed without a flushed turn.completed usage event");
|
|
}
|
|
if (agentRun.code === 0 && agentRun.totalTokens < 1) {
|
|
errors.push("Codex completed without reported token usage");
|
|
}
|
|
if (!existsSync(resultFile)) {
|
|
errors.push("Codex did not write a structured result");
|
|
} else {
|
|
try {
|
|
result = JSON.parse(readFileSync(resultFile, "utf8"));
|
|
} catch (error) {
|
|
errors.push(`Could not parse Codex result: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (errors.length === 0 && result.status === "no_change") {
|
|
const paths = await changedPaths(worktree, deadline.getTime());
|
|
if (paths.length > 0) errors.push("Agent returned no_change but modified files");
|
|
else {
|
|
iteration.status = "no_change";
|
|
iteration.summary = result.summary;
|
|
state.status = "no_safe_target";
|
|
break;
|
|
}
|
|
}
|
|
if (errors.length === 0 && result.status === "blocked") {
|
|
errors.push(`Agent reported blocked: ${result.summary}`);
|
|
continue;
|
|
}
|
|
if (errors.length === 0 && result.status === "changed") {
|
|
iteration.status = "validating";
|
|
writeJsonAtomic(stateFile, state);
|
|
validation = await validateIteration({
|
|
worktree,
|
|
startingSha,
|
|
result,
|
|
baseline: state.baseline,
|
|
deadlineMs: deadline.getTime(),
|
|
logDir,
|
|
});
|
|
errors.push(...validation.errors);
|
|
if (errors.length === 0) break;
|
|
}
|
|
}
|
|
|
|
if (iteration.status === "no_change") {
|
|
iteration.finishedAt = new Date().toISOString();
|
|
writeJsonAtomic(stateFile, state);
|
|
break;
|
|
}
|
|
if (errors.length > 0) {
|
|
iteration.status = "escalated";
|
|
iteration.errors = errors;
|
|
const issue = mustSucceed(
|
|
await openEscalationIssue({
|
|
repoRoot,
|
|
repo,
|
|
base: options.base,
|
|
iteration: number,
|
|
errors,
|
|
worktree,
|
|
deadlineMs: deadline.getTime(),
|
|
}),
|
|
"escalation issue creation",
|
|
);
|
|
iteration.issueUrl = issue.stdout.trim() || undefined;
|
|
iteration.finishedAt = new Date().toISOString();
|
|
state.status = "iteration_failed";
|
|
writeJsonAtomic(stateFile, state);
|
|
break;
|
|
}
|
|
|
|
iteration.status = "publishing";
|
|
writeJsonAtomic(stateFile, state);
|
|
const prUrl = await publishIteration({
|
|
repoRoot,
|
|
repo,
|
|
base: options.base,
|
|
branch,
|
|
worktree,
|
|
startingSha,
|
|
result,
|
|
validation,
|
|
deadlineMs: deadline.getTime(),
|
|
logDir,
|
|
});
|
|
iteration.status = "pr_opened";
|
|
iteration.summary = result.summary;
|
|
iteration.prUrl = prUrl;
|
|
iteration.finishedAt = new Date().toISOString();
|
|
completed.push(`${result.kind}: ${result.summary} (${prUrl})`);
|
|
writeJsonAtomic(stateFile, state);
|
|
|
|
mustSucceed(
|
|
await runCommand({
|
|
command: "git",
|
|
args: ["worktree", "remove", "--force", worktree],
|
|
cwd: repoRoot,
|
|
deadlineMs: Math.max(deadline.getTime(), Date.now() + 30_000),
|
|
}),
|
|
"iteration worktree cleanup",
|
|
);
|
|
iteration.worktree = undefined;
|
|
writeJsonAtomic(stateFile, state);
|
|
} catch (error) {
|
|
iteration.status = "runner_error";
|
|
iteration.errors = [error.message];
|
|
iteration.finishedAt = new Date().toISOString();
|
|
state.status = "runner_error";
|
|
writeJsonAtomic(stateFile, state);
|
|
const escalation = await openEscalationIssue({
|
|
repoRoot,
|
|
repo,
|
|
base: options.base,
|
|
iteration: number,
|
|
errors: iteration.errors,
|
|
worktree,
|
|
deadlineMs: Math.max(deadline.getTime(), Date.now() + 30_000),
|
|
});
|
|
if (escalation.code !== 0) {
|
|
iteration.errors.push(
|
|
`Escalation issue failed: ${tail(combined(escalation), 1_500)}`,
|
|
);
|
|
writeJsonAtomic(stateFile, state);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (state.status === "running") {
|
|
state.status =
|
|
state.iterations.length >= options.maxIterations
|
|
? "iteration_budget_reached"
|
|
: "completed";
|
|
}
|
|
state.finishedAt = new Date().toISOString();
|
|
writeJsonAtomic(stateFile, state);
|
|
writeFileSync(join(stateRoot, "summary.md"), finalSummary(state));
|
|
process.stdout.write(`${finalSummary(state)}State: ${stateRoot}\n`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
if (fatalContext) {
|
|
fatalContext.state.status = "fatal_error";
|
|
fatalContext.state.errors = [error.message];
|
|
fatalContext.state.finishedAt = new Date().toISOString();
|
|
writeJsonAtomic(fatalContext.stateFile, fatalContext.state);
|
|
writeFileSync(
|
|
join(fatalContext.stateRoot, "summary.md"),
|
|
finalSummary(fatalContext.state),
|
|
);
|
|
}
|
|
process.stderr.write(`night-loop: ${error.stack ?? error.message}\n`);
|
|
process.exitCode = 1;
|
|
});
|