feat(runtime): fully autonomous self-healing — no operator approval (#10)
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: Yuriy Mayatnikov <mayatnikov@me.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #10.
This commit is contained in:
+87
-20
@@ -30,6 +30,7 @@ import {
|
||||
readProposal,
|
||||
approveProposal,
|
||||
} from "./state-store.js";
|
||||
import { startAutoImprover } from "./auto-improve.js";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
@@ -136,7 +137,9 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
}
|
||||
reflexCtx.busy = true;
|
||||
reflexCtx.currentActionLabel = label;
|
||||
writeCurrentTask({ label, status: "in_progress", snapshot: lastSnapshot });
|
||||
// current-task is a resume anchor — keep it small. Embedding the full
|
||||
// perception snapshot blows the file up to ~3 KB per write × every action.
|
||||
writeCurrentTask({ label, status: "in_progress", position: lastSnapshot.position });
|
||||
info("dispatch", `→ ${label}`);
|
||||
Promise.resolve()
|
||||
.then(() => fn())
|
||||
@@ -170,14 +173,56 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
}
|
||||
|
||||
// ---- failure tracking + proposal detection --------------------------------
|
||||
//
|
||||
// A proposal is a request to the LLM to patch the codebase. They cost tokens
|
||||
// and may produce risky patches that need rolling back. We file them ONLY for
|
||||
// failures that genuinely look like bugs the reflex layer can't handle on its
|
||||
// own. Everything else is a feature gap the script should solve via reflex
|
||||
// chain reordering, cooldowns, or new primitives.
|
||||
|
||||
const PROPOSAL_THRESHOLD = 3; // same labelled action fails 3+ times in a row
|
||||
const PROPOSAL_THRESHOLD = 5; // raised from 3 to dampen spam
|
||||
let lastProposalAt = 0;
|
||||
const PROPOSAL_COOLDOWN_MS = 30 * 60 * 1000; // don't spam proposal files
|
||||
const PROPOSAL_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
|
||||
// Detail substrings that mean "this is a known feature gap, the bot handles
|
||||
// it via reflex routing already". Don't file a proposal — the bot will switch
|
||||
// strategies on its own. If something here is wrong, fix the routing.
|
||||
const NORMAL_FAILURE_SUBSTRINGS = [
|
||||
"no reachable log",
|
||||
"no log within",
|
||||
"no bed in range",
|
||||
"no food in inventory",
|
||||
"no target in reach",
|
||||
"rate-limited",
|
||||
"can't see you nearby",
|
||||
"returned false",
|
||||
"no result",
|
||||
];
|
||||
|
||||
// Detail substrings that look like a real bug — patch-worthy.
|
||||
const BUG_FAILURE_SUBSTRINGS = [
|
||||
"TypeError",
|
||||
"ReferenceError",
|
||||
"Cannot read properties",
|
||||
"is not a function",
|
||||
"is not iterable",
|
||||
"is not defined",
|
||||
"unknown block",
|
||||
"unknown item",
|
||||
];
|
||||
|
||||
function classifyFailure(detail) {
|
||||
const s = String(detail ?? "");
|
||||
if (BUG_FAILURE_SUBSTRINGS.some((sub) => s.includes(sub))) return "bug";
|
||||
if (NORMAL_FAILURE_SUBSTRINGS.some((sub) => s.includes(sub))) return "feature-gap";
|
||||
if (s.includes("timed out")) return "timeout";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function recordFailure(label, detail) {
|
||||
reflexCtx.recentFailures.push({ ts: Date.now(), label, detail });
|
||||
if (reflexCtx.recentFailures.length > 10) reflexCtx.recentFailures.shift();
|
||||
const kind = classifyFailure(detail);
|
||||
reflexCtx.recentFailures.push({ ts: Date.now(), label, detail, kind });
|
||||
if (reflexCtx.recentFailures.length > 20) reflexCtx.recentFailures.shift();
|
||||
maybeFileProposal(label);
|
||||
}
|
||||
|
||||
@@ -186,7 +231,7 @@ function clearRecentFailures(label) {
|
||||
}
|
||||
|
||||
function maybeFileProposal(label) {
|
||||
// Count consecutive trailing failures with the same label.
|
||||
// Same-label trailing run.
|
||||
const trailing = [];
|
||||
for (let i = reflexCtx.recentFailures.length - 1; i >= 0; i--) {
|
||||
const f = reflexCtx.recentFailures[i];
|
||||
@@ -195,37 +240,58 @@ function maybeFileProposal(label) {
|
||||
}
|
||||
if (trailing.length < PROPOSAL_THRESHOLD) return;
|
||||
if (Date.now() - lastProposalAt < PROPOSAL_COOLDOWN_MS) return;
|
||||
|
||||
// Only file when the run is dominated by bug-class failures (any single
|
||||
// bug counts) OR persistent timeouts on the same operation. Feature gaps
|
||||
// are skipped — the reflex layer should re-route, not the LLM.
|
||||
const anyBug = trailing.some((f) => f.kind === "bug");
|
||||
const allTimeout = trailing.every((f) => f.kind === "timeout");
|
||||
if (!anyBug && !allTimeout) return;
|
||||
|
||||
lastProposalAt = Date.now();
|
||||
|
||||
const summary = `${label} failed ${trailing.length}× in a row`;
|
||||
const summary = `${label} failed ${trailing.length}× in a row (${anyBug ? "bug" : "persistent timeout"})`;
|
||||
const slimSnapshot = lastSnapshot && {
|
||||
position: lastSnapshot.position,
|
||||
health: lastSnapshot.health,
|
||||
food: lastSnapshot.food,
|
||||
inventory: lastSnapshot.inventory,
|
||||
isDay: lastSnapshot.isDay,
|
||||
closestHostile: lastSnapshot.closestHostile,
|
||||
dimension: lastSnapshot.dimension,
|
||||
};
|
||||
const body = [
|
||||
`# Repeated failure: ${label}`,
|
||||
"",
|
||||
`Class: **${anyBug ? "bug" : "persistent timeout"}**.`,
|
||||
"",
|
||||
"## What happened",
|
||||
"",
|
||||
`The reflex layer dispatched \`${label}\` ${trailing.length} times in succession without a single success.`,
|
||||
"",
|
||||
"## Most recent failures",
|
||||
"",
|
||||
...trailing.slice(0, 5).map(
|
||||
(f, i) => `${i + 1}. \`${new Date(f.ts).toISOString()}\` — ${JSON.stringify(f.detail).slice(0, 200)}`,
|
||||
),
|
||||
...trailing
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(f, i) =>
|
||||
`${i + 1}. \`${new Date(f.ts).toISOString()}\` [${f.kind}] ${JSON.stringify(f.detail).slice(0, 200)}`,
|
||||
),
|
||||
"",
|
||||
"## Snapshot at moment of last failure",
|
||||
"## Slim snapshot",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(lastSnapshot, null, 2),
|
||||
JSON.stringify(slimSnapshot, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"## Suggested next step",
|
||||
"## Constraints for the patch",
|
||||
"",
|
||||
"Operator: review whether the reflex should:",
|
||||
"- back off (cooldown extension)",
|
||||
"- switch to a different action variant",
|
||||
"- escalate to Pi for situational reasoning",
|
||||
"- or whether the underlying primitive in `runtime/actions.js` needs work.",
|
||||
"",
|
||||
`Approve this proposal (move to \`proposals/approved/\`) and run \`npm run propose:apply <filename>\` to delegate a patch attempt to Pi headless.`,
|
||||
"- Touch only files under `runtime/`. Don't touch `extensions/`, `tui/`, or any docs.",
|
||||
"- Don't introduce new npm dependencies.",
|
||||
"- Don't change `.env` or anything in `state/`.",
|
||||
"- Don't push, don't open a PR. Commit on the current branch only.",
|
||||
"- Prefer the smallest viable fix. A 3-line guard is better than a 30-line refactor.",
|
||||
"- If the failure is genuinely irrecoverable (server-side, not code), document it in a code comment and exit 1.",
|
||||
].join("\n");
|
||||
|
||||
const { filename } = writeProposal({ kind: `repeated-fail-${label}`, summary, body });
|
||||
@@ -591,3 +657,4 @@ ipc = createIpcServer({
|
||||
});
|
||||
connect();
|
||||
startTickLoop();
|
||||
startAutoImprover();
|
||||
|
||||
Reference in New Issue
Block a user