From 2308596164fe4ef6a5056a8c3eeeea247d67e1a2 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 22:10:41 +0300 Subject: [PATCH] feat(runtime): observability + no-progress detector (Phase 1) Phase 1 of plans/autonomous-survival-bot-prd.md. The bot must always be able to answer "what am I doing and why am I not doing more?" without parsing the log stream. New modules: - runtime/state.js: pure FSM classifier emitting emergency / working / recovering / planning / social / idle from snapshot + reflex context. - runtime/no-progress.js: sliding-window detector that watches position and inventory; when both are unchanged for 60 s+, emits one stable reason code from REASONS (waiting_for_day, night_hostile_nearby, no_food_source, inventory_full, no_reachable_target, planner_empty, awaiting_action_cooldown). - runtime/viewer.js: optional prismarine-viewer launcher behind VIEWER_PORT. Lazy import so the dep is not required by default. Wiring: - runtime/bot.js: tick() now computes runtimeState + noProgressReason every tick and stamps them on the snapshot along with activeSkill, currentMilestone (read from plan.md, cached 30 s), lastResult, failuresByCode and lastEscalation. - runtime/bot.js: dispatchAction records lastResult and lastFailureAt for the recovering-state classifier. - runtime/planner.js: exports isPlannerBusy(), readNextMilestone() and planExists() so the runtime can show planning state + current milestone without spawning extra Pi calls. - runtime/config.js: adds VIEWER_PORT support. TUI: - tui/tui.tsx: StatusBar gains a state badge, current-skill row, milestone row, no-progress reason warning, last-result line with ok/fail color, failures-by-class summary and last-escalation age. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 6 ++ docs/runtime.md | 25 +++++++ runtime/bot.js | 95 ++++++++++++++++++++++++++- runtime/config.js | 5 ++ runtime/no-progress.js | 146 +++++++++++++++++++++++++++++++++++++++++ runtime/planner.js | 27 ++++++++ runtime/state.js | 57 ++++++++++++++++ runtime/viewer.js | 30 +++++++++ tui/tui.tsx | 60 +++++++++++++++-- 9 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 runtime/no-progress.js create mode 100644 runtime/state.js create mode 100644 runtime/viewer.js diff --git a/.env.example b/.env.example index aa73d4d..037f70a 100644 --- a/.env.example +++ b/.env.example @@ -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:). 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 diff --git a/docs/runtime.md b/docs/runtime.md index d4475ed..be06b7d 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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//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=` 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 diff --git a/runtime/bot.js b/runtime/bot.js index 9f68a39..4497861 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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 }; diff --git a/runtime/config.js b/runtime/config.js index 8e60e1f..e362f4d 100644 --- a/runtime/config.js +++ b/runtime/config.js @@ -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}`; diff --git a/runtime/no-progress.js b/runtime/no-progress.js new file mode 100644 index 0000000..89d991c --- /dev/null +++ b/runtime/no-progress.js @@ -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 }; +} diff --git a/runtime/planner.js b/runtime/planner.js index b3e6d76..2b28355 100644 --- a/runtime/planner.js +++ b/runtime/planner.js @@ -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; +} diff --git a/runtime/state.js b/runtime/state.js new file mode 100644 index 0000000..5977e4e --- /dev/null +++ b/runtime/state.js @@ -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; +} diff --git a/runtime/viewer.js b/runtime/viewer.js new file mode 100644 index 0000000..3db97ca --- /dev/null +++ b/runtime/viewer.js @@ -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}`); + } +} diff --git a/tui/tui.tsx b/tui/tui.tsx index 46ba062..d78bdd2 100644 --- a/tui/tui.tsx +++ b/tui/tui.tsx @@ -100,8 +100,26 @@ function reducer(state: State, action: Action): State { } } +const STATE_COLOR: Record = { + 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 = snapshot.failuresByCode ?? {}; + const failuresStr = Object.entries(failuresByCode) + .map(([k, v]) => `${k}:${v}`) + .join(" "); return ( @@ -112,6 +130,10 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p {connectedToBot ? "IPC ok" : "IPC down"} {" "} {paused ? ⏸ reflex paused : ▶ reflex live} + {" "} + + state={stateName} + user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "} @@ -125,17 +147,43 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p {snapshot.busy ? ( - ▸ busy: {snapshot.busy.label} - ) : snapshot.lastReflex ? ( + ▸ skill: {snapshot.busy.label} + ) : snapshot.activeSkill ? ( - 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)})` : ""} ) : ( - no reflex action yet + no skill yet )} + + milestone: + {milestone ?? (none — planner_empty?)} + + {reason ? ( + + + ▲ no-progress: + {" "} + {reason} + + ) : null} + {lastResult ? ( + + last result: + + {lastResult.label} → {lastResult.code ?? (lastResult.ok ? "ok" : "fail")} + + {lastResult.ts ? ` ${formatAge(lastResult.ts)}` : ""} + + ) : null} + {failuresStr ? ( + failures by class: {failuresStr} + ) : null} + {snapshot.lastEscalation?.ts ? ( + last Pi escalation: {formatAge(snapshot.lastEscalation.ts)} + ) : null} ); }