v0.3.0-rc.3: event-driven awareness + skill pre-emption

Adds a reactive layer on top of the polling reflex. The bot now
notices environmental shocks (forced moves, HP plunges, hostile
spawns) within ~100ms instead of waiting for the next DISPATCH tick,
and the in-flight skill is preempted so the next reflex cycle can
re-plan against the current world state.

This is the rc that wires the "rc.1 plumbing + rc.2 manifesto" into
a feedback loop:
  - awareness fires preempt → dispatch aborts
  - reflex tick re-evaluates → manifesto walks the ladder
  - new dispatch picks the right skill for the new world state

Pieces:

- runtime/awareness/events.js (new) — bot.on listeners:
  - move: single-tick Δposition ≥ 5 blocks → forced_move flag + preempt
  - health: HP drop ≥ 2 → health_plunge flag + preempt
  - entitySpawn: hostile mob within 12 blocks → hostile_added + preempt
  - blockUpdate: nearby block change → env_changed flag (no preempt,
    throttled 800ms; otherwise gather skills would self-preempt
    every dig)

- runtime/skills/index.js — RUNNER_CODES.PREEMPTED + raceWithAbort()
  wraps every execute() against ctx.abortSignal. Existing skills get
  preemption for free; they don't have to check the signal manually.

- runtime/bot.js:
  - dispatchAction creates a fresh AbortController per dispatch and
    stores it on reflexCtx.currentAbort
  - attachAwareness fires controller.abort() when something disrupts
    the active skill; runSkill returns code: "preempted" and the
    reflex moves on
  - reflexCtx.lastPreempt records the most recent shock

Tests: 332 green (was 315 on rc.2, +17 new):
- runtime/awareness/events.test.js — 12 tests (each event type +
  thresholds + throttling + passive-mob filter)
- runtime/skills/contract.test.js — 3 abortSignal tests
  (mid-flight, pre-armed, clean signal)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:56:33 +03:00
co-authored by Claude Opus 4.7
parent ddc67a5031
commit 8473ba6519
7 changed files with 471 additions and 5 deletions
+30
View File
@@ -61,6 +61,7 @@ import { initKnowledge } from "./knowledge/index.js";
import { attach as attachCoach } from "./coach/postmortem.js";
import { attach as attachReflect } from "./coach/reflect.js";
import { attach as attachChatter } from "./persona/chatter.js";
import { attachAwareness } from "./awareness/events.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -78,6 +79,7 @@ const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000;
let bot = null;
let pathWatchdog = null;
let awarenessState = null;
let reflexPaused = false;
let tickTimer = null;
let reconnectTimer = null;
@@ -237,6 +239,14 @@ function dispatchAction(fn, label, opts = {}) {
}
reflexCtx.busy = true;
reflexCtx.currentActionLabel = label;
// v0.3.0-rc.3 — pre-emption: each dispatch gets a fresh AbortController.
// awareness/events.js#onPreempt fires controller.abort() when the env
// shocks (forced move, HP plunge, hostile spawn) the current skill
// shouldn't run against. runSkill races execute() with the signal and
// returns code: "preempted" within one microtask.
const dispatchAbort = new AbortController();
reflexCtx.currentAbort = dispatchAbort;
reflexCtx.abortSignal = dispatchAbort.signal;
const startedAt = Date.now();
// Capture the situation hash BEFORE the action runs so a failure is
// attributable to the state at dispatch time, not the state after the
@@ -312,6 +322,10 @@ function dispatchAction(fn, label, opts = {}) {
.finally(() => {
reflexCtx.busy = false;
reflexCtx.currentActionLabel = null;
if (reflexCtx.currentAbort === dispatchAbort) {
reflexCtx.currentAbort = null;
reflexCtx.abortSignal = null;
}
});
}
@@ -673,6 +687,22 @@ function connect() {
try { attachCoach(bot, { stateDir, askPi }); } catch (e) { warn("coach", `attach: ${e?.message ?? e}`); }
try { attachReflect({ bot, stateDir, askPi, getSnapshot: () => lastSnapshot }); } catch (e) { warn("reflect", `attach: ${e?.message ?? e}`); }
try { attachChatter(bot, { getSnapshot: () => lastSnapshot }); } catch (e) { warn("persona", `attach: ${e?.message ?? e}`); }
// v0.3.0-rc.3 — awareness layer: listens to bot.on('move'/'health'/
// 'entitySpawn'/'blockUpdate') and aborts the current dispatch via
// reflexCtx.currentAbort when something disrupts the in-flight skill.
try {
awarenessState = attachAwareness(bot, {
onPreempt: ({ reason, payload }) => {
const abort = reflexCtx.currentAbort;
if (abort && !abort.signal.aborted) {
info("preempt", `aborting ${reflexCtx.currentActionLabel ?? "?"} due to ${reason}`);
abort.abort();
}
reflexCtx.lastPreempt = { reason, payload, at: Date.now() };
},
});
reflexCtx.awareness = awarenessState;
} catch (e) { warn("awareness", `attach: ${e?.message ?? e}`); }
});
bot.on("messagestr", (text) => {