self-review 2026-05-26: wall-clock heartbeat timing + seed rotation fix
Two awareness loop bugs fixed: 1. Seed rotation never worked: dist/awareness.c was compiled from stale source (pre-fix awareness.el still had broken ts_minutes % 4). Compiled C showed `minute_block = (ts / 60000); EL_NULL; 4;` — minute_block was always ts_minutes (millions), never 0-3. if(minute_block==1/2/3) never matched. Fix: recompile from current awareness.el which has the correct modulo workaround: ts_minutes - minute_q4 (via + - / only). 2. Heartbeat/curiosity silent for 24h at 99% CPU: old design used idle-tick counting (idle_n >= beat_interval). Failed when perceive() inbox guard false-positives on "soul-inbox" substring matches in knowledge nodes — did_work=true every tick, idle_n never accumulated, neither signal fired. Fix: wall-clock elapsed time (time_now() - last_ts >= interval_ms). Heartbeat fires regardless of load. New SOUL_HEARTBEAT_MS env var (default 60000ms) avoids the broken EL * operator. Verified: heartbeat ISEs flowing at pulse 3 within 2 minutes of restart.
This commit is contained in:
+41
-23
@@ -387,6 +387,27 @@ fn awareness_run() -> Void {
|
||||
let tick_raw: String = env("SOUL_TICK_MS")
|
||||
let tick_ms: Int = if str_eq(tick_raw, "") { 200 } else { str_to_int(tick_raw) }
|
||||
|
||||
// Wall-clock timing for heartbeat and curiosity scan.
|
||||
//
|
||||
// ARCHITECTURE FIX (2026-05-26): the old design used idle-tick counting
|
||||
// (idle_n >= beat_interval). This broke silently when the daemon was always
|
||||
// "working" — e.g., when perceive() false-positives on the inbox guard due to
|
||||
// "soul-inbox" substring matches in knowledge nodes. In that state, did_work=true
|
||||
// every tick, idle_n never accumulated, and neither heartbeat nor curiosity ever
|
||||
// fired. The daemon ran at 99% CPU with no ISEs for 24+ hours undetected.
|
||||
//
|
||||
// Fix: use wall-clock elapsed time (time_now() - last_ts). Heartbeat fires
|
||||
// on real time regardless of whether the daemon is busy. Curiosity scan
|
||||
// remains idle-gated (won't fire while inbox work is active) but also uses
|
||||
// wall time so it fires correctly once inbox clears.
|
||||
//
|
||||
// SOUL_HEARTBEAT_MS: ms between heartbeat ISEs (default 60000 = 60s).
|
||||
// Avoids EL * operator (broken in this codegen) by reading ms directly.
|
||||
// Replaces SOUL_HEARTBEAT_INTERVAL (tick-based) for timing purposes.
|
||||
let beat_ms_raw: String = env("SOUL_HEARTBEAT_MS")
|
||||
let beat_ms: Int = if str_eq(beat_ms_raw, "") { 60000 } else { str_to_int(beat_ms_raw) }
|
||||
let scan_ms: Int = beat_ms / 2
|
||||
|
||||
while true {
|
||||
let running: String = state_get("soul.running")
|
||||
if str_eq(running, "false") {
|
||||
@@ -394,37 +415,34 @@ fn awareness_run() -> Void {
|
||||
return ""
|
||||
}
|
||||
let did_work: Bool = one_cycle()
|
||||
// Maintain idle counter for observability (reported in heartbeat ISE).
|
||||
let did_work = if did_work { idle_reset() } else { did_work }
|
||||
let idle_n: Int = if !did_work { idle_inc() } else { 0 }
|
||||
let beat_interval_raw: String = env("SOUL_HEARTBEAT_INTERVAL")
|
||||
let beat_interval: Int = if str_eq(beat_interval_raw, "") { 300 } else { str_to_int(beat_interval_raw) }
|
||||
// Proactive curiosity fires at half the heartbeat interval.
|
||||
let curiosity_interval: Int = beat_interval / 2
|
||||
if curiosity_interval < 1 { let curiosity_interval = 1 }
|
||||
let now_ts: Int = time_now()
|
||||
|
||||
// TIMING FIX (2026-05-25): EL's % operator is broken — it compiles as
|
||||
// a no-op (drops the modulo, emits dead code). `idle_n % X == 0` always
|
||||
// evaluated to `idle_n` (truthy from tick 1), causing both heartbeat and
|
||||
// curiosity to fire on every single idle tick.
|
||||
//
|
||||
// Fix: use >= comparisons instead of % == 0. idle_n increments on each
|
||||
// idle tick and is reset to 0 by idle_reset(). When idle_n reaches the
|
||||
// threshold, the event fires and idle_reset() is called, so the next event
|
||||
// fires after another full interval of idle ticks. No modulo needed.
|
||||
//
|
||||
// Beat has higher priority: if both thresholds are crossed, beat fires and
|
||||
// scan is suppressed (they share the idle counter, so scan would fire at
|
||||
// the next curiosity_interval boundary regardless).
|
||||
let should_beat: Bool = !did_work && idle_n > 0 && idle_n >= beat_interval
|
||||
// Heartbeat: wall-clock based. Fires every beat_ms regardless of idle
|
||||
// state so system health ISEs are always emitted even under load.
|
||||
let last_beat_str: String = state_get("soul.last_beat_ts")
|
||||
let last_beat_ts: Int = if str_eq(last_beat_str, "") { 0 } else { str_to_int(last_beat_str) }
|
||||
let beat_elapsed: Int = now_ts - last_beat_ts
|
||||
let should_beat: Bool = beat_elapsed >= beat_ms
|
||||
if should_beat {
|
||||
emit_heartbeat()
|
||||
idle_reset()
|
||||
state_set("soul.last_beat_ts", int_to_str(now_ts))
|
||||
}
|
||||
let should_scan: Bool = !did_work && idle_n > 0 && idle_n >= curiosity_interval && !should_beat
|
||||
|
||||
// Curiosity scan: idle-gated AND wall-clock based. Only fires when the
|
||||
// daemon has no current inbox work (did_work=false) AND enough wall time
|
||||
// has elapsed. Prevents curiosity activation from competing with inbox
|
||||
// processing while still ensuring it runs regularly during quiet periods.
|
||||
let last_scan_str: String = state_get("soul.last_scan_ts")
|
||||
let last_scan_ts: Int = if str_eq(last_scan_str, "") { 0 } else { str_to_int(last_scan_str) }
|
||||
let scan_elapsed: Int = now_ts - last_scan_ts
|
||||
let should_scan: Bool = !did_work && scan_elapsed >= scan_ms
|
||||
if should_scan {
|
||||
let found_something: Bool = proactive_curiosity()
|
||||
idle_reset()
|
||||
state_set("soul.last_scan_ts", int_to_str(now_ts))
|
||||
}
|
||||
|
||||
sleep_ms(tick_ms)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user