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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user