fix(auto-improve): detach auto-patch + recovery-tunnel-out test in suite
Two bugs the live self-improvement run exposed: 1) Auto-patch was spawned with detached:false, so when supervisor restarted bot.js (file change after Pi's commit landed on the auto branch), the auto-patch child was killed mid-way — Pi's commit lived in the auto branch but never got cherry-picked. Recovered manually this round via reflog + cherry-pick. Now detached:true + child.unref() + a per-run log at state/_auto-patch-last.log so the operator can read Pi's full output later. 2) Pi's recovery-tunnel-out.test.js was created but not in npm test script; tests would have stayed unrun forever. Added. Also commits the Pi-authored skill (eb29591 cherry-picked): - runtime/skills/recovery-tunnel-out.js (+ test) - improvements to runtime/actions.js + runtime/skills/explore-far.js - wired into runtime/skills/index.js npm test 137/137. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"tui": "tsx tui/tui.tsx",
|
"tui": "tsx tui/tui.tsx",
|
||||||
"propose:apply": "node scripts/propose-apply.js",
|
"propose:apply": "node scripts/propose-apply.js",
|
||||||
"stop": "bash scripts/stop.sh",
|
"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": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
+15
-6
@@ -483,8 +483,12 @@ function clonePos(pos) {
|
|||||||
return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 };
|
return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function movedDistance(a, b) {
|
function horizontalDistance(a, b) {
|
||||||
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
|
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) {
|
async function escapePit(bot, maxSteps = 3) {
|
||||||
@@ -520,11 +524,16 @@ async function escapePit(bot, maxSteps = 3) {
|
|||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
const moved = movedDistance(before, bot.entity.position);
|
// Let jump physics settle before deciding whether escape-pit worked.
|
||||||
if (moved >= 0.75) {
|
// A mid-jump Y delta is not freedom; require horizontal movement or a
|
||||||
return { ok: true, code: "done", detail: { mode: "escape-pit-up", moved } };
|
// 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" });
|
return digEscapeTunnel(bot, { maxSteps: 3, reason: "wander escape-pit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-16
@@ -51,31 +51,36 @@ function listPendingProposals() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spawnPatcher(filename) {
|
function spawnPatcher(filename) {
|
||||||
info("auto-improve", `spawning auto-patch for ${filename}`);
|
info("auto-improve", `spawning auto-patch for ${filename} (detached)`);
|
||||||
inFlight = true;
|
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], {
|
const child = spawn(process.execPath, [PATCH_SCRIPT, filename], {
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", logFd, logFd],
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
detached: false,
|
detached: true,
|
||||||
});
|
|
||||||
let stdoutBuf = "";
|
|
||||||
let stderrBuf = "";
|
|
||||||
child.stdout.on("data", (c) => {
|
|
||||||
stdoutBuf += c.toString();
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (c) => {
|
|
||||||
stderrBuf += c.toString();
|
|
||||||
});
|
});
|
||||||
|
child.unref(); // critical: parent (bot.js) can exit without waiting
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
lastFinishedAt = Date.now();
|
lastFinishedAt = Date.now();
|
||||||
recentRuns.push(lastFinishedAt);
|
recentRuns.push(lastFinishedAt);
|
||||||
const tail = (stdoutBuf + "\n" + stderrBuf).trim().split("\n").slice(-3).join(" | ");
|
if (code === 0) info("auto-improve", `patch applied for ${filename} (see ${logPath} for full output)`);
|
||||||
if (code === 0) info("auto-improve", `patch applied for ${filename}: ${tail}`);
|
else warn("auto-improve", `patch did not apply (code=${code}) for ${filename} (see ${logPath})`);
|
||||||
else warn("auto-improve", `patch did not apply (code=${code}) for ${filename}: ${tail}`);
|
});
|
||||||
// We do NOT trigger supervisor restart manually — the supervisor's
|
child.on("error", (e) => {
|
||||||
// fs.watch on runtime/*.js fires the moment the cherry-pick lands.
|
warn("auto-improve", `spawn error: ${e.message}`);
|
||||||
|
inFlight = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,12 @@ function clonePos(pos) {
|
|||||||
return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 };
|
return { x: pos?.x ?? 0, y: pos?.y ?? 0, z: pos?.z ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function movedDistance(a, b) {
|
function horizontalDistance(a, b) {
|
||||||
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
|
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) {
|
async function escapePit(bot, maxSteps = 3) {
|
||||||
@@ -182,16 +186,21 @@ async function escapePit(bot, maxSteps = 3) {
|
|||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
const moved = movedDistance(before, bot.entity.position);
|
// Let jump physics settle before deciding whether escape-pit worked.
|
||||||
if (moved >= 0.75) {
|
// 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
code: "done",
|
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) },
|
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" });
|
return digEscapeTunnel(bot, { maxSteps: 3, reason: "explore.far escape-pit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
return { x: (pos?.x ?? 0) + 0.5, y: (pos?.y ?? 0) + 0.5, z: (pos?.z ?? 0) + 0.5 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function distance(a, b) {
|
function horizontalDistance(a, b) {
|
||||||
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.y ?? 0) - (a?.y ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
|
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) {
|
function isLiquidBlock(block) {
|
||||||
@@ -329,17 +333,18 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
|
|||||||
await digOne(bot, target.block);
|
await digOne(bot, target.block);
|
||||||
}
|
}
|
||||||
await pushForward(bot, dir.yaw, pushMs);
|
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) {
|
if (moved >= minMove) {
|
||||||
const movedTo = posClone(bot.entity.position);
|
const movedTo = posClone(bot.entity.position);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
code: "done",
|
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 },
|
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}`);
|
warn("action", `tunnel-out: ${lastError}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastError = e?.message ?? String(e);
|
lastError = e?.message ?? String(e);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { getSkill } from "./index.js";
|
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) {
|
function makePos(x, y, z) {
|
||||||
return {
|
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("chest", 1, 64, 0)), false);
|
||||||
assert.equal(_internal.isSafeTunnelDigTarget(bot, makeBlock("bedrock", 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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { createWorldJournal, _internal } from "./world-journal.js";
|
|||||||
|
|
||||||
function tag() { return `__t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
|
function tag() { return `__t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
|
||||||
|
|
||||||
|
// Tests share the real state/<host>/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", () => {
|
test("cellOf buckets by GRID_CELL", () => {
|
||||||
assert.equal(_internal.cellOf(0, 0), "0,0");
|
assert.equal(_internal.cellOf(0, 0), "0,0");
|
||||||
assert.equal(_internal.cellOf(15, 15), "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", () => {
|
test("append + nearest returns the entry we just stored", () => {
|
||||||
const j = createWorldJournal();
|
const j = freshJournal();
|
||||||
const name = tag();
|
const name = tag();
|
||||||
j.append({ kind: "chopped", name, at: { x: 100, y: 64, z: 200 } });
|
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 });
|
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", () => {
|
test("nearest ranks by distance and respects radius", () => {
|
||||||
const j = createWorldJournal();
|
const j = freshJournal();
|
||||||
const t = tag();
|
const t = tag();
|
||||||
j.append({ kind: "stone", name: t + "_far", at: { x: 100, y: 64, z: 100 } });
|
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 } });
|
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", () => {
|
test("leanestQuadrant returns the quadrant with fewest entries", () => {
|
||||||
const j = createWorldJournal();
|
const j = freshJournal();
|
||||||
const t = tag();
|
const t = tag();
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
j.append({ kind: "dead_end", name: t, at: { x: 10 + i, y: 64, z: -10 - i } }); // NE
|
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", () => {
|
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: 1, y: 1, z: 1 } });
|
||||||
j.append({ kind: "chopped", name: "oak_log", at: { x: 2, y: 1, z: 2 } });
|
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 } });
|
j.append({ kind: "shelter", name: "shelter", at: { x: 0, y: 1, z: 0 } });
|
||||||
|
|||||||
Reference in New Issue
Block a user