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:
+4
-2
@@ -11,8 +11,10 @@
|
||||
"scripts": {
|
||||
"agent": "pi",
|
||||
"agent:resume": "pi -c",
|
||||
"bot": "node runtime/bot.js",
|
||||
"tui": "tsx tui/tui.tsx"
|
||||
"bot": "node runtime/supervisor.js",
|
||||
"bot:bare": "node runtime/bot.js",
|
||||
"tui": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
+138
-2
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
// propose:apply <filename>
|
||||
//
|
||||
// Take an approved proposal from state/<host>/proposals/approved/, build a
|
||||
// task prompt for Pi headless, hand it the proposal + relevant repo context,
|
||||
// and let Pi write the patch on a new feature branch. The script:
|
||||
//
|
||||
// 1. Verifies the proposal exists in proposals/approved/.
|
||||
// 2. Refuses to run on a dirty working tree.
|
||||
// 3. Creates a new branch `feat/proposal-<slug>` off the current HEAD.
|
||||
// 4. Spawns `pi -p "<prompt>"` and streams its stdout to the terminal.
|
||||
// 5. After Pi exits, prints the resulting `git status` so the operator
|
||||
// can inspect what changed before pushing/merging.
|
||||
//
|
||||
// We do NOT push or merge automatically — the operator reviews `git diff`,
|
||||
// runs the smoke test, and decides.
|
||||
|
||||
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, "..");
|
||||
|
||||
const filenameArg = process.argv[2];
|
||||
if (!filenameArg) {
|
||||
console.error("usage: npm run propose:apply <filename>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function findApproved(filename) {
|
||||
const stateRoot = path.join(REPO_ROOT, "state");
|
||||
if (!fs.existsSync(stateRoot)) return null;
|
||||
for (const host of fs.readdirSync(stateRoot)) {
|
||||
const candidate = path.join(stateRoot, host, "proposals", "approved", filename);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const proposalPath = findApproved(filenameArg);
|
||||
if (!proposalPath) {
|
||||
console.error(`proposal not found in any state/<host>/proposals/approved/: ${filenameArg}`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: REPO_ROOT })
|
||||
.stdout.toString()
|
||||
.trim();
|
||||
if (dirty) {
|
||||
console.error("working tree is dirty — commit or stash first");
|
||||
console.error(dirty);
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const slug = filenameArg
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
||||
.slice(0, 60);
|
||||
const branch = `feat/proposal-${slug}`;
|
||||
|
||||
const branchRes = spawnSync("git", ["checkout", "-b", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
if (branchRes.status !== 0) {
|
||||
console.error(`could not create branch ${branch}`);
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
const proposalText = fs.readFileSync(proposalPath, "utf8");
|
||||
|
||||
const prompt = [
|
||||
"You are about to patch the pepa-pi-bot repo to address an approved proposal.",
|
||||
"",
|
||||
"## The proposal",
|
||||
"",
|
||||
proposalText,
|
||||
"",
|
||||
"## Your task",
|
||||
"",
|
||||
"Read the proposal carefully. Make a *minimal* patch to address the underlying problem.",
|
||||
"",
|
||||
"Rules:",
|
||||
"1. Touch as few files as possible. Prefer fixing the smallest thing that addresses the root cause.",
|
||||
"2. The fix should land under `runtime/` (the hybrid runtime) — NOT under `extensions/` (Pi-only legacy).",
|
||||
"3. Honor the existing patterns:",
|
||||
" - Reflexes live in `runtime/reflex.js` and follow the `(ctx) => { action, ... }` shape.",
|
||||
" - Actions live in `runtime/actions.js` and return `{ ok, detail }` with a hard timeout.",
|
||||
" - State on disk goes through `runtime/state-store.js`.",
|
||||
"4. Add or update a short comment explaining WHY only if the change is non-obvious.",
|
||||
"5. Do not introduce new npm dependencies without a strong reason.",
|
||||
"6. Do not push, do not open a PR — just commit on the current branch.",
|
||||
"7. Use a conventional commit message (feat/fix/refactor scope).",
|
||||
"",
|
||||
"When you're done, commit and stop. The operator will review.",
|
||||
].join("\n");
|
||||
|
||||
console.log("\n=== spawning pi -p (this may take a few minutes) ===\n");
|
||||
const child = spawn("pi", ["-p", prompt], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
child.on("exit", (code) => {
|
||||
console.log(`\n=== pi exited code=${code} ===\n`);
|
||||
console.log("git status:");
|
||||
spawnSync("git", ["status"], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
console.log("\nDiff summary:");
|
||||
spawnSync("git", ["diff", "--stat", "HEAD~1..HEAD"], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
console.log(`\nBranch: ${branch}`);
|
||||
console.log(`Next steps: review the diff, smoke-test with 'npm run bot', then 'git push -u origin ${branch}' if good.`);
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
+67
-2
@@ -35,6 +35,7 @@ type State = {
|
||||
paused: boolean;
|
||||
piStream: string;
|
||||
piRunning: boolean;
|
||||
proposal: { filename: string | null; body: string | null; total: number } | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
@@ -47,7 +48,9 @@ type Action =
|
||||
| { type: "pi-chunk"; payload: { stream: string; text: string } }
|
||||
| { type: "pi-done"; payload: { code: number; durationMs: number } }
|
||||
| { type: "set-paused"; paused: boolean }
|
||||
| { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } };
|
||||
| { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } }
|
||||
| { type: "proposal"; payload: { filename: string | null; body: string | null; total: number } }
|
||||
| { type: "proposal-close" };
|
||||
|
||||
const MAX_LOGS = 200;
|
||||
const MAX_CHAT = 100;
|
||||
@@ -88,6 +91,10 @@ function reducer(state: State, action: Action): State {
|
||||
snapshot: action.payload.snapshot || {},
|
||||
logs: action.payload.recentLogs || [],
|
||||
};
|
||||
case "proposal":
|
||||
return { ...state, proposal: action.payload };
|
||||
case "proposal-close":
|
||||
return { ...state, proposal: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -114,11 +121,42 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
|
||||
: "?"}{" "}
|
||||
day={String(snapshot.isDay ?? "?")} hostiles={snapshot.hostileCount ?? 0}
|
||||
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
|
||||
{snapshot.pendingProposals ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalPanel({
|
||||
proposal,
|
||||
onClose,
|
||||
onApprove,
|
||||
}: {
|
||||
proposal: { filename: string | null; body: string | null; total: number };
|
||||
onClose: () => void;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
if (!proposal.filename) {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="gray">
|
||||
<Text>No pending proposals. ([Esc] to close)</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const lines = (proposal.body ?? "").split("\n").slice(0, 30);
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="magenta">
|
||||
<Text bold color="magenta">
|
||||
proposal: {proposal.filename} (total pending: {proposal.total})
|
||||
</Text>
|
||||
{lines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
<Text dimColor>[y]es approve [n]o close — uses npm run propose:apply afterwards</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EventLog({ logs }: { logs: LogEntry[] }) {
|
||||
const last = logs.slice(-14);
|
||||
return (
|
||||
@@ -175,6 +213,7 @@ function App() {
|
||||
paused: false,
|
||||
piStream: "",
|
||||
piRunning: false,
|
||||
proposal: null,
|
||||
});
|
||||
|
||||
const [client] = useState(() => createIpcClient());
|
||||
@@ -207,6 +246,9 @@ function App() {
|
||||
case EVENT_TYPES.ASK_PI_DONE:
|
||||
dispatch({ type: "pi-done", payload: frame.payload });
|
||||
break;
|
||||
case EVENT_TYPES.PROPOSAL:
|
||||
dispatch({ type: "proposal", payload: frame.payload });
|
||||
break;
|
||||
}
|
||||
};
|
||||
(client as any).on("connected", onConnected);
|
||||
@@ -222,6 +264,16 @@ function App() {
|
||||
|
||||
useInput((input, key) => {
|
||||
if (mode !== "idle") return; // text input has its own handling
|
||||
// Proposal panel is open — accept y/n only.
|
||||
if (state.proposal) {
|
||||
if (input === "y" && state.proposal.filename) {
|
||||
client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename });
|
||||
dispatch({ type: "proposal-close" });
|
||||
} else if (input === "n" || key.escape) {
|
||||
dispatch({ type: "proposal-close" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (input === "q") {
|
||||
client.close();
|
||||
exit();
|
||||
@@ -240,6 +292,7 @@ function App() {
|
||||
}
|
||||
if (input === "c") setMode("chat");
|
||||
if (input === "a") setMode("ask-pi");
|
||||
if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {});
|
||||
});
|
||||
|
||||
function submit(value: string) {
|
||||
@@ -254,7 +307,7 @@ function App() {
|
||||
|
||||
const hotkeyHint =
|
||||
mode === "idle"
|
||||
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [q]uit"
|
||||
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit"
|
||||
: mode === "chat"
|
||||
? "chat → MC (Enter to send, Esc to cancel)"
|
||||
: "ask-pi → spawn pi -p (Enter to send)";
|
||||
@@ -267,6 +320,18 @@ function App() {
|
||||
<ChatPanel chat={state.chat} />
|
||||
</Box>
|
||||
<PiPanel piStream={state.piStream} piRunning={state.piRunning} />
|
||||
{state.proposal ? (
|
||||
<ProposalPanel
|
||||
proposal={state.proposal}
|
||||
onClose={() => dispatch({ type: "proposal-close" })}
|
||||
onApprove={() => {
|
||||
if (state.proposal?.filename) {
|
||||
client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename });
|
||||
dispatch({ type: "proposal-close" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Box borderStyle="single" paddingX={1}>
|
||||
{mode === "idle" ? (
|
||||
<Text dimColor>{hotkeyHint}</Text>
|
||||
|
||||
Reference in New Issue
Block a user