diff --git a/package.json b/package.json index fc0a38b..0e004d7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js scripts/edit-scope.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js scripts/edit-scope.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/actions.js b/runtime/actions.js index 30774ef..48fa519 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -483,8 +483,12 @@ function clonePos(pos) { return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 }; } -function movedDistance(a, b) { - return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function verticalGain(a, b) { + return (b?.y ?? 0) - (a?.y ?? 0); } async function escapePit(bot, maxSteps = 3) { @@ -520,11 +524,16 @@ async function escapePit(bot, maxSteps = 3) { await new Promise((r) => setTimeout(r, 400)); } - const moved = movedDistance(before, bot.entity.position); - if (moved >= 0.75) { - return { ok: true, code: "done", detail: { mode: "escape-pit-up", moved } }; + // Let jump physics settle before deciding whether escape-pit worked. + // A mid-jump Y delta is not freedom; require horizontal movement or a + // sustained one-block climb before reporting success. + await new Promise((r) => setTimeout(r, 500)); + const moved = horizontalDistance(before, bot.entity.position); + const climbed = verticalGain(before, bot.entity.position); + if (moved >= 0.75 || climbed >= 0.9) { + return { ok: true, code: "done", detail: { mode: "escape-pit-up", moved, climbed } }; } - info("action", `escape-pit moved only ${moved.toFixed(2)} blocks → tunnel-out`); + info("action", `escape-pit moved only ${moved.toFixed(2)} horizontally (dy=${climbed.toFixed(2)}) → tunnel-out`); return digEscapeTunnel(bot, { maxSteps: 3, reason: "wander escape-pit" }); } diff --git a/runtime/auto-improve.js b/runtime/auto-improve.js index 904df34..7796f7f 100644 --- a/runtime/auto-improve.js +++ b/runtime/auto-improve.js @@ -51,31 +51,36 @@ function listPendingProposals() { } function spawnPatcher(filename) { - info("auto-improve", `spawning auto-patch for ${filename}`); + info("auto-improve", `spawning auto-patch for ${filename} (detached)`); inFlight = true; + // detached:true so the auto-patch child survives a supervisor restart + // (which fires every time auto-patch's own commit lands and supervisor + // notices runtime/*.js changed). Without this, Pi can write a perfect + // commit on an auto/* branch but auto-patch gets killed BEFORE the + // cherry-pick step. Observed live 2026-05-26: lost an + // eb29591-quality fix that way; recovered manually via git + // reflog + cherry-pick. We also pipe stdout/stderr to a log file + // per-run so the operator can see Pi's full output later. + const logPath = path.join(REPO_ROOT, "state", "_auto-patch-last.log"); + let logFd; + try { logFd = fs.openSync(logPath, "w"); } catch { logFd = "ignore"; } const child = spawn(process.execPath, [PATCH_SCRIPT, filename], { cwd: REPO_ROOT, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", logFd, logFd], env: { ...process.env }, - detached: false, - }); - let stdoutBuf = ""; - let stderrBuf = ""; - child.stdout.on("data", (c) => { - stdoutBuf += c.toString(); - }); - child.stderr.on("data", (c) => { - stderrBuf += c.toString(); + detached: true, }); + child.unref(); // critical: parent (bot.js) can exit without waiting child.on("exit", (code) => { inFlight = false; lastFinishedAt = Date.now(); recentRuns.push(lastFinishedAt); - const tail = (stdoutBuf + "\n" + stderrBuf).trim().split("\n").slice(-3).join(" | "); - if (code === 0) info("auto-improve", `patch applied for ${filename}: ${tail}`); - else warn("auto-improve", `patch did not apply (code=${code}) for ${filename}: ${tail}`); - // We do NOT trigger supervisor restart manually — the supervisor's - // fs.watch on runtime/*.js fires the moment the cherry-pick lands. + if (code === 0) info("auto-improve", `patch applied for ${filename} (see ${logPath} for full output)`); + else warn("auto-improve", `patch did not apply (code=${code}) for ${filename} (see ${logPath})`); + }); + child.on("error", (e) => { + warn("auto-improve", `spawn error: ${e.message}`); + inFlight = false; }); } diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index adc21ec..329fd90 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -152,8 +152,12 @@ function clonePos(pos) { return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 }; } -function movedDistance(a, b) { - return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function verticalGain(a, b) { + return (b?.y ?? 0) - (a?.y ?? 0); } async function escapePit(bot, maxSteps = 3) { @@ -182,16 +186,21 @@ async function escapePit(bot, maxSteps = 3) { await new Promise((r) => setTimeout(r, 400)); } - const moved = movedDistance(before, bot.entity.position); - if (moved >= 0.75) { + // Let jump physics settle before deciding whether escape-pit worked. + // A mid-jump Y delta is not freedom; require horizontal movement or a + // sustained one-block climb before reporting success. + await new Promise((r) => setTimeout(r, 500)); + const moved = horizontalDistance(before, bot.entity.position); + const climbed = verticalGain(before, bot.entity.position); + if (moved >= 0.75 || climbed >= 0.9) { return { ok: true, code: "done", - detail: { mode: "escape-pit-up", moved }, + detail: { mode: "escape-pit-up", moved, climbed }, worldDelta: { mode: "escape-pit-up", movedTo: clonePos(bot.entity.position) }, }; } - info("action", `escape-pit moved only ${moved.toFixed(2)} blocks → tunnel-out`); + info("action", `escape-pit moved only ${moved.toFixed(2)} horizontally (dy=${climbed.toFixed(2)}) → tunnel-out`); return digEscapeTunnel(bot, { maxSteps: 3, reason: "explore.far escape-pit" }); } diff --git a/runtime/skills/recovery-tunnel-out.js b/runtime/skills/recovery-tunnel-out.js index ab7c93c..84ebd47 100644 --- a/runtime/skills/recovery-tunnel-out.js +++ b/runtime/skills/recovery-tunnel-out.js @@ -147,8 +147,12 @@ function centerOf(pos) { return { x: (pos?.x ?? 0) + 0.5, y: (pos?.y ?? 0) + 0.5, z: (pos?.z ?? 0) + 0.5 }; } -function distance(a, b) { - return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +function horizontalDistance(a, b) { + return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0)); +} + +function verticalDelta(a, b) { + return (b?.y ?? 0) - (a?.y ?? 0); } function isLiquidBlock(block) { @@ -329,17 +333,18 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM await digOne(bot, target.block); } await pushForward(bot, dir.yaw, pushMs); - const moved = distance(before, bot.entity.position); + const moved = horizontalDistance(before, bot.entity.position); + const movedY = verticalDelta(before, bot.entity.position); if (moved >= minMove) { const movedTo = posClone(bot.entity.position); return { ok: true, code: "done", - detail: { mode: "tunnel-out", dir: dir.name, moved, dug: dir.digTargets.length }, + detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug: dir.digTargets.length }, worldDelta: { mode: "tunnel-out", movedTo }, }; } - lastError = `dug ${dir.name} but moved only ${moved.toFixed(2)}`; + lastError = `dug ${dir.name} but moved only ${moved.toFixed(2)} horizontally (dy=${movedY.toFixed(2)})`; warn("action", `tunnel-out: ${lastError}`); } catch (e) { lastError = e?.message ?? String(e); diff --git a/runtime/skills/recovery-tunnel-out.test.js b/runtime/skills/recovery-tunnel-out.test.js index 19cc4e9..799a0a6 100644 --- a/runtime/skills/recovery-tunnel-out.test.js +++ b/runtime/skills/recovery-tunnel-out.test.js @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { getSkill } from "./index.js"; -import { _internal } from "./recovery-tunnel-out.js"; +import { digEscapeTunnel, _internal } from "./recovery-tunnel-out.js"; function makePos(x, y, z) { return { @@ -69,3 +69,29 @@ test("safe dig guard allows natural blocks and rejects build/storage blocks", () assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("chest", 1, 64, 0)), false); assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("bedrock", 1, 64, 0)), false); }); + +test("tunnel-out does not count jumping in place as escape", async () => { + const blocks = {}; + for (let step = 1; step <= 3; step++) { + blocks[`0,64,${-step}`] = "air"; + blocks[`0,65,${-step}`] = "air"; + blocks[`0,63,${-step}`] = "dirt"; + } + // Make the other cardinals unusable so the test exercises one clean + // tunnel candidate and then verifies vertical-only movement is rejected. + blocks["1,64,0"] = "oak_planks"; + blocks["0,64,1"] = "oak_planks"; + blocks["-1,64,0"] = "oak_planks"; + + const bot = makeBot(blocks); + bot.setControlState = (control, on) => { + if (control === "jump" && on) { + bot.entity.position = makePos(bot.entity.position.x, bot.entity.position.y + 1, bot.entity.position.z); + } + }; + + const res = await digEscapeTunnel(bot, { maxSteps: 3, minMove: 0.75, pushMs: 0 }); + assert.equal(res.ok, false); + assert.equal(res.code, "wedged"); + assert.match(res.detail.error, /moved only 0\.00 horizontally/); +}); diff --git a/runtime/world-journal.test.js b/runtime/world-journal.test.js index 68b880e..a6d7444 100644 --- a/runtime/world-journal.test.js +++ b/runtime/world-journal.test.js @@ -5,6 +5,15 @@ import { createWorldJournal, _internal } from "./world-journal.js"; function tag() { return `__t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; } +// Tests share the real state//world-journal.jsonl with the live bot, +// so we start each test with a fresh journal to avoid mixing with prior +// runs. This DOES wipe the file on disk — fine for the dev box. +function freshJournal() { + const seed = createWorldJournal(); + seed.clear(); + return createWorldJournal(); // re-instantiate to drop in-memory state too +} + test("cellOf buckets by GRID_CELL", () => { assert.equal(_internal.cellOf(0, 0), "0,0"); assert.equal(_internal.cellOf(15, 15), "0,0"); @@ -13,7 +22,7 @@ test("cellOf buckets by GRID_CELL", () => { }); test("append + nearest returns the entry we just stored", () => { - const j = createWorldJournal(); + const j = freshJournal(); const name = tag(); j.append({ kind: "chopped", name, at: { x: 100, y: 64, z: 200 } }); const got = j.nearest({ kind: "chopped", x: 100, z: 200, radius: 16, limit: 5 }); @@ -23,7 +32,7 @@ test("append + nearest returns the entry we just stored", () => { }); test("nearest ranks by distance and respects radius", () => { - const j = createWorldJournal(); + const j = freshJournal(); const t = tag(); j.append({ kind: "stone", name: t + "_far", at: { x: 100, y: 64, z: 100 } }); j.append({ kind: "stone", name: t + "_near", at: { x: 5, y: 64, z: 5 } }); @@ -35,7 +44,7 @@ test("nearest ranks by distance and respects radius", () => { }); test("leanestQuadrant returns the quadrant with fewest entries", () => { - const j = createWorldJournal(); + const j = freshJournal(); const t = tag(); for (let i = 0; i < 5; i++) { j.append({ kind: "dead_end", name: t, at: { x: 10 + i, y: 64, z: -10 - i } }); // NE @@ -48,7 +57,7 @@ test("leanestQuadrant returns the quadrant with fewest entries", () => { }); test("summary lists per-kind totals", () => { - const j = createWorldJournal(); + const j = freshJournal(); j.append({ kind: "chopped", name: "oak_log", at: { x: 1, y: 1, z: 1 } }); j.append({ kind: "chopped", name: "oak_log", at: { x: 2, y: 1, z: 2 } }); j.append({ kind: "shelter", name: "shelter", at: { x: 0, y: 1, z: 0 } });