feat(runtime): state persistence + proposals + supervisor hot-restart (#6)

Closes the self-improvement loop end-to-end:

  reflex fails 3× → proposal file → operator approves in TUI →
  `npm run propose:apply <file>` spawns Pi on a feature branch →
  Pi commits the patch → supervisor watches runtime/*.js and
  restarts the child on change.

runtime/state-store.js — atomic current-task.json writes, daily diary
  append, proposals/ + proposals/approved/ helpers.

runtime/bot.js:
  - ctx.dispatch writes current-task.json on start and updates it on
    completion / failure / throw.
  - failure tracker: 3 consecutive same-label failures → writeProposal()
    with the snapshot, labels, and a suggested-next-step section.
    30-min cooldown prevents proposal spam.
  - on startup, surfaces resume info (previous task + pending proposal
    count); on death, clears current-task.json + writes diary line.
  - new IPC commands: PROPOSAL_LATEST returns the newest pending
    proposal body; PROPOSAL_APPROVE moves it to proposals/approved/.

tui/tui.tsx — status bar shows `[proposals N, press y]` badge when
  bot.pendingProposals > 0. Hotkey 'y' opens the proposal panel; 'y'
  approves, 'n'/Esc closes.

scripts/propose-apply.js — given an approved proposal filename, creates
  a `feat/proposal-<slug>` branch and spawns `pi -p` with the proposal
  + repo-conventions prompt. Refuses on dirty tree. No auto-push, no
  auto-merge — operator reviews the diff and decides.

runtime/supervisor.js — forks bot.js as a child, watches runtime/*.js,
  restarts on file change or on child exit code 42. Rate-limited at 5
  restarts/minute. SIGINT/SIGTERM forward cleanly. `npm run bot` now
  goes through the supervisor; `npm run bot:bare` skips it.

Smoke-tested: supervisor spawned, bot connected to MC, spawned at
expected coords, diary line written, state cleanup on SIGTERM correct.

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 #6.
This commit is contained in:
Yuriy Mayatnikov
2026-05-25 16:20:09 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent ee2b4c26eb
commit cd14bbf89a
7 changed files with 560 additions and 6 deletions
+138 -2
View File
@@ -20,6 +20,16 @@ import { runTick } from "./reflex.js";
import { createIpcServer } from "./ipc-server.js";
import { askPi } from "./pi-bridge.js";
import { COMMAND_TYPES, EVENT_TYPES } from "./ipc-protocol.js";
import {
readCurrentTask,
writeCurrentTask,
clearCurrentTask,
appendDiary,
writeProposal,
listProposals,
readProposal,
approveProposal,
} from "./state-store.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -50,6 +60,8 @@ const reflexCtx = {
idleCounter: 0,
lastEatAt: 0,
lastSleepAttemptAt: 0,
// Tracks repeated failure of the same labelled action — triggers a proposal.
recentFailures: [], // [{label, detail, ts}], capped at 10
dispatch: dispatchAction,
clearOperatorGoal: () => {
reflexCtx.operatorGoal = null;
@@ -124,11 +136,20 @@ function dispatchAction(fn, label, opts = {}) {
}
reflexCtx.busy = true;
reflexCtx.currentActionLabel = label;
writeCurrentTask({ label, status: "in_progress", snapshot: lastSnapshot });
info("dispatch", `${label}`);
Promise.resolve()
.then(() => fn())
.then((res) => {
info("dispatch", `${label} ${res?.ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`);
const ok = !!res?.ok;
info(
"dispatch",
`${label} ${ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`,
);
writeCurrentTask({ label, status: ok ? "completed" : "failed", detail: res?.detail });
if (!ok) recordFailure(label, res?.detail);
else clearRecentFailures(label);
if (opts.onComplete) {
try {
opts.onComplete(res ?? { ok: false, detail: "no result" });
@@ -139,6 +160,8 @@ function dispatchAction(fn, label, opts = {}) {
})
.catch((e) => {
warn("dispatch", `${label} threw: ${e?.message ?? e}`);
writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) });
recordFailure(label, String(e?.message ?? e));
})
.finally(() => {
reflexCtx.busy = false;
@@ -146,6 +169,70 @@ function dispatchAction(fn, label, opts = {}) {
});
}
// ---- failure tracking + proposal detection --------------------------------
const PROPOSAL_THRESHOLD = 3; // same labelled action fails 3+ times in a row
let lastProposalAt = 0;
const PROPOSAL_COOLDOWN_MS = 30 * 60 * 1000; // don't spam proposal files
function recordFailure(label, detail) {
reflexCtx.recentFailures.push({ ts: Date.now(), label, detail });
if (reflexCtx.recentFailures.length > 10) reflexCtx.recentFailures.shift();
maybeFileProposal(label);
}
function clearRecentFailures(label) {
reflexCtx.recentFailures = reflexCtx.recentFailures.filter((f) => f.label !== label);
}
function maybeFileProposal(label) {
// Count consecutive trailing failures with the same label.
const trailing = [];
for (let i = reflexCtx.recentFailures.length - 1; i >= 0; i--) {
const f = reflexCtx.recentFailures[i];
if (f.label === label) trailing.push(f);
else break;
}
if (trailing.length < PROPOSAL_THRESHOLD) return;
if (Date.now() - lastProposalAt < PROPOSAL_COOLDOWN_MS) return;
lastProposalAt = Date.now();
const summary = `${label} failed ${trailing.length}× in a row`;
const body = [
`# Repeated failure: ${label}`,
"",
"## 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)}`,
),
"",
"## Snapshot at moment of last failure",
"",
"```json",
JSON.stringify(lastSnapshot, null, 2),
"```",
"",
"## Suggested next step",
"",
"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.`,
].join("\n");
const { filename } = writeProposal({ kind: `repeated-fail-${label}`, summary, body });
warn("proposal", `filed ${filename}: ${summary}`);
appendDiary(`proposal filed: ${filename} (${summary})`);
}
// ---- operator chat commands ------------------------------------------------
function isOperator(username) {
@@ -228,6 +315,7 @@ function connect() {
bot.once("spawn", () => {
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`);
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
});
@@ -251,9 +339,11 @@ function connect() {
bot.on("death", () => {
const pos = bot.entity?.position;
warn("mc", `died at ${JSON.stringify(pos)}`);
appendDiary(`died at ${pos?.x.toFixed(0)},${pos?.y.toFixed(0)},${pos?.z.toFixed(0)}`);
ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos });
// On death, drop any operator goal — they need to ask again.
reflexCtx.operatorGoal = null;
clearCurrentTask();
});
bot.on("kicked", (reason) => {
@@ -325,6 +415,7 @@ function tick() {
if (shuttingDown) return;
if (bot && bot.entity) {
lastSnapshot = buildSnapshot(bot);
lastSnapshot.pendingProposals = listProposals().length;
reflexCtx.snapshot = lastSnapshot;
if (!reflexPaused) {
const result = runTick(reflexCtx);
@@ -388,6 +479,32 @@ function handleCommand(msg, send) {
case COMMAND_TYPES.SNAPSHOT:
send(EVENT_TYPES.STATUS, lastSnapshot);
break;
case COMMAND_TYPES.PROPOSAL_LATEST: {
const all = listProposals();
if (all.length === 0) {
send(EVENT_TYPES.PROPOSAL, { filename: null, body: null, total: 0 });
return;
}
const filename = all[all.length - 1];
send(EVENT_TYPES.PROPOSAL, {
filename,
body: readProposal(filename),
total: all.length,
});
break;
}
case COMMAND_TYPES.PROPOSAL_APPROVE: {
const filename = msg.payload?.filename;
if (!filename) return;
try {
const dst = approveProposal(filename);
info("proposal", `approved ${filename}${dst}`);
appendDiary(`proposal approved: ${filename}`);
} catch (e) {
send(EVENT_TYPES.ERROR, { source: "proposal", text: e.message });
}
break;
}
default:
warn("ipc", `unknown command type: ${msg.type}`);
}
@@ -412,8 +529,27 @@ process.on("SIGINT", () => gracefulExit(0));
process.on("SIGTERM", () => gracefulExit(0));
info("runtime", `pepa runtime starting; cfg=${JSON.stringify(redactedConfig())}`);
// Resume info: surface stale state across restarts. We don't auto-resume any
// action — but we tell the operator if the bot died mid-task last time, and
// the count of pending proposals.
const lastTask = readCurrentTask();
if (lastTask && lastTask.label) {
info("resume", `previous task: ${lastTask.label} (${lastTask.status ?? "?"}, ${lastTask.ts ?? "?"})`);
if (lastTask.status === "in_progress") {
warn("resume", `last shutdown happened mid-action — operator should review state/<host>/current-task.json`);
}
}
const pendingProposals = listProposals();
if (pendingProposals.length > 0) {
warn("resume", `${pendingProposals.length} pending proposal(s) — see state/<host>/proposals/`);
}
ipc = createIpcServer({
getStatusSnapshot: () => lastSnapshot,
getStatusSnapshot: () => ({
...lastSnapshot,
pendingProposals: listProposals().length,
}),
onCommand: handleCommand,
});
connect();
+3
View File
@@ -14,6 +14,7 @@ export const EVENT_TYPES = Object.freeze({
ASK_PI_CHUNK: "ask-pi-chunk", // streamed stdout chunk from Pi subprocess
ASK_PI_DONE: "ask-pi-done", // Pi subprocess exited { code, durationMs }
HELLO: "hello", // sent on client connect with current snapshot
PROPOSAL: "proposal", // pending proposal payload { filename, body }
});
// Client → server commands.
@@ -24,6 +25,8 @@ export const COMMAND_TYPES = Object.freeze({
CHAT: "cmd:chat", // { text } sent into MC as bot
ASK_PI: "cmd:ask-pi", // { prompt } spawn `pi -p` and stream output
SNAPSHOT: "cmd:snapshot", // request immediate STATUS event
PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal
PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/
});
export function encodeFrame(obj) {
+138
View File
@@ -0,0 +1,138 @@
// Per-server state on disk. Two surfaces:
// - current-task.json — what action is in flight (or last finished).
// Used as the resume anchor across restarts. Atomically rewritten via
// write-rename to avoid torn reads.
// - diary/YYYY-MM-DD.md — append-only daily journal. One line per
// meaningful milestone (death, escalation, first attack on a new mob
// type, sleep success, etc.). The reflex layer decides what counts.
//
// All writes are sync — these files are tiny and per-tick at most.
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "./config.js";
const CURRENT_TASK_PATH = path.join(stateDir, "current-task.json");
const DIARY_DIR = path.join(stateDir, "diary");
const PROPOSALS_DIR = path.join(stateDir, "proposals");
const PROPOSALS_APPROVED_DIR = path.join(PROPOSALS_DIR, "approved");
fs.mkdirSync(DIARY_DIR, { recursive: true });
fs.mkdirSync(PROPOSALS_DIR, { recursive: true });
fs.mkdirSync(PROPOSALS_APPROVED_DIR, { recursive: true });
// ---- current-task ----------------------------------------------------------
export function readCurrentTask() {
try {
const raw = fs.readFileSync(CURRENT_TASK_PATH, "utf8").trim();
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
if (e.code === "ENOENT") return null;
// corrupt file — treat as no task
return null;
}
}
function writeAtomic(filePath, content) {
const tmp = `${filePath}.tmp`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
}
export function writeCurrentTask(task) {
if (!task) {
clearCurrentTask();
return;
}
writeAtomic(
CURRENT_TASK_PATH,
JSON.stringify({ ts: new Date().toISOString(), ...task }, null, 2),
);
}
export function clearCurrentTask() {
try {
writeAtomic(CURRENT_TASK_PATH, "{}\n");
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
}
// ---- diary -----------------------------------------------------------------
function diaryPath(date = new Date()) {
const stamp = date.toISOString().slice(0, 10);
return path.join(DIARY_DIR, `${stamp}.md`);
}
export function appendDiary(text) {
const stamp = new Date().toISOString().slice(11, 19);
const line = `${stamp} ${text}\n`;
fs.appendFileSync(diaryPath(), line);
}
// ---- proposals -------------------------------------------------------------
function slugify(s) {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
export function writeProposal({ kind, summary, body }) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const filename = `${stamp}-${slugify(kind)}.md`;
const filePath = path.join(PROPOSALS_DIR, filename);
const content = [
"---",
`kind: ${kind}`,
`ts: ${new Date().toISOString()}`,
`summary: ${JSON.stringify(summary)}`,
"approved: false",
"---",
"",
body,
"",
].join("\n");
fs.writeFileSync(filePath, content);
return { filePath, filename };
}
export function listProposals({ approved = false } = {}) {
const dir = approved ? PROPOSALS_APPROVED_DIR : PROPOSALS_DIR;
try {
return fs
.readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.sort();
} catch (e) {
if (e.code === "ENOENT") return [];
throw e;
}
}
export function readProposal(filename, { approved = false } = {}) {
const dir = approved ? PROPOSALS_APPROVED_DIR : PROPOSALS_DIR;
const filePath = path.join(dir, filename);
return fs.readFileSync(filePath, "utf8");
}
export function approveProposal(filename) {
const src = path.join(PROPOSALS_DIR, filename);
const dst = path.join(PROPOSALS_APPROVED_DIR, filename);
const content = fs.readFileSync(src, "utf8").replace(/^approved: false/m, "approved: true");
fs.writeFileSync(dst, content);
fs.unlinkSync(src);
return dst;
}
export const paths = Object.freeze({
currentTask: CURRENT_TASK_PATH,
diary: DIARY_DIR,
proposals: PROPOSALS_DIR,
proposalsApproved: PROPOSALS_APPROVED_DIR,
});
+101
View File
@@ -0,0 +1,101 @@
// Supervisor: forks runtime/bot.js as a child process and restarts it
// when the child exits with a "reload requested" code (RELOAD_EXIT_CODE).
// Also watches runtime/*.js — when a file changes, signals the child to
// reload itself by exiting with the same code.
//
// True hot module reload in Node ESM is fragile (caches, open sockets,
// mineflayer client state). Restart-on-change is the same outcome with
// none of the gotchas: the only thing the bot loses is the MC TCP
// connection, which it would reconnect anyway after a Mineflayer kick.
//
// Run via `npm run bot`. Falls back to plain `node runtime/bot.js` via
// `npm run bot:bare` if you want to skip the supervisor.
import { spawn } 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 RUNTIME_DIR = __dirname;
const BOT_ENTRY = path.join(RUNTIME_DIR, "bot.js");
export const RELOAD_EXIT_CODE = 42;
const WATCH_DEBOUNCE_MS = 800;
const MAX_RESTARTS_PER_MINUTE = 5;
let child = null;
let restartingDueToWatch = false;
const restartTimestamps = [];
function nowMs() {
return Date.now();
}
function spawnChild() {
child = spawn(process.execPath, [BOT_ENTRY], {
stdio: "inherit",
env: { ...process.env, PEPA_SUPERVISED: "1" },
});
child.on("exit", (code, signal) => {
console.log(`[supervisor] child exited code=${code} signal=${signal}`);
const wantsRestart = code === RELOAD_EXIT_CODE || restartingDueToWatch;
restartingDueToWatch = false;
if (!wantsRestart) {
// Clean exit (SIGINT/SIGTERM bubble) or crash — don't relaunch.
process.exit(code ?? 0);
}
// Rate-limit restarts so a crash loop doesn't burn CPU.
const now = nowMs();
restartTimestamps.push(now);
while (restartTimestamps.length && now - restartTimestamps[0] > 60_000) restartTimestamps.shift();
if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) {
console.error(`[supervisor] too many restarts (${restartTimestamps.length} in 60s) — giving up`);
process.exit(1);
}
console.log(`[supervisor] restarting in 500ms…`);
setTimeout(spawnChild, 500);
});
child.on("error", (err) => {
console.error(`[supervisor] failed to spawn child: ${err.message}`);
process.exit(1);
});
}
let debounceTimer = null;
function watchRuntime() {
const watcher = fs.watch(RUNTIME_DIR, { recursive: false }, (eventType, filename) => {
if (!filename || !filename.endsWith(".js")) return;
// supervisor.js itself is excluded — restarting THIS process from
// inside itself would require a separate exec, which we don't do.
if (filename === "supervisor.js") return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`[supervisor] ${filename} changed — restarting child`);
restartingDueToWatch = true;
child?.kill("SIGTERM");
}, WATCH_DEBOUNCE_MS);
});
watcher.on("error", (err) => {
console.error(`[supervisor] watcher error: ${err.message}`);
});
}
// Forward signals to the child, then exit ourselves once it has.
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => {
console.log(`[supervisor] forwarding ${sig} to child`);
if (!child) process.exit(0);
child.once("exit", () => process.exit(0));
child.kill(sig);
// hard cap in case the child hangs
setTimeout(() => process.exit(1), 5000).unref();
});
}
console.log(`[supervisor] starting; watching ${RUNTIME_DIR} for *.js changes`);
spawnChild();
watchRuntime();