Files
pepa-pi-bot/runtime/pathfinder-watchdog.js
mayatnikovandClaude Opus 4.7 86e5294bb8 chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements)
Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this
state. Subsequent commits in this branch layer the knowledge base,
post-mortem coach, and persona narration on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:05:12 +03:00

139 lines
4.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pathfinder stuck-watchdog.
//
// Problem (observed live 2026-05-26): mineflayer-pathfinder computes a
// path once when goto() is called and does NOT recompute when the
// world changes mid-traversal. If a player places a block in front of
// the bot — or a creeper craters the path — pathfinder keeps trying to
// step into the old node and the bot just stands there pressing forward
// against the new obstacle until the goto() timeout (typically 3060 s).
//
// Fix: a poll loop that runs while `bot.pathfinder.goal` is non-null.
// Every WATCH_INTERVAL_MS we read the horizontal position. If we haven't
// moved STUCK_DELTA blocks in STUCK_WINDOW_MS, we declare the path
// stale and force a replan by clearing the goal and re-setting it. The
// pathfinder then recomputes against the current world, taking the new
// obstacle into account — including digging through it if canDig=true.
//
// Side benefit: even when no obstacle was placed, this catches the
// pathological "pathfinder stuck on a goal it can never reach" case
// (mineflayer-pathfinder issue #222) much earlier than the 45 s goto
// timeout we wrapped goto() in.
import { info, warn } from "./log.js";
const WATCH_INTERVAL_MS = 2_000;
const STUCK_WINDOW_MS = 6_000;
const STUCK_DELTA = 0.5;
const MIN_TRAVEL_TIME_MS = 1_500; // grace at the start so we don't replan during initial step
const MAX_REPLAN_PER_GOAL = 3;
function hpos(p) {
return p ? { x: p.x, z: p.z } : null;
}
function hdist(a, b) {
if (!a || !b) return Number.POSITIVE_INFINITY;
return Math.hypot(a.x - b.x, a.z - b.z);
}
function collectBlockOwnsPathfinder(bot) {
try {
const targets = bot.collectBlock?.targets;
if (!targets) return false;
if (typeof targets.empty === "boolean") return targets.empty === false;
if (Array.isArray(targets.targets)) return targets.targets.length > 0;
if (typeof targets.size === "number") return targets.size > 0;
return false;
} catch {
return false;
}
}
export function createPathfinderWatchdog(bot, {
intervalMs = WATCH_INTERVAL_MS,
windowMs = STUCK_WINDOW_MS,
delta = STUCK_DELTA,
maxReplans = MAX_REPLAN_PER_GOAL,
} = {}) {
if (!bot) throw new Error("pathfinder-watchdog: bot required");
let lastSeenAt = 0;
let lastSeenPos = null;
let lastGoal = null;
let goalStartedAt = 0;
let replansThisGoal = 0;
let stopped = false;
let timer = null;
function tick() {
if (stopped) return;
const pf = bot.pathfinder;
const goal = pf?.goal;
if (!goal) {
// No active goal — reset our state.
lastGoal = null;
lastSeenPos = null;
replansThisGoal = 0;
return;
}
if (goal !== lastGoal) {
// New goal started — reset counters.
lastGoal = goal;
lastSeenPos = hpos(bot.entity?.position);
lastSeenAt = Date.now();
goalStartedAt = Date.now();
replansThisGoal = 0;
return;
}
if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return;
if (collectBlockOwnsPathfinder(bot)) {
// mineflayer-collectblock treats pathfinder goal changes as a
// hard cancellation ("The goal was changed before it could be
// completed"). Let the collect skill's own timeout/blacklist
// handle these paths instead of invalidating the current dig.
lastSeenPos = hpos(bot.entity?.position);
lastSeenAt = Date.now();
return;
}
const now = Date.now();
const here = hpos(bot.entity?.position);
if (here && hdist(here, lastSeenPos) >= delta) {
lastSeenPos = here;
lastSeenAt = now;
return;
}
if (now - lastSeenAt < windowMs) return;
// Stuck. Force a replan by setting the exact same goal object again.
// mineflayer-pathfinder emits goal_updated on every setGoal() call
// and rebuilds the graph, but goto() only rejects as GoalChanged when
// the new goal object is different. Clearing to null first breaks the
// caller, as observed live with gather.logs/explore.far.
if (replansThisGoal >= maxReplans) {
warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`);
lastSeenAt = now; // throttle further warnings within this window
return;
}
replansThisGoal++;
info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`);
try {
const goalCopy = goal;
pf.setGoal(goalCopy);
lastSeenAt = now; // reset window
} catch (e) {
warn("pathfinder", `replan failed: ${e.message}`);
}
}
timer = setInterval(tick, intervalMs);
return {
stop() {
stopped = true;
if (timer) clearInterval(timer);
timer = null;
},
};
}
// Pure helpers for tests.
export const _internal = { hpos, hdist, collectBlockOwnsPathfinder };