Files
pepa-pi-bot/scripts/auto-patch.js
T
mayatnikovandClaude Opus 4.7 ba96619628 feat(runtime): fully autonomous self-healing — no operator approval
Operator feedback: "бот должен быть полностью автономным — сам себя
улучшать и чинить, в этом и есть смысл; все что я вижу пока что он
стоит на месте и кидает proposals на каждый чих — это кардинально не
то что я хочу". Acted on:

1. Trigger filter — proposals only on real bugs.

   runtime/bot.js classifies failure detail into bug / timeout /
   feature-gap / other. The 5-in-a-row trigger fires only when the run
   contains a bug (TypeError / Cannot read / is not defined …) OR is
   entirely timeouts on the same operation. Feature gaps like "no
   reachable log within 32 blocks", "no food in inventory", "no bed in
   range", "no target in reach" are SKIPPED — the reflex layer routes
   around them (noTreesUntil → wander, etc). The LLM has no business
   patching code for missing inventory.

   Threshold raised 3 → 5 in a row. Cooldown unchanged (30 min).

2. Auto-apply, no operator-in-the-loop.

   New runtime/auto-improve.js polls proposals/ every 2s. When it sees
   a new .md and 10s have passed since first sighting (debounce),
   spawns scripts/auto-patch.js detached.

   New scripts/auto-patch.js: refuses on dirty tree, moves proposal
   pending → approved/, branches `auto/<slug>` off main, runs `pi -p`
   with 10-min timeout. If Pi committed AND every changed file is
   under runtime/ → cherry-picks onto main. Otherwise discards the
   branch. No push, no PR. Audit trail in state/<host>/proposals/approved/.

   Rate limit: 15-min cooldown between finished runs + 4/hour hard cap.

3. Auto-rollback on bad patches.

   runtime/supervisor.js: when MAX_RESTARTS_PER_MINUTE is exceeded
   AND `git log -1 HEAD` is younger than 15 min AND HEAD touched
   runtime/, runs `git reset --hard HEAD~1`. Up to MAX_ROLLBACKS=3
   lifetime, then exits 1 for manual investigation. Restart counters
   are reset after a successful rollback so the next attempt isn't
   immediately killed.

4. current-task.json slim.

   No longer stores the full perception snapshot (was ~3 KB per write
   × every action). Position only — sufficient as a resume anchor.
   Slim snapshot still goes into the proposal markdown for context.

docs/runtime.md — rewrote the self-improvement section: full flow
diagram, classification rules, all rate-limit knobs, manual escape
hatches kept but documented as rarely-needed.

Also cleared 5 stale proposals from previous smoke tests so the first
production run isn't burning Pi tokens on stale bugs that have since
been fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:55:55 +03:00

181 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
// auto-patch.js <proposal-filename>
//
// Unattended sibling of propose-apply.js. Picks an *unapproved* proposal,
// moves it to approved/, branches off main, runs `pi -p` headless, and if Pi
// commits something — cherry-picks the commit back into main. The point is
// to close the self-improvement loop with no operator interaction.
//
// Exit codes:
// 0 patch applied cleanly (commit on main)
// 1 pi spawned but produced no commit (no change to repo)
// 2 preflight failed (dirty tree, missing proposal, etc.)
// 3 pi exited non-zero
// 4 cherry-pick conflict — left in unresolved state on a branch
//
// Designed to be launched by runtime/auto-improve.js as a detached child.
// We deliberately avoid touching anything outside the repo and don't push.
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..");
function log(level, msg) {
const line = `${new Date().toISOString()} [auto-patch] [${level}] ${msg}`;
if (level === "error" || level === "warn") console.error(line);
else console.log(line);
}
function git(args, opts = {}) {
return spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8", ...opts });
}
function exit(code, reason) {
log(code === 0 ? "info" : "warn", `exit ${code}: ${reason}`);
process.exit(code);
}
const filenameArg = process.argv[2];
if (!filenameArg) exit(2, "usage: auto-patch.js <proposal-filename>");
function findProposal(filename) {
const stateRoot = path.join(REPO_ROOT, "state");
if (!fs.existsSync(stateRoot)) return null;
for (const host of fs.readdirSync(stateRoot)) {
const pending = path.join(stateRoot, host, "proposals", filename);
const approved = path.join(stateRoot, host, "proposals", "approved", filename);
if (fs.existsSync(pending)) return { path: pending, host, status: "pending" };
if (fs.existsSync(approved)) return { path: approved, host, status: "approved" };
}
return null;
}
const proposal = findProposal(filenameArg);
if (!proposal) exit(2, `proposal not found: ${filenameArg}`);
// Refuse on dirty tree — we'd lose the operator's WIP.
const dirty = git(["status", "--porcelain"]).stdout.trim();
if (dirty) exit(2, `working tree dirty: ${dirty.split("\n")[0]}`);
// Move pending → approved so we don't try to apply the same proposal twice.
if (proposal.status === "pending") {
const approvedDir = path.join(path.dirname(proposal.path), "approved");
fs.mkdirSync(approvedDir, { recursive: true });
const dst = path.join(approvedDir, filenameArg);
const content = fs.readFileSync(proposal.path, "utf8").replace(/^approved: false/m, "approved: true (auto)");
fs.writeFileSync(dst, content);
fs.unlinkSync(proposal.path);
proposal.path = dst;
log("info", `moved pending → approved: ${filenameArg}`);
}
const proposalText = fs.readFileSync(proposal.path, "utf8");
// Capture current main HEAD so we can roll back to it if cherry-pick fails.
const baseSha = git(["rev-parse", "HEAD"]).stdout.trim();
const slug = filenameArg
.replace(/\.md$/, "")
.replace(/[^a-zA-Z0-9-]+/g, "-")
.slice(0, 60);
const branch = `auto/${slug}`;
// Delete the branch if it exists from a previous failed attempt.
git(["branch", "-D", branch]); // ignore error if absent
const checkout = git(["checkout", "-b", branch]);
if (checkout.status !== 0) exit(2, `cannot create branch ${branch}: ${checkout.stderr}`);
const prompt = [
"You are patching the pepa-pi-bot repo to address an automatically-detected failure.",
"This is an UNATTENDED run — no operator will review your output before it lands on main.",
"Be conservative. Prefer guard clauses and small surgical edits.",
"",
"## The proposal",
"",
proposalText,
"",
"## Hard rules (non-negotiable)",
"",
"1. Touch only files under `runtime/`. Do NOT modify `tui/`, `extensions/`, `scripts/`, `docs/`, `package.json`, or anything in `state/`.",
"2. Do not introduce npm dependencies.",
"3. Do not push, do not open a PR. Commit on the current branch only.",
"4. Use a conventional commit message: `fix(runtime/<file>): <one-line summary>`.",
"5. If you can't safely fix the issue, write a short comment in the relevant runtime file explaining why and stop — do NOT make a speculative change.",
"6. Make exactly ONE commit. If you find multiple issues, focus on the one the proposal describes.",
"",
"After you commit, your job is done.",
].join("\n");
log("info", `spawning pi -p (timeout 10 min)`);
const pi = spawn("pi", ["-p", prompt], {
cwd: REPO_ROOT,
stdio: ["ignore", "pipe", "pipe"],
});
let piStdout = "";
let piStderr = "";
pi.stdout.on("data", (chunk) => {
piStdout += chunk.toString();
});
pi.stderr.on("data", (chunk) => {
piStderr += chunk.toString();
});
const PI_TIMEOUT_MS = 10 * 60 * 1000;
const timer = setTimeout(() => {
log("warn", "pi timeout — killing subprocess");
pi.kill("SIGTERM");
}, PI_TIMEOUT_MS);
pi.on("exit", (code) => {
clearTimeout(timer);
log("info", `pi exited code=${code}; stdout=${piStdout.length}B stderr=${piStderr.length}B`);
if (code !== 0) {
// Pi crashed or timed out — return to main, drop the branch.
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(3, `pi exited ${code}`);
}
const newHead = git(["rev-parse", "HEAD"]).stdout.trim();
if (newHead === baseSha) {
// Pi did not commit anything. Clean up.
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(1, "pi made no commit");
}
// Verify the commit touched only runtime/.
const filesChanged = git(["diff", "--name-only", `${baseSha}..HEAD`]).stdout.trim().split("\n").filter(Boolean);
const outside = filesChanged.filter((f) => !f.startsWith("runtime/"));
if (outside.length > 0) {
log("error", `commit touched files outside runtime/: ${outside.join(", ")} — discarding`);
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(2, "patch touched off-limits files");
}
// Cherry-pick onto main.
git(["checkout", "main"]);
const cherry = git(["cherry-pick", newHead]);
if (cherry.status !== 0) {
log("error", `cherry-pick failed: ${cherry.stderr}`);
// Leave the branch around for operator inspection; abort the failed
// cherry-pick so main is clean.
git(["cherry-pick", "--abort"]);
exit(4, `cherry-pick conflict — see branch ${branch}`);
}
// Success — delete the feature branch (the commit is on main now).
git(["branch", "-D", branch]);
log("info", `patch applied to main as ${git(["rev-parse", "HEAD"]).stdout.trim().slice(0, 8)}`);
exit(0, `applied ${filenameArg}`);
});