From c63e3d1a688c63303e2e679203011934512b386f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 21 Jul 2026 08:50:47 -0500 Subject: [PATCH] =?UTF-8?q?self-review=202026-07-21:=20break=20perceive?= =?UTF-8?q?=E2=86=92respond=E2=86=92store=20feedback=20loop=20in=20awarene?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soul daemon leaked ~104 orphan in-memory nodes/min (17.6GB RSS, OOM-killed) because the perceive gate substring-matched 'soul-inbox' against the loop's own verbatim-copy output, the trigger node was strengthened but never consumed, and record() persisted a Memory node per cycle. Fixes: perceive gates and activates only on the dedicated soul-inbox-pending tag; one_cycle requires the tag on the node's tags field before attending (makes consumption safe); processed triggers are consumed via engram_forget; loop outcomes route through ISE telemetry (48h prune) instead of permanent Memory nodes. Verified post-restart: node_delta 104→~0, curiosity scans resumed, WM average unfrozen (0.120833→0.0676), RSS 17.6GB→184MB. --- awareness.el | 55 +++++++++++++++++++++++++++++++++++------------- dist/awareness.c | 24 ++++++++++----------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/awareness.el b/awareness.el index 9d04e0e..a97a5ec 100644 --- a/awareness.el +++ b/awareness.el @@ -391,22 +391,23 @@ fn perceive() -> String { // running it every second when the inbox is empty destroys working memory // accumulated by MCP-layer activations. engram_search_json is a pure // substring scan with no WM side-effects; use it as a cheap gate. - let inbox_check: String = engram_search_json("soul-inbox", 5) + // 2026-07-21 self-review: gate and activate ONLY on the dedicated inbox tag + // "soul-inbox-pending" (the tag routes.el:207 actually writes). The old + // broad "soul-inbox" gate + fallback activation substring-matched ANY node + // whose content merely mentioned the phrase — including the loop's own + // soul-response output, which respond() stores as a verbatim copy of the + // trigger. That fed a self-sustaining perceive→respond→store loop: ~2 + // orphan nodes/pulse (~104/min), 17.6GB RSS, WM frozen at avg 0.120833, + // and proactive_curiosity permanently suppressed via did_work=true. + let inbox_check: String = engram_search_json("soul-inbox-pending", 5) let has_inbox: Bool = !str_eq(inbox_check, "") && !str_eq(inbox_check, "[]") if !has_inbox { return "[]" } - // Only run the full activation pipeline when there is inbox content. let from_pending: String = engram_activate_json("soul-inbox-pending", 2) let pending_ok: Bool = !str_eq(from_pending, "") && !str_eq(from_pending, "[]") if pending_ok { return from_pending } - // Fallback: broader inbox scan - let from_inbox: String = engram_activate_json("soul-inbox", 2) - let inbox_ok: Bool = !str_eq(from_inbox, "") && !str_eq(from_inbox, "[]") - if inbox_ok { - return from_inbox - } return "[]" } @@ -418,11 +419,10 @@ fn attend(node_json: String) -> String { return make_action("noop", "") } - let node_id: String = json_get(node_json, "id") - if !str_eq(node_id, "") { - engram_strengthen(node_id) - } - + // 2026-07-21 self-review: the trigger node is no longer strengthened here. + // Strengthening RAISED the trigger's salience (+0.05) on every pass while + // nothing ever consumed it, so the same node out-ranked real inbox items + // indefinitely. one_cycle() now consumes the trigger after processing. let content: String = json_get(node_json, "content") if str_eq(content, "") { return make_action("noop", "") @@ -515,8 +515,13 @@ fn respond(action_json: String) -> String { } fn record(outcome_json: String) -> Void { - let tags: String = "[\"loop-outcome\"]" - mem_store(outcome_json, "loop-outcome", tags) + // 2026-07-21 self-review: loop outcomes are telemetry, not memories. They + // now go through ise_post (InternalStateEvent — covered by the 48h prune) + // instead of mem_store, which created one permanent orphan Memory node + // per cycle: the single largest per-tick node-creation path in the daemon. + let safe: String = str_replace(outcome_json, "\"", "'") + let ts: Int = time_now() + ise_post("{\"event\":\"loop-outcome\",\"outcome\":\"" + safe + "\",\"ts\":" + int_to_str(ts) + "}") } fn one_cycle() -> Bool { @@ -533,6 +538,17 @@ fn one_cycle() -> Bool { return false } + // 2026-07-21 self-review: positive filter — only nodes explicitly TAGGED + // soul-inbox-pending are inbox items. Activation seeding is substring-based + // over content too, so without this check any node whose content merely + // mentions the inbox phrase (knowledge notes, the daemon's own output) + // would be attended, responded to, and — now that triggers are consumed — + // destroyed. Tag check makes consumption safe. + let node_tags: String = json_get(node, "tags") + if !str_contains(node_tags, "soul-inbox-pending") { + return false + } + let action: String = attend(node) let kind: String = json_get(action, "kind") @@ -552,6 +568,15 @@ fn one_cycle() -> Bool { let outcome: String = respond(action) record(outcome) + + // Consume the processed inbox trigger. attend() used to only strengthen + // it (raising its rank every pass); nothing ever removed it, so the same + // item could be re-processed forever. engram_forget no-ops on unknown ids, + // so this is safe even if the action itself already removed the node. + let trigger_id: String = json_get(node, "id") + if !str_eq(trigger_id, "") { + engram_forget(trigger_id) + } return true } diff --git a/dist/awareness.c b/dist/awareness.c index 55db2d7..df6789b 100644 --- a/dist/awareness.c +++ b/dist/awareness.c @@ -284,7 +284,7 @@ el_val_t make_action(el_val_t kind, el_val_t payload) { } el_val_t perceive(void) { - el_val_t inbox_check = engram_search_json(EL_STR("soul-inbox"), 5); + el_val_t inbox_check = engram_search_json(EL_STR("soul-inbox-pending"), 5); el_val_t has_inbox = (!str_eq(inbox_check, EL_STR("")) && !str_eq(inbox_check, EL_STR("[]"))); if (!has_inbox) { return EL_STR("[]"); @@ -294,11 +294,6 @@ el_val_t perceive(void) { if (pending_ok) { return from_pending; } - el_val_t from_inbox = engram_activate_json(EL_STR("soul-inbox"), 2); - el_val_t inbox_ok = (!str_eq(from_inbox, EL_STR("")) && !str_eq(from_inbox, EL_STR("[]"))); - if (inbox_ok) { - return from_inbox; - } return EL_STR("[]"); return 0; } @@ -310,10 +305,6 @@ el_val_t attend(el_val_t node_json) { if (str_eq(node_json, EL_STR("[]"))) { return make_action(EL_STR("noop"), EL_STR("")); } - el_val_t node_id = json_get(node_json, EL_STR("id")); - if (!str_eq(node_id, EL_STR(""))) { - engram_strengthen(node_id); - } el_val_t content = json_get(node_json, EL_STR("content")); if (str_eq(content, EL_STR(""))) { return make_action(EL_STR("noop"), EL_STR("")); @@ -392,8 +383,9 @@ el_val_t respond(el_val_t action_json) { } el_val_t record(el_val_t outcome_json) { - el_val_t tags = EL_STR("[\"loop-outcome\"]"); - mem_store(outcome_json, EL_STR("loop-outcome"), tags); + el_val_t safe = str_replace(outcome_json, EL_STR("\""), EL_STR("'")); + el_val_t ts = time_now(); + ise_post(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"event\":\"loop-outcome\",\"outcome\":\""), safe), EL_STR("\",\"ts\":")), int_to_str(ts)), EL_STR("}"))); return 0; } @@ -409,6 +401,10 @@ el_val_t one_cycle(void) { if (str_eq(node, EL_STR(""))) { return 0; } + el_val_t node_tags = json_get(node, EL_STR("tags")); + if (!str_contains(node_tags, EL_STR("soul-inbox-pending"))) { + return 0; + } el_val_t action = attend(node); el_val_t kind = json_get(action, EL_STR("kind")); el_val_t is_interesting = (!str_eq(kind, EL_STR("noop")) && !str_eq(kind, EL_STR("respond"))); @@ -424,6 +420,10 @@ el_val_t one_cycle(void) { } el_val_t outcome = respond(action); record(outcome); + el_val_t trigger_id = json_get(node, EL_STR("id")); + if (!str_eq(trigger_id, EL_STR(""))) { + engram_forget(trigger_id); + } return 1; return 0; }