feat(runtime): observability + no-progress detector (Phase 1) #13

Merged
halofourteen merged 1 commits from feat/survival-pivot-phase-1 into main 2026-05-25 22:11:13 +03:00
9 changed files with 442 additions and 9 deletions
+6
View File
@@ -59,3 +59,9 @@ OPERATOR_USERNAMES=
# --- Optional: Telegram bridge (future skill, not wired yet) ------------------
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_OPERATOR_CHAT_ID=
# --- Optional: prismarine-viewer for local visual debugging -------------------
# Set to a port to launch the in-browser viewer alongside the bot
# (http://localhost:<port>). Requires `npm i prismarine-viewer` — kept out of
# the default deps to keep the runtime light. Set to 0 or leave empty to skip.
VIEWER_PORT=0
+25
View File
@@ -150,6 +150,31 @@ should only suggest what to do *with the existing tools*. If a deeper
problem is happening, the failure-tracker (see Self-improvement) will
file a proposal instead.
## Observability (Phase 1 — survival-bot pivot)
Every STATUS snapshot now carries fields the TUI uses to answer
"what is the bot doing and why isn't it doing more?" without
parsing the log stream:
| Field | Meaning |
|-------|---------|
| `runtimeState` | finite-state classification: `emergency` / `working` / `recovering` / `planning` / `social` / `idle` (see `runtime/state.js`). |
| `activeSkill` | current dispatched action label, or the last one if idle. |
| `currentMilestone` | first uncompleted line from `state/<host>/plan.md` (cached 30 s). |
| `lastResult` | `{ label, ok, code, detail, ts }` of the most recent dispatched action. |
| `noProgressReason` | one of `waiting_for_day`, `night_hostile_nearby`, `no_food_source`, `inventory_full`, `no_reachable_target`, `planner_empty`, `awaiting_action_cooldown`, … emitted when position + inventory have not changed for ≥60 s (see `runtime/no-progress.js`). |
| `failuresByCode` | rolling counts of recent failures grouped by class (`bug` / `timeout` / `feature-gap` / `other`). |
| `lastEscalation` | `{ ts, ageMs }` of the most recent Pi auto-escalation. |
| `reflexPaused` | mirror of the local pause flag (so TUI shows the right state immediately). |
### Optional: prismarine-viewer
Set `VIEWER_PORT=<port>` in `.env` to launch
[`prismarine-viewer`](https://github.com/PrismarineJS/prismarine-viewer)
in-process. The package is **not** a default dep — install it explicitly
(`npm i prismarine-viewer`) before enabling. If missing, the runtime
logs a warning and continues.
## In-game chat (dialog-only)
As of the Phase 0 survival-bot pivot, MC chat does **not** drive bot
+92 -3
View File
@@ -34,7 +34,10 @@ import {
approveProposal,
} from "./state-store.js";
import { startAutoImprover } from "./auto-improve.js";
import { startPlanner } from "./planner.js";
import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./planner.js";
import { computeState, STATES } from "./state.js";
import { createNoProgressDetector } from "./no-progress.js";
import { maybeStartViewer } from "./viewer.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -55,6 +58,17 @@ let lastSnapshot = { connected: false };
let consecutiveNoops = 0;
let lastEscalationAt = 0;
// Observability state — surfaced in every STATUS snapshot so the TUI (and
// future Telegram/diary surfaces) can answer "what is the bot doing and why
// isn't it doing more?" without parsing the log stream.
const noProgress = createNoProgressDetector();
let lastResult = null; // { label, ok, code, detail, ts }
let lastFailureAt = 0;
let lastPlanReadAt = 0;
let cachedMilestone = null;
let cachedPlanExists = false;
const MILESTONE_CACHE_MS = 30_000;
// Reflex context — passed into reflex.js every tick. Mutable across ticks.
const reflexCtx = {
bot: null,
@@ -150,8 +164,19 @@ function dispatchAction(fn, label, opts = {}) {
`${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);
lastResult = {
label,
ok,
code: res?.code ?? (ok ? "done" : classifyFailure(res?.detail)),
detail: res?.detail,
ts: Date.now(),
};
if (!ok) {
lastFailureAt = lastResult.ts;
recordFailure(label, res?.detail);
} else {
clearRecentFailures(label);
}
if (opts.onComplete) {
try {
@@ -164,6 +189,14 @@ function dispatchAction(fn, label, opts = {}) {
.catch((e) => {
warn("dispatch", `${label} threw: ${e?.message ?? e}`);
writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) });
lastResult = {
label,
ok: false,
code: "threw",
detail: String(e?.message ?? e),
ts: Date.now(),
};
lastFailureAt = lastResult.ts;
recordFailure(label, String(e?.message ?? e));
})
.finally(() => {
@@ -391,6 +424,7 @@ function connect() {
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));
maybeStartViewer(bot).catch((e) => warn("viewer", `start threw: ${e?.message ?? e}`));
});
bot.on("messagestr", (text) => {
@@ -481,8 +515,29 @@ function maybeAutoEscalate() {
// ---- tick ------------------------------------------------------------------
function failuresByCode() {
const counts = {};
for (const f of reflexCtx.recentFailures) {
const k = f.kind || "other";
counts[k] = (counts[k] ?? 0) + 1;
}
return counts;
}
function refreshMilestoneCache(now) {
if (now - lastPlanReadAt < MILESTONE_CACHE_MS) return;
lastPlanReadAt = now;
try {
cachedMilestone = readNextMilestone();
cachedPlanExists = planExists();
} catch (e) {
warn("planner", `milestone read failed: ${e.message}`);
}
}
function tick() {
if (shuttingDown) return;
const now = Date.now();
if (bot && bot.entity) {
lastSnapshot = buildSnapshot(bot);
lastSnapshot.pendingProposals = listProposals().length;
@@ -502,6 +557,40 @@ function tick() {
consecutiveNoops = 0;
}
}
// Observability: compute runtime state + no-progress reason and
// stamp them on the snapshot so the TUI / future surfaces can show
// one concrete answer to "why is the bot idle?".
refreshMilestoneCache(now);
const plannerInFlight = isPlannerBusy();
const runtimeState = computeState({
snapshot: lastSnapshot,
ctx: reflexCtx,
plannerInFlight,
lastChatReplyAt,
lastFailureAt,
now,
});
const noProgressReason = noProgress.detect({
snapshot: lastSnapshot,
ctx: reflexCtx,
planExists: cachedPlanExists,
now,
});
lastSnapshot.runtimeState = runtimeState;
lastSnapshot.activeSkill = reflexCtx.busy
? reflexCtx.currentActionLabel
: reflexCtx.lastReflex?.label ?? null;
lastSnapshot.currentMilestone = cachedMilestone;
lastSnapshot.lastResult = lastResult;
lastSnapshot.noProgressReason = noProgressReason;
lastSnapshot.failuresByCode = failuresByCode();
lastSnapshot.lastEscalation = lastEscalationAt
? { ts: lastEscalationAt, ageMs: now - lastEscalationAt }
: null;
lastSnapshot.reflexPaused = reflexPaused;
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
} else {
lastSnapshot = { connected: false };
+5
View File
@@ -44,6 +44,11 @@ export const config = Object.freeze({
.filter(Boolean),
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
// Optional prismarine-viewer port for local visual debugging. 0/empty = off.
viewerPort: (() => {
const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10);
return Number.isFinite(v) && v > 0 ? v : 0;
})(),
});
export const serverKey = `${host}_${port}`;
+146
View File
@@ -0,0 +1,146 @@
// No-progress detector. Independent from exception-style failures: this
// watches whether the bot is producing any *observable* world change
// (movement or inventory change) over time, and, if not, emits a single
// stable reason code so TUI + diary + future Pi escalation can show one
// concrete answer to "why is the bot standing still?".
//
// Reason codes are deliberately stable strings so they can be diffed
// across versions, counted, and used by future skill triggers.
export const REASONS = Object.freeze({
WAITING_FOR_DAY: "waiting_for_day",
NIGHT_HOSTILE_NEARBY: "night_hostile_nearby",
NO_FOOD_SOURCE: "no_food_source",
NO_SAFE_PATH: "no_safe_path",
NO_KNOWN_BASE: "no_known_base",
INVENTORY_FULL: "inventory_full",
PLANNER_EMPTY: "planner_empty",
NO_REACHABLE_TARGET: "no_reachable_target",
AWAITING_ACTION_COOLDOWN: "awaiting_action_cooldown",
});
const STILL_THRESHOLD_MS = 60_000;
const POS_EPSILON = 1.5;
const INVENTORY_FULL_SLOTS = 32; // 36 main slots; treat >=32 distinct stacks as full-ish
// Conservative food list — items the bot can safely consume right now via
// the eat reflex / mineflayer auto-eat lineage. Kept short on purpose: if
// the bot is "hungry but no food", we want to detect that even when the
// inventory has dirt and sticks.
const FOOD_NAMES = new Set([
"bread",
"cooked_beef",
"cooked_chicken",
"cooked_porkchop",
"cooked_mutton",
"cooked_rabbit",
"cooked_salmon",
"cooked_cod",
"baked_potato",
"apple",
"golden_apple",
"carrot",
"beetroot",
"melon_slice",
"sweet_berries",
"glow_berries",
"mushroom_stew",
"rabbit_stew",
"beetroot_soup",
"suspicious_stew",
"cooked_chicken",
"dried_kelp",
"pumpkin_pie",
]);
function hasFood(inventory) {
for (const name of Object.keys(inventory ?? {})) {
if (FOOD_NAMES.has(name)) return true;
}
return false;
}
function inventoryKey(inventory) {
const entries = Object.entries(inventory ?? {});
if (entries.length === 0) return "";
entries.sort(([a], [b]) => (a < b ? -1 : 1));
return entries.map(([k, v]) => `${k}:${v}`).join("|");
}
function distinctStacks(inventory) {
return Object.keys(inventory ?? {}).length;
}
function classify({ snapshot, ctx, planExists }) {
if (!snapshot.isDay) {
if (snapshot.closestHostile && snapshot.closestHostile.distance <= 16) {
return REASONS.NIGHT_HOSTILE_NEARBY;
}
return REASONS.WAITING_FOR_DAY;
}
const food = snapshot.food ?? 20;
if (food <= 6 && !hasFood(snapshot.inventory)) {
return REASONS.NO_FOOD_SOURCE;
}
if (distinctStacks(snapshot.inventory) >= INVENTORY_FULL_SLOTS) {
return REASONS.INVENTORY_FULL;
}
if (ctx?.noTreesUntil && Date.now() < ctx.noTreesUntil) {
return REASONS.NO_REACHABLE_TARGET;
}
if (!planExists) return REASONS.PLANNER_EMPTY;
return REASONS.AWAITING_ACTION_COOLDOWN;
}
export function createNoProgressDetector({ stillThresholdMs = STILL_THRESHOLD_MS } = {}) {
let stillSince = null;
let lastPos = null;
let lastInvKey = "";
function reset() {
stillSince = null;
}
function detect({ snapshot, ctx, planExists = true, now = Date.now() }) {
if (!snapshot?.connected) {
reset();
return null;
}
if (ctx?.busy) {
reset();
return null;
}
const pos = snapshot.position;
const ik = inventoryKey(snapshot.inventory);
const moved =
!lastPos || !pos
? lastPos !== pos
: Math.hypot(pos.x - lastPos.x, pos.z - lastPos.z) > POS_EPSILON ||
Math.abs(pos.y - lastPos.y) > POS_EPSILON;
const invChanged = ik !== lastInvKey;
if (moved || invChanged) {
lastPos = pos ? { ...pos } : null;
lastInvKey = ik;
stillSince = now;
return null;
}
if (stillSince == null) {
stillSince = now;
return null;
}
if (now - stillSince < stillThresholdMs) return null;
return classify({ snapshot, ctx, planExists });
}
return { detect, reset };
}
+27
View File
@@ -170,3 +170,30 @@ export function stopPlanner() {
export function readCurrentPlan() {
return readOr(PLAN_PATH);
}
export function isPlannerBusy() {
return planInFlight;
}
// Returns the first uncompleted milestone in plan.md (a line that starts
// with a list marker but is NOT preceded by "✓ "), or null if the plan is
// empty or every milestone is done. Plain text, leading list markers
// stripped, capped at 200 chars to keep it TUI-friendly.
export function readNextMilestone() {
const text = readOr(PLAN_PATH);
if (!text.trim()) return null;
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(?:[-*]|\d+[.)])\s+(.*)$/);
if (!m) continue;
const content = m[1];
if (content.startsWith("✓ ") || content.startsWith("[x] ") || content.startsWith("[X] ")) continue;
return content.slice(0, 200);
}
return null;
}
export function planExists() {
return readOr(PLAN_PATH).trim().length > 0;
}
+57
View File
@@ -0,0 +1,57 @@
// Finite-state classifier for the runtime. Each tick the bot is in exactly
// one of:
//
// emergency — survival is at risk (low HP, hostile in melee, drowning…)
// working — an async action is in flight (reflexCtx.busy === true)
// recovering — last action failed recently and we are in its cooldown
// planning — the LLM planner is computing a fresh plan.md
// social — we just produced a chat reply (cools down social activity)
// idle — none of the above; the bot is up but nothing is happening
//
// Pure function: takes the current observed inputs and returns a string.
// No side effects; the caller decides what to do with the classification.
export const STATES = Object.freeze({
EMERGENCY: "emergency",
WORKING: "working",
RECOVERING: "recovering",
PLANNING: "planning",
SOCIAL: "social",
IDLE: "idle",
});
const EMERGENCY_HP = 8;
const EMERGENCY_HOSTILE_DISTANCE = 4;
const RECOVERING_WINDOW_MS = 30_000;
const SOCIAL_WINDOW_MS = 5_000;
export function computeState({
snapshot,
ctx,
plannerInFlight = false,
lastChatReplyAt = 0,
lastFailureAt = 0,
now = Date.now(),
}) {
if (!snapshot?.connected) return STATES.IDLE;
const hp = snapshot.health ?? 20;
if (hp <= EMERGENCY_HP) return STATES.EMERGENCY;
if (snapshot.closestHostile && snapshot.closestHostile.distance <= EMERGENCY_HOSTILE_DISTANCE) {
return STATES.EMERGENCY;
}
if (ctx?.busy) return STATES.WORKING;
if (lastFailureAt && now - lastFailureAt < RECOVERING_WINDOW_MS) {
return STATES.RECOVERING;
}
if (plannerInFlight) return STATES.PLANNING;
if (lastChatReplyAt && now - lastChatReplyAt < SOCIAL_WINDOW_MS) {
return STATES.SOCIAL;
}
return STATES.IDLE;
}
+30
View File
@@ -0,0 +1,30 @@
// Optional prismarine-viewer launch — local visual debugging surface.
// Activated by setting VIEWER_PORT in .env (e.g. 3007). The dependency is
// not required at runtime: if `prismarine-viewer` is not installed, this
// module logs once and returns, so production deploys aren't forced to
// carry the extra dep.
import { info, warn } from "./log.js";
import { config } from "./config.js";
let started = false;
export async function maybeStartViewer(bot) {
if (started) return;
const port = config.viewerPort;
if (!port) return;
let mineflayerViewer;
try {
({ mineflayer: mineflayerViewer } = await import("prismarine-viewer"));
} catch (e) {
warn("viewer", `VIEWER_PORT=${port} requested but prismarine-viewer is not installed (npm i prismarine-viewer)`);
return;
}
try {
mineflayerViewer(bot, { port, firstPerson: false });
started = true;
info("viewer", `prismarine-viewer listening on http://localhost:${port}`);
} catch (e) {
warn("viewer", `failed to start: ${e?.message ?? e}`);
}
}
+54 -6
View File
@@ -100,8 +100,26 @@ function reducer(state: State, action: Action): State {
}
}
const STATE_COLOR: Record<string, string> = {
emergency: "red",
working: "cyan",
recovering: "yellow",
planning: "magenta",
social: "blue",
idle: "gray",
};
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
const tone = snapshot.connected ? "green" : "red";
const stateName: string = snapshot.runtimeState ?? "?";
const stateColor = STATE_COLOR[stateName] ?? "white";
const reason: string | null = snapshot.noProgressReason ?? null;
const lastResult: any = snapshot.lastResult ?? null;
const milestone: string | null = snapshot.currentMilestone ?? null;
const failuresByCode: Record<string, number> = snapshot.failuresByCode ?? {};
const failuresStr = Object.entries(failuresByCode)
.map(([k, v]) => `${k}:${v}`)
.join(" ");
return (
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
<Text>
@@ -112,6 +130,10 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
<Text color={connectedToBot ? "green" : "red"}>{connectedToBot ? "IPC ok" : "IPC down"}</Text>
{" "}
{paused ? <Text color="yellow"> reflex paused</Text> : <Text color="green"> reflex live</Text>}
{" "}
<Text color={stateColor} bold>
state={stateName}
</Text>
</Text>
<Text>
user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "}
@@ -125,17 +147,43 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
</Text>
<Text>
{snapshot.busy ? (
<Text color="cyan"> busy: {snapshot.busy.label}</Text>
) : snapshot.lastReflex ? (
<Text color="cyan"> skill: {snapshot.busy.label}</Text>
) : snapshot.activeSkill ? (
<Text dimColor>
last reflex: {snapshot.lastReflex.name}
{snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "}
{snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""}
last skill: {snapshot.activeSkill}
{snapshot.lastReflex?.ts ? ` (${formatAge(snapshot.lastReflex.ts)})` : ""}
</Text>
) : (
<Text dimColor>no reflex action yet</Text>
<Text dimColor>no skill yet</Text>
)}
</Text>
<Text>
<Text dimColor>milestone: </Text>
<Text>{milestone ?? <Text dimColor>(none planner_empty?)</Text>}</Text>
</Text>
{reason ? (
<Text>
<Text color="yellow" bold>
no-progress:
</Text>{" "}
<Text color="yellow">{reason}</Text>
</Text>
) : null}
{lastResult ? (
<Text>
<Text dimColor>last result: </Text>
<Text color={lastResult.ok ? "green" : "red"}>
{lastResult.label} {lastResult.code ?? (lastResult.ok ? "ok" : "fail")}
</Text>
<Text dimColor>{lastResult.ts ? ` ${formatAge(lastResult.ts)}` : ""}</Text>
</Text>
) : null}
{failuresStr ? (
<Text dimColor>failures by class: {failuresStr}</Text>
) : null}
{snapshot.lastEscalation?.ts ? (
<Text dimColor>last Pi escalation: {formatAge(snapshot.lastEscalation.ts)}</Text>
) : null}
</Box>
);
}