Files
pepa-pi-bot/runtime/supervisor.js
T
mayatnikovandClaude Opus 4.7 29542f0559 fix(runtime): unstick scheduler + chop + sleep + bed/shelter/farm skills
Recovers the bot from the live-server symptoms reported 2026-05-26:
1) constant supervisor reconnects, 2) chop "clicks once and stops",
3) sleep does nothing without a bed and so blocks night-skipping for
other players, 4) curriculum reflex always fell through to wander.

Supervisor (#38):
- runtime/watch-filter.js: pure predicate excluding *.test.js + the
  supervisor itself; recursive:true so skills/ + social/ edits also
  restart. Burned a working main once when test files counted toward
  the rollback threshold.
- runtime/supervisor.js: watch-triggered restarts no longer count
  toward the crash-loop rollback path. Watcher is now recursive.

Chop / mine (#39):
- runtime/actions.js + runtime/skills/gather-stone.js: replaced raw
  pathfinder.goto + bot.dig with mineflayer-collectblock's
  bot.collectBlock.collect — handles approach, repositioning, LoS,
  dig and pickup as one primitive. Old version "swung once" because
  GoalGetToBlock often parked the bot in leaves above the log.

Sleep + bed (#40):
- runtime/actions.js: sleepInBed now ALSO places a carried bed on
  solid ground next to the bot and sleeps on it. Critical so the bot
  stops blocking player night-skipping the moment it owns a bed.

Bed pipeline (#41):
- runtime/skills/gather-wool.js: gather.wool skill — mines wool block
  if any nearby, otherwise shears or attacks the nearest sheep.
- runtime/skills/craft.js: craftBedSkill (any colour the bot has ≥3
  wool of, plus 3 planks, plus a table).
- runtime/curriculum.js: new milestone survive.bed sits between
  wood.tools and stone.32 so the bot gets a bed BEFORE everything else.
  Test fixture updated to include a red_bed in post-survive.bed stages.

Village / shelter / wheat (#42, #43):
- runtime/skills/build-shelter.js: village.build-shelter — real 3×3×3
  resumable hut blueprint around the recorded base, places one block
  per loop, idempotent so an interrupted build resumes correctly,
  marks each placed block in the owned-blocks ledger.
- runtime/skills/deposit-surplus.js: village.deposit-surplus opens
  the nearest chest and transfers surplus stacks while keeping a
  reserve of tools/food/bed.
- runtime/skills/farm-wheat.js: farm.wheat does one step per call
  (till adjacent-to-water grass, plant seeds, or harvest ripe wheat).
- runtime/curriculum.js: village.shelter milestone after base-site.

Scheduler glitch (root of "always wander"):
- runtime/bot.js: curriculum + locations are now computed BEFORE
  runTick. Previously they were stamped AFTER, so reflex.js saw
  snapshot.curriculum=undefined every tick and fell through to the
  wander fallback. Verified live: scheduler now dispatches
  gather.logs/gather.stone/craft.* by id via runSkill.

Eat-spam:
- runtime/reflex.js: eatReflex now checks inventory for actual food
  and updates lastEatAt on EVERY dispatch (not only successes), so a
  failed eat respects the 5 s cooldown instead of firing every tick.

npm test 123/123. Validated live on play.xmatic.team (curriculum
dispatched gather.logs via runSkill, recover hint switched to wander
when no log in range).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:12:21 +03:00

212 lines
7.6 KiB
JavaScript

// 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, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { stateDir } from "./config.js";
import { isWatchableJs } from "./watch-filter.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RUNTIME_DIR = __dirname;
const REPO_ROOT = path.resolve(RUNTIME_DIR, "..");
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;
// When a recent auto-patch breaks the bot, we roll it back. "Recent" =
// landed on main within the last ROLLBACK_FRESHNESS_MS. The signal is
// MAX_RESTARTS_PER_MINUTE exceeded — i.e. the patch reliably crashes.
const ROLLBACK_FRESHNESS_MS = 15 * 60_000;
const MAX_ROLLBACKS = 3; // hard ceiling per supervisor lifetime
// Pidfile prevents two supervisors from racing on the same MC nickname. The
// Minecraft server refuses the second login ("Игрок с данным никнеймом уже
// играет на сервере") and the loser enters a kick-reconnect loop forever.
// Observed live 2026-05-25 when a smoke-test bot stayed alive in the
// background after its operator window was closed.
const PID_FILE = path.join(stateDir, "supervisor.pid");
let child = null;
let restartingDueToWatch = false;
const restartTimestamps = [];
let rollbackCount = 0;
function lastCommitAgeMs() {
const res = spawnSync("git", ["log", "-1", "--format=%ct", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" });
if (res.status !== 0) return Number.POSITIVE_INFINITY;
const epochSec = Number.parseInt(res.stdout.trim(), 10);
if (!Number.isFinite(epochSec)) return Number.POSITIVE_INFINITY;
return Date.now() - epochSec * 1000;
}
function lastCommitTouchedRuntime() {
const res = spawnSync("git", ["diff", "--name-only", "HEAD~1..HEAD"], { cwd: REPO_ROOT, encoding: "utf8" });
if (res.status !== 0) return false;
return res.stdout.split("\n").some((f) => f.startsWith("runtime/"));
}
function rollbackLastCommit() {
const sha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" }).stdout.trim();
console.log(`[supervisor] rolling back HEAD (${sha.slice(0, 8)})`);
const reset = spawnSync("git", ["reset", "--hard", "HEAD~1"], {
cwd: REPO_ROOT,
encoding: "utf8",
stdio: "inherit",
});
if (reset.status !== 0) {
console.error(`[supervisor] git reset failed — operator intervention needed`);
return false;
}
rollbackCount++;
return true;
}
function nowMs() {
return Date.now();
}
function isProcessAlive(pid) {
try {
// Signal 0 doesn't kill — just probes existence + permission.
process.kill(pid, 0);
return true;
} catch (e) {
return e.code === "EPERM"; // exists but we don't own it
}
}
function acquireLock() {
try {
const existing = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
if (Number.isFinite(existing) && existing !== process.pid && isProcessAlive(existing)) {
console.error(
`[supervisor] another supervisor (pid=${existing}) is already running. ` +
`Stop it first ('kill ${existing}') or delete ${PID_FILE} if it's stale.`,
);
process.exit(2);
}
} catch (e) {
if (e.code !== "ENOENT") {
console.error(`[supervisor] could not read pidfile: ${e.message}`);
}
}
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(PID_FILE, String(process.pid));
}
function releaseLock() {
try {
const pid = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
if (pid === process.pid) fs.unlinkSync(PID_FILE);
} catch {}
}
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;
// Capture BEFORE clearing — watch-triggered restarts are intentional
// and must not be counted toward the crash-loop rollback threshold.
const isWatchRestart = 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. Watch-triggered
// restarts don't count — burned a working main once (2026-05-26) when
// edits to runtime/*.test.js looked like a crash loop and rolled back
// the scheduler PR.
if (!isWatchRestart) {
const now = nowMs();
restartTimestamps.push(now);
while (restartTimestamps.length && now - restartTimestamps[0] > 60_000) restartTimestamps.shift();
if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) {
// Crash loop. If the last commit is young AND touched runtime/, it
// probably broke us — roll it back and try once more.
const ageMs = lastCommitAgeMs();
if (
ageMs < ROLLBACK_FRESHNESS_MS &&
lastCommitTouchedRuntime() &&
rollbackCount < MAX_ROLLBACKS &&
rollbackLastCommit()
) {
console.log(`[supervisor] auto-rollback ${rollbackCount}/${MAX_ROLLBACKS} applied; restart counters reset`);
restartTimestamps.length = 0;
setTimeout(spawnChild, 500);
return;
}
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() {
// recursive:true so edits to runtime/skills/*.js and runtime/social/*.js
// also restart the child. macOS + Linux support recursive fs.watch on
// Node 20+.
const watcher = fs.watch(RUNTIME_DIR, { recursive: true }, (eventType, filename) => {
if (!isWatchableJs(filename)) 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`);
releaseLock();
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();
});
}
// Best-effort lock release on any other exit path (uncaught, exit 1, etc).
process.on("exit", releaseLock);
acquireLock();
console.log(`[supervisor] starting (pid=${process.pid}); watching ${RUNTIME_DIR} for *.js changes`);
spawnChild();
watchRuntime();