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:
2026-05-26 08:54:58 -05:00
parent 54a0ee0949
commit e92fd2d5a4
3 changed files with 391 additions and 367 deletions
+41 -23
View File
@@ -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)
}
}