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>
This commit was merged in pull request #17.
This commit is contained in:
Yuriy Mayatnikov
2026-05-25 22:35:01 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent fc62160524
commit c7eab06f22
7 changed files with 325 additions and 8 deletions
+86
View File
@@ -0,0 +1,86 @@
// Tests for runtime/stuck-incident.js and runtime/skill-metrics.js.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createStuckIncidentDetector } from "./stuck-incident.js";
import { createSkillMetrics } from "./skill-metrics.js";
function snap(reason, extras = {}) {
return {
connected: true,
noProgressReason: reason,
runtimeState: "idle",
inventory: {},
curriculum: extras.curriculum ?? null,
position: { x: 0, y: 64, z: 0 },
health: 20,
food: 18,
isDay: true,
...extras,
};
}
test("returns null while reason is absent", () => {
const d = createStuckIncidentDetector({ thresholdMs: 100, cooldownMs: 1_000 });
assert.equal(d.check({ snapshot: snap(null), now: 0 }), null);
assert.equal(d.check({ snapshot: snap(null), now: 50 }), null);
});
test("does not fire before threshold elapses", () => {
const d = createStuckIncidentDetector({ thresholdMs: 100, cooldownMs: 1_000 });
assert.equal(d.check({ snapshot: snap("planner_empty"), now: 0 }), null);
assert.equal(d.check({ snapshot: snap("planner_empty"), now: 50 }), null);
assert.equal(d.check({ snapshot: snap("planner_empty"), now: 99 }), null);
});
test("fires after threshold and includes scope + body", () => {
const d = createStuckIncidentDetector({ thresholdMs: 100, cooldownMs: 1_000 });
d.check({ snapshot: snap("no_food_source"), now: 0 });
const out = d.check({
snapshot: snap("no_food_source", {
curriculum: { milestone: { title: "Secure food" }, plan: { skillId: "survive.eat" } },
}),
lastResult: { label: "survive.eat", ok: false, code: "no_food_source", detail: "no edible item" },
metrics: { "survive.eat": { ok: 0, fail: 3 } },
now: 200,
});
assert.ok(out);
assert.equal(out.fire, true);
assert.equal(out.kind, "stuck-no_food_source");
assert.match(out.summary, /no_food_source/);
assert.match(out.body, /Secure food/);
assert.match(out.body, /survive\.eat/);
assert.ok(out.editScope.some((p) => p.includes("survive-eat") || p.includes("survive.eat") || p === "runtime/skills/"));
});
test("changes in reason restart the timer (no premature fire)", () => {
const d = createStuckIncidentDetector({ thresholdMs: 100, cooldownMs: 1_000 });
d.check({ snapshot: snap("planner_empty"), now: 0 });
d.check({ snapshot: snap("planner_empty"), now: 80 });
// reason changes — restart
assert.equal(d.check({ snapshot: snap("no_food_source"), now: 100 }), null);
// only the NEW reason's clock counts now
assert.equal(d.check({ snapshot: snap("no_food_source"), now: 150 }), null);
});
test("cooldown prevents back-to-back firings", () => {
const d = createStuckIncidentDetector({ thresholdMs: 50, cooldownMs: 1_000 });
d.check({ snapshot: snap("planner_empty"), now: 0 });
const first = d.check({ snapshot: snap("planner_empty"), now: 100 });
assert.ok(first?.fire);
const second = d.check({ snapshot: snap("planner_empty"), now: 200 });
assert.equal(second, null);
});
test("skill metrics record ok/fail and expose snapshot", () => {
const m = createSkillMetrics();
m.record("gather.logs", true);
m.record("gather.logs", true);
m.record("gather.logs", false);
m.record("survive.eat", false);
const snap = m.snapshot();
assert.equal(snap["gather.logs"].ok, 2);
assert.equal(snap["gather.logs"].fail, 1);
assert.equal(snap["survive.eat"].fail, 1);
});