Files
c7eab06f22 feat(runtime): stuck-incident detector + skill metrics + edit scope (Phase 6) (#17)
Phase 6 of plans/autonomous-survival-bot-prd.md. Expand the
self-improvement loop so the bot can spot and report no-progress
stagnation, not just exception-class failures.

New:
- runtime/stuck-incident.js: detector fires a structured proposal when
  the same noProgressReason persists past 5 min (cooldown 30 min).
  Body includes runtimeState, milestone, suggested skill, slim
  snapshot, last action result, per-skill success/failure metrics
  and a forbidden-paths list. Pure module — caller (bot.js) writes
  the proposal.
- runtime/skill-metrics.js: in-memory per-skill ok/fail counters
  surfaced on snapshot.skillMetrics for the TUI and the incident
  body.
- runtime/stuck-incident.test.js: 6 tests covering null reason,
  threshold gating, cooldown, reason change resetting the timer,
  body composition and metrics snapshot.

Wiring:
- runtime/state-store.js: writeProposal accepts {editScope: string[]}
  and persists it in the frontmatter; readProposalEditScope() reads
  it back so future auto-patch.js can refuse cherry-picks that touch
  other areas.
- runtime/bot.js: tick() invokes the stuck detector each tick,
  records skill ok/fail via skillMetrics, stamps snapshot.skillMetrics
  and writes the stuck proposal via writeProposal({editScope}).
  dispatchAction now records into skillMetrics for both the
  resolved-result and the exception path.

npm test now 46/46.

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:35:01 +03:00

214 lines
6.5 KiB
JavaScript

// 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);
}
// Tail N most-recent diary lines from today's file. Returns the most-recent
// line (or null if the day's diary is empty / missing). Cheap enough to
// call from the chat reply path.
export function readDiaryTail(n = 1) {
try {
const raw = fs.readFileSync(diaryPath(), "utf8");
const lines = raw.split("\n").filter((l) => l.trim());
if (lines.length === 0) return null;
return lines.slice(-n).join("\n");
} catch (e) {
if (e.code === "ENOENT") return null;
return null;
}
}
// ---- escalations -----------------------------------------------------------
//
// Per-server JSON-lines log of moments the bot chose NOT to act because the
// request looked unsafe or out of scope. Read by future operator UIs (TUI
// surfaces the count) and by the chat handler when Phase 5 social classifies
// an inbound message as UNSAFE_REQUEST.
const ESCALATIONS_PATH = path.join(stateDir, "escalations.jsonl");
export function writeEscalation({ from, request, whyUnsure, wouldHave }) {
const line = JSON.stringify({
ts: new Date().toISOString(),
from,
request,
why_unsure: whyUnsure,
would_have: wouldHave,
}) + "\n";
fs.appendFileSync(ESCALATIONS_PATH, line);
}
export function listEscalations({ limit = 50 } = {}) {
try {
const raw = fs.readFileSync(ESCALATIONS_PATH, "utf8");
const lines = raw.split("\n").filter((l) => l.trim());
const slice = lines.slice(-limit);
return slice
.map((l) => {
try { return JSON.parse(l); } catch { return null; }
})
.filter(Boolean);
} catch (e) {
if (e.code === "ENOENT") return [];
return [];
}
}
// ---- proposals -------------------------------------------------------------
function slugify(s) {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
// editScope (optional): array of repo-relative path prefixes the
// auto-patcher is allowed to modify. Read back from the frontmatter
// so scripts/auto-patch.js can refuse cherry-picks that touch other
// areas. Falls back to the historical default (`runtime/`) when
// absent for backwards compatibility with older proposals.
export function writeProposal({ kind, summary, body, editScope }) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const filename = `${stamp}-${slugify(kind)}.md`;
const filePath = path.join(PROPOSALS_DIR, filename);
const frontmatter = [
"---",
`kind: ${kind}`,
`ts: ${new Date().toISOString()}`,
`summary: ${JSON.stringify(summary)}`,
"approved: false",
];
if (Array.isArray(editScope) && editScope.length) {
frontmatter.push(`editScope: ${JSON.stringify(editScope)}`);
}
frontmatter.push("---", "");
const content = frontmatter.concat([body, ""]).join("\n");
fs.writeFileSync(filePath, content);
return { filePath, filename };
}
// Best-effort parse of editScope from a proposal markdown body's
// frontmatter. Returns null when not specified.
export function readProposalEditScope(filename, { approved = false } = {}) {
const dir = approved ? PROPOSALS_APPROVED_DIR : PROPOSALS_DIR;
const filePath = path.join(dir, filename);
let raw;
try { raw = fs.readFileSync(filePath, "utf8"); } catch { return null; }
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) return null;
const scopeLine = m[1].split("\n").find((l) => l.startsWith("editScope:"));
if (!scopeLine) return null;
try {
return JSON.parse(scopeLine.slice("editScope:".length).trim());
} catch {
return null;
}
}
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,
});