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:
@@ -44,6 +44,8 @@ import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
|
||||
import { classifyIntent, INTENTS } from "./social/intent.js";
|
||||
import { generateReply } from "./social/reply.js";
|
||||
import { createChatMemory } from "./social/memory.js";
|
||||
import { createStuckIncidentDetector } from "./stuck-incident.js";
|
||||
import { createSkillMetrics } from "./skill-metrics.js";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
@@ -68,6 +70,8 @@ let lastEscalationAt = 0;
|
||||
// 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();
|
||||
const stuckIncident = createStuckIncidentDetector();
|
||||
const skillMetrics = createSkillMetrics();
|
||||
let lastResult = null; // { label, ok, code, detail, ts }
|
||||
let lastFailureAt = 0;
|
||||
let lastPlanReadAt = 0;
|
||||
@@ -177,6 +181,7 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
detail: res?.detail,
|
||||
ts: Date.now(),
|
||||
};
|
||||
skillMetrics.record(label, ok);
|
||||
if (!ok) {
|
||||
lastFailureAt = lastResult.ts;
|
||||
recordFailure(label, res?.detail);
|
||||
@@ -202,6 +207,7 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
detail: String(e?.message ?? e),
|
||||
ts: Date.now(),
|
||||
};
|
||||
skillMetrics.record(label, false);
|
||||
lastFailureAt = lastResult.ts;
|
||||
recordFailure(label, String(e?.message ?? e));
|
||||
})
|
||||
@@ -604,11 +610,38 @@ function tick() {
|
||||
lastSnapshot.lastResult = lastResult;
|
||||
lastSnapshot.noProgressReason = noProgressReason;
|
||||
lastSnapshot.failuresByCode = failuresByCode();
|
||||
lastSnapshot.skillMetrics = skillMetrics.snapshot();
|
||||
lastSnapshot.lastEscalation = lastEscalationAt
|
||||
? { ts: lastEscalationAt, ageMs: now - lastEscalationAt }
|
||||
: null;
|
||||
lastSnapshot.reflexPaused = reflexPaused;
|
||||
|
||||
// Stuck-incident detector: file a structured proposal when the same
|
||||
// no-progress reason persists past the threshold. Kept separate from
|
||||
// the existing bug-class failure tracker — they target different
|
||||
// classes of breakage. The detector enforces its own cooldown so we
|
||||
// don't spam the proposals dir.
|
||||
const stuck = stuckIncident.check({
|
||||
snapshot: lastSnapshot,
|
||||
lastResult,
|
||||
metrics: lastSnapshot.skillMetrics,
|
||||
now,
|
||||
});
|
||||
if (stuck?.fire) {
|
||||
try {
|
||||
const { filename } = writeProposal({
|
||||
kind: stuck.kind,
|
||||
summary: stuck.summary,
|
||||
body: stuck.body,
|
||||
editScope: stuck.editScope,
|
||||
});
|
||||
warn("stuck", `filed ${filename}: ${stuck.summary}`);
|
||||
appendDiary(`stuck-proposal filed: ${filename} (${stuck.summary})`);
|
||||
} catch (e) {
|
||||
warn("stuck", `writeProposal failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
} else {
|
||||
lastSnapshot = { connected: false };
|
||||
|
||||
Reference in New Issue
Block a user