Files
will.anderson b77f537dc6 fix cross-repo path deps; remove el compiler from neuron-lang/
- neuron-runtime, neuron-store, neuron-migrate: fix path deps
  (products/engram/ → foundation/engram/engrams/, products/el/ → foundation/el/engrams/)
- neuron-rs workspace: fix axon-events/axon-protocol paths
  (../../../platform/ → ../../platform/, paths were off by one level)
- neuron-lang/compiler/ removed (el self-hosting compiler now lives in foundation/el/el-compiler/)
2026-04-29 03:27:39 -05:00

333 lines
12 KiB
EmacsLisp

// loop.el Six-tier runtime heartbeat for the Neuron daemon.
//
// Implements the self-pacing cognitive loop described in the architecture
// doc. The loop runs in its own OS thread (spawned via `spawn_thread`)
// and communicates with the rest of the daemon through global shared
// state (`state_get` / `state_set`).
//
// The six tiers:
// resting 30 min (low signal, diffuse)
// watching 10 min (ambient monitoring)
// working 15 sec (background task in progress)
// active 500 ms (conversation in progress)
// critical 10 ms (bell fired)
// realtime busy loop (physical actuator pinned)
//
// Tier transition rules:
// 1. Bell is sacred escalates to Critical immediately, never dropped.
// 2. Escalation is immediate loop re-enters without finishing sleep.
// 3. Step-down is earned 4 consecutive idle ticks before stepping down.
// 4. Floor is configurable never drops below `loop_min_tier`.
//
// State keys this file reads / writes:
// loop_tier, loop_min_tier, loop_ticks, loop_bell_fires,
// loop_tier_changes, loop_idle_ticks, loop_signal, loop_override_tier
//
// Cognitive substrate:
// loop manages WHEN to tick; agent.el manages WHAT to do on each tick.
// loop_do_tick() calls agent_tick() after confirming neuronrs is live.
import "agent.el"
import "plugins/host.el"
// Tier constants and ordering
fn loop_tier_rank(tier: String) -> Int {
if str_eq(tier, "resting") { return 0 }
if str_eq(tier, "watching") { return 1 }
if str_eq(tier, "working") { return 2 }
if str_eq(tier, "active") { return 3 }
if str_eq(tier, "critical") { return 4 }
if str_eq(tier, "realtime") { return 5 }
return 1
}
fn loop_tier_from_rank(rank: Int) -> String {
if rank <= 0 { return "resting" }
if rank == 1 { return "watching" }
if rank == 2 { return "working" }
if rank == 3 { return "active" }
if rank == 4 { return "critical" }
return "realtime"
}
// Sleep interval in milliseconds for each tier.
// Returns 0 for realtime (busy loop, no sleep at all).
fn loop_tier_interval(tier: String) -> Int {
if str_eq(tier, "resting") { return 1800000 }
if str_eq(tier, "watching") { return 600000 }
if str_eq(tier, "working") { return 15000 }
if str_eq(tier, "active") { return 500 }
if str_eq(tier, "critical") { return 10 }
if str_eq(tier, "realtime") { return 0 }
return 600000
}
// Signal handling
// Apply a signal to the current tier and return the new tier.
//
// Bell is special: it always escalates to at least Critical, regardless
// of `min_tier`. Other escalations clamp to max(needed, min_tier) so a
// configured floor cannot trap us above an explicit step-down.
fn loop_apply_signal(current: String, signal: String, min_tier: String) -> String {
let cur_rank: Int = loop_tier_rank(current)
let min_rank: Int = loop_tier_rank(min_tier)
// Bell sacred. Always go to at least Critical.
if str_eq(signal, "bell") {
let crit_rank: Int = 4
if cur_rank >= crit_rank {
return current
}
return "critical"
}
// Realtime escalation pins us at the top.
if str_eq(signal, "realtime") {
return "realtime"
}
// Release-realtime drops us back to Critical.
if str_eq(signal, "release-realtime") {
if str_eq(current, "realtime") {
return "critical"
}
return current
}
// Active escalates to at least Active.
if str_eq(signal, "active") {
let need: Int = 3
if cur_rank >= need {
return current
}
return "active"
}
// Task escalates to at least Working.
if str_eq(signal, "task") {
let need: Int = 2
if cur_rank >= need {
return current
}
return "working"
}
// Sleep is an explicit step-down request drop one tier
// (respecting the floor).
if str_eq(signal, "sleep") {
return loop_step_down(current, min_tier)
}
// Idle / drain just contribute to the idle counter; the tier
// itself does not change here.
return current
}
// Step down one tier, but never below the configured min_tier.
fn loop_step_down(current: String, min_tier: String) -> String {
let cur_rank: Int = loop_tier_rank(current)
let min_rank: Int = loop_tier_rank(min_tier)
if cur_rank <= min_rank {
return min_tier
}
let new_rank: Int = cur_rank - 1
if new_rank < min_rank {
return min_tier
}
return loop_tier_from_rank(new_rank)
}
// Bell-aware sleep
// Tail-recursive sleep that wakes early if a bell signal arrives.
// Sleeps in 100ms chunks, peeking at `loop_signal` between each chunk.
fn loop_sleep_chunked(remaining_ms: Int) -> Void {
if remaining_ms <= 0 {
// done
} else {
let pending: String = state_get("loop_signal")
if str_eq(pending, "bell") {
// Bell detected mid-sleep return immediately so the
// outer loop can re-enter and escalate.
} else {
let chunk: Int = if remaining_ms < 100 { remaining_ms } else { 100 }
sleep_ms(chunk)
loop_sleep_chunked(remaining_ms - chunk)
}
}
}
// Public sleep entry point. realtime (interval == 0) is a no-op.
fn loop_sleep(remaining_ms: Int) -> Void {
if remaining_ms > 0 {
loop_sleep_chunked(remaining_ms)
}
}
// Tick
// Perform one cognitive tick.
//
// First pings neuronrs /health to confirm the substrate is live.
// If live, delegates to agent_tick() the cognitive work is in agent.el.
// loop manages WHEN; agent manages WHAT.
//
// Side effect: writes "1" or "0" to `loop_last_tick_idle` so the
// caller can see whether the tick was idle (no live counterpart).
fn loop_do_tick(tier: String) -> Void {
let url: String = "http://localhost:7770/health"
let resp: String = http_get(url)
let live: Bool = str_contains(resp, "ok")
if live {
state_set("loop_last_tick_idle", "0")
// Delegate to the agent cognitive substrate.
agent_tick({})
} else {
state_set("loop_last_tick_idle", "1")
}
// Drain the plugin event bus on every tick, regardless of substrate liveness.
// Plugins interact through events only host_tick routes events to all
// plugins that subscribed to them. No hooks, no direct callbacks.
host_tick({})
println("[loop] tick tier=" + tier + " live=" + bool_to_str(live))
}
// Counters
fn loop_incr_state_int(key: String) -> Void {
let raw: String = state_get(key)
let n: Int = if str_eq(raw, "") { 0 } else { str_to_int(raw) }
state_set(key, int_to_str(n + 1))
}
fn loop_set_int(key: String, value: Int) -> Void {
state_set(key, int_to_str(value))
}
// Core loop
// One iteration of the heartbeat. Tail-recursive: each tick re-enters
// itself with the (possibly updated) tier and idle counter.
fn loop_run(tier: String, idle_count: Int) -> Void {
// 1. Read floor and pending signals.
let min_tier: String = state_get("loop_min_tier")
let floor: String = if str_eq(min_tier, "") { "resting" } else { min_tier }
let pending: String = state_get("loop_signal")
let override_tier: String = state_get("loop_override_tier")
// 2. Apply override (explicit set tier wins over signal).
let after_override: String = if str_eq(override_tier, "") {
tier
} else {
override_tier
}
if !str_eq(override_tier, "") {
state_set("loop_override_tier", "")
loop_incr_state_int("loop_tier_changes")
}
// 3. Apply signal.
let after_signal: String = if str_eq(pending, "") {
after_override
} else {
loop_apply_signal(after_override, pending, floor)
}
let signal_changed: Bool = !str_eq(pending, "")
if signal_changed {
state_set("loop_signal", "")
if str_eq(pending, "bell") {
loop_incr_state_int("loop_bell_fires")
}
if !str_eq(after_signal, after_override) {
loop_incr_state_int("loop_tier_changes")
}
}
// 4. Persist current tier.
state_set("loop_tier", after_signal)
// 5. Sleep for this tier's interval (bell-aware, chunked).
let interval_ms: Int = loop_tier_interval(after_signal)
loop_sleep(interval_ms)
// 6. If a bell arrived during sleep, re-enter immediately without
// ticking the next iteration will re-read the signal and
// escalate.
let after_sleep_signal: String = state_get("loop_signal")
if str_eq(after_sleep_signal, "bell") {
loop_run(after_signal, idle_count)
} else {
// 7. Run the cognitive tick.
loop_do_tick(after_signal)
loop_incr_state_int("loop_ticks")
// 8. Update idle counter and step down if earned.
let last_idle: String = state_get("loop_last_tick_idle")
let was_idle: Bool = str_eq(last_idle, "1")
let new_idle_count: Int = if was_idle { idle_count + 1 } else { 0 }
// Idle / drain signals also push the counter forward, but
// `loop_apply_signal` does not change the tier for them.
let idle_signal: Bool = str_eq(pending, "idle") || str_eq(pending, "drain")
let bumped_idle_count: Int = if idle_signal {
new_idle_count + 1
} else {
new_idle_count
}
// Realtime is pinned only `release-realtime` can lower it.
let pinned: Bool = str_eq(after_signal, "realtime")
if !pinned && bumped_idle_count >= 4 {
let stepped: String = loop_step_down(after_signal, floor)
if !str_eq(stepped, after_signal) {
loop_incr_state_int("loop_tier_changes")
}
loop_set_int("loop_idle_ticks", 0)
loop_run(stepped, 0)
} else {
loop_set_int("loop_idle_ticks", bumped_idle_count)
loop_run(after_signal, bumped_idle_count)
}
}
}
// Entry point called by `spawn_thread("loop_main")`.
fn loop_main() -> Void {
let start_tier: String = state_get("loop_tier")
let initial: String = if str_eq(start_tier, "") { "watching" } else { start_tier }
println("[loop] starting at tier=" + initial)
loop_run(initial, 0)
}
// Status JSON
fn loop_status_field(key: String, default: String) -> String {
let v: String = state_get(key)
if str_eq(v, "") { return default }
return v
}
fn loop_status_json() -> String {
let tier: String = loop_status_field("loop_tier", "watching")
let min_tier: String = loop_status_field("loop_min_tier", "resting")
let ticks: String = loop_status_field("loop_ticks", "0")
let bell_fires: String = loop_status_field("loop_bell_fires", "0")
let tier_changes: String = loop_status_field("loop_tier_changes", "0")
let idle_ticks: String = loop_status_field("loop_idle_ticks", "0")
let signal: String = loop_status_field("loop_signal", "")
let parts: String = "{\"current_tier\":\"" + tier + "\""
let p2: String = parts + ",\"min_tier\":\"" + min_tier + "\""
let p3: String = p2 + ",\"ticks\":" + ticks
let p4: String = p3 + ",\"bell_fires\":" + bell_fires
let p5: String = p4 + ",\"tier_changes\":" + tier_changes
let p6: String = p5 + ",\"idle_ticks\":" + idle_ticks
let p7: String = p6 + ",\"pending_signal\":\"" + signal + "\"}"
return p7
}