b77f537dc6
- 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/)
333 lines
12 KiB
EmacsLisp
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
|
|
}
|