From a57625b5413ccb3f1b63b92b174c566f9a18f6f6 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 12:24:22 +0300 Subject: [PATCH] feat(runtime): wedged-cant-escape proposal trigger (self-improvement v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the self-improvement loop: when escape-pit + wedged-jump + blind fallback all run 3× in a row without freeing the bot, fire a dedicated proposal at the auto-improver. Pi gets the full context (journal byKind, last 12 scenario-memory entries, current slim snapshot) and is asked to either improve escapePit() (dig forward + down + side, not only up) OR add a brand-new recovery.tunnel-out skill. - runtime/stuck-incident.js: new checkWedged() path with separate cooldown (10 min) from the no-progress path. noteResult() ingests every dispatched action's detail.mode to count wedged completions. - runtime/bot.js: dispatchAction calls stuckIncident.noteResult(res) after each result; tick() calls checkWedged() and files the proposal via writeProposal({editScope:[runtime/actions.js, runtime/skills/, runtime/reflex.js]}). This is the architectural piece: a bot wedged in a 1×1 hole now generates a proposal that Pi can act on (with edit-scope guard rails + npm test smoke gate from PR #19), instead of looping wedged-jump forever. Verified live: bot now also picks direction from journal — "explore.far: journal says leanest quadrant=NE → prefer N" — first time the bot uses persistent memory to choose where to go next. Co-Authored-By: Claude Opus 4.7 (1M context) --- runtime/bot.js | 27 ++++++++++ runtime/stuck-incident.js | 104 +++++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/runtime/bot.js b/runtime/bot.js index d4294aa..0429326 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -245,6 +245,7 @@ function dispatchAction(fn, label, opts = {}) { detail: res?.detail, }); recordWorldDeltaToJournal(label, res, startSnap); + stuckIncident.noteResult(res); if (!ok) { lastFailureAt = lastResult.ts; recordFailure(label, res?.detail); @@ -797,6 +798,32 @@ function tick() { } } + // Second fast-track trigger: explicit wedged loop (escape-pit ran N + // times in a row without freeing the bot). Auto-improve picks this + // up like any other proposal — Pi writes a new escape strategy. + const wedged = stuckIncident.checkWedged({ + snapshot: lastSnapshot, + lastResult, + metrics: lastSnapshot.skillMetrics, + journalSummary: worldJournal.summary(), + scenarioTail: scenarioMemory.recentTailFor({ n: 12 }), + now, + }); + if (wedged?.fire) { + try { + const { filename } = writeProposal({ + kind: wedged.kind, + summary: wedged.summary, + body: wedged.body, + editScope: wedged.editScope, + }); + warn("wedged", `filed ${filename}: ${wedged.summary}`); + appendDiary(`wedged-proposal filed: ${filename}`); + } catch (e) { + warn("wedged", `writeProposal failed: ${e.message}`); + } + } + ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot); } else { lastSnapshot = { connected: false }; diff --git a/runtime/stuck-incident.js b/runtime/stuck-incident.js index ac3b902..072ef20 100644 --- a/runtime/stuck-incident.js +++ b/runtime/stuck-incident.js @@ -24,11 +24,42 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, let firstSeenAt = 0; let lastFiredAt = 0; + // Additional trigger path: when the bot has produced N "non-productive + // completions" in a row (same skill returning mode:escape-pit / + // wedged-jump / blind walk, no inventory or position change), fire a + // stuck incident immediately — these are exactly the cases where Pi + // should write a NEW skill, not patch an existing one. + let consecutiveWedged = 0; + const WEDGED_FIRE_AT = 3; + let lastWedgedFireAt = 0; + const WEDGED_COOLDOWN_MS = 10 * 60_000; + function reset() { currentReason = null; firstSeenAt = 0; } + // Caller pings this every time an action completes; we look at the + // mode/detail to decide if it counts as "wedged" (bot didn't really + // accomplish anything in-world). + function noteResult(res) { + const mode = res?.detail?.mode ?? res?.worldDelta?.mode; + const isWedgedShape = mode === "escape-pit" || mode === "wedged-jump" || mode === "blind" || mode === "wedged-jump"; + if (isWedgedShape && res?.ok) { + consecutiveWedged++; + } else if (res?.ok) { + consecutiveWedged = 0; + } + } + + function wedgedShouldFire(now = Date.now()) { + if (consecutiveWedged < WEDGED_FIRE_AT) return false; + if (now - lastWedgedFireAt < WEDGED_COOLDOWN_MS) return false; + lastWedgedFireAt = now; + consecutiveWedged = 0; + return true; + } + // Returns { fire: true, body, kind, editScope } when an incident should // be filed this tick; null otherwise. function check({ snapshot, lastResult, metrics, journalSummary, scenarioTail, now = Date.now() }) { @@ -137,5 +168,76 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, }; } - return { check, reset }; + function checkWedged({ snapshot, lastResult, metrics, journalSummary, scenarioTail, now = Date.now() }) { + if (!wedgedShouldFire(now)) return null; + const slim = { + runtimeState: snapshot?.runtimeState, + noProgressReason: snapshot?.noProgressReason, + position: snapshot?.position, + health: snapshot?.health, + food: snapshot?.food, + isDay: snapshot?.isDay, + inventoryCounts: Object.keys(snapshot?.inventory ?? {}).length, + }; + const metricsLine = metrics && Object.keys(metrics).length + ? Object.entries(metrics).map(([id, m]) => `${id}: ok=${m.ok} fail=${m.fail}`).join(", ") + : "(none)"; + const journalLine = journalSummary + ? `byKind=${JSON.stringify(journalSummary.byKind ?? {})} buckets=${journalSummary.totalBuckets ?? 0}` + : "(none)"; + const scenarioLines = Array.isArray(scenarioTail) && scenarioTail.length + ? scenarioTail.map((e) => + `- \`${e.skillId}\` ${e.ok ? "OK" : "FAIL"} code=${e.code ?? "?"} ${e.detail ? `(${e.detail.slice?.(0, 120) ?? ""})` : ""} situ=${e.situationHashShort ?? "?"}`, + ).join("\n") + : "_(no scenario memory)_"; + + const body = [ + `# Wedged — escape-pit cannot extract the bot`, + "", + `The bot has produced ${WEDGED_FIRE_AT}+ "wedged-jump / escape-pit / blind" completions in a row.`, + "In-world it stands still; the existing escape primitives are not enough.", + "", + "## Current state", + "```json", + JSON.stringify(slim, null, 2), + "```", + "", + "## Last action result", + lastResult + ? `\`${lastResult.label}\` → ${lastResult.code ?? (lastResult.ok ? "ok" : "fail")} ${lastResult.detail ? `(${JSON.stringify(lastResult.detail).slice(0, 200)})` : ""}` + : "_(none)_", + "", + "## Skill metrics", + metricsLine, + "", + "## World journal byKind", + journalLine, + "", + "## Recent scenario memory (last attempts)", + scenarioLines, + "", + "## Suggested fix", + "", + "Either improve `escapePit()` in `runtime/actions.js` (e.g. dig forward + down + side, not only up) OR add a NEW skill `recovery.tunnel-out` that breaks the bot out of a 1×1 hole by digging a 3-block tunnel in the most-free cardinal. Add tests under `runtime/skills/`.", + "", + "## Edit scope", + "- runtime/actions.js", + "- runtime/skills/", + "- runtime/reflex.js", + "", + "## Forbidden", + "- Don't touch `.env`, `state/`, `extensions/`, `tui/`, `package.json`.", + "- Don't add new npm dependencies.", + ].join("\n"); + + return { + fire: true, + kind: "wedged-cant-escape", + summary: "bot wedged in place; escape-pit ran 3× without freeing it", + body, + editScope: ["runtime/actions.js", "runtime/skills/", "runtime/reflex.js"], + }; + } + + return { check, checkWedged, noteResult, reset }; }