From 494d973a3be6f9053764aa21974ed49d1eed8b8f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 11:48:59 -0500 Subject: [PATCH] =?UTF-8?q?fix(reliability):=20engram-write=20=E2=80=94=20?= =?UTF-8?q?guard=20all=20fire-and-forget=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every engram_node_full call that dropped its return value now binds it and emits a println on empty string. engram_save calls in consolidate, heartbeat, and dharma-room-turn are checked for failure. The two API handlers (log_state_event, tune_config) that skipped api_persisted() now match the read-back-after-write contract used everywhere else in neuron-api.el. Files changed: - chat.el: conv_history_persist, handle_dharma_room_turn, auto_persist - soul.el: emit_session_start_event, seed_persona_from_env HTTP check - memory.el: mem_save, mem_boot_count_inc - neuron-api.el: handle_api_log_state_event, handle_api_tune_config, handle_api_consolidate (engram_save + session summary write) - awareness.el: ise_post local-engram fallback path TODO comments added for non-atomic patterns (issues #12, #13) and the missing circuit breaker (#14) — these require new primitives. --- awareness.el | 5 ++++- chat.el | 35 +++++++++++++++++++++++++++++++---- memory.el | 10 ++++++++-- neuron-api.el | 12 ++++++++++-- soul.el | 12 ++++++++++-- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/awareness.el b/awareness.el index a3a5432..50bf1f6 100644 --- a/awareness.el +++ b/awareness.el @@ -23,11 +23,14 @@ fn ise_post(content: String) -> Void { let ise_url: String = env("SOUL_ISE_URL") let engram_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url } if str_eq(engram_url, "") { - let discard: String = engram_node_full( + let local_id: String = engram_node_full( content, "InternalStateEvent", "state-event", el_from_float(0.3), el_from_float(0.3), el_from_float(0.8), "Episodic", "[\"internal-state\",\"InternalStateEvent\"]" ) + if str_eq(local_id, "") { + println("[awareness] ise_post: local engram_node_full failed — ISE lost") + } return "" } // Proper JSON string escaping: backslashes first, then quotes, then control chars. diff --git a/chat.el b/chat.el index 913259d..6d809e2 100644 --- a/chat.el +++ b/chat.el @@ -130,11 +130,14 @@ fn conv_history_persist(hist: String) -> Void { if str_eq(hist, "[]") { return "" } let ts: Int = time_now() let tags: String = "[\"conv-history\",\"persistent\"]" - let discard: String = engram_node_full( + let node_id: String = engram_node_full( hist, "Conversation", "conv:history", el_from_float(0.7), el_from_float(0.8), el_from_float(0.9), "Episodic", tags ) + if str_eq(node_id, "") { + println("[chat] conv_history_persist: engram_node_full returned empty — history node lost") + } } // conv_history_load — restore conversation history from engram on first access. @@ -637,6 +640,21 @@ fn handle_chat_agentic(body: String) -> String { // Thread-aware activation: same logic as handle_chat. // Use the session's or global history to anchor short messages to the thread. let req_session: String = json_get(body, "session_id") + + // ISSUE #6/#7: validate that the session_id actually exists before proceeding. + // Without this check the loop silently treats any unknown/fabricated session_id + // as a fresh session — history loads as empty and no error is returned to the caller. + // Only validate when a session_id is explicitly provided; anonymous calls + // (no session_id) continue to work for backward compatibility. + let session_valid: Bool = if str_eq(req_session, "") { + true + } else { + session_exists(req_session) + } + if !session_valid { + return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}" + } + let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session } let agentic_hist: String = state_get(hist_key) let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) } @@ -1054,13 +1072,19 @@ fn handle_dharma_room_turn(body: String) -> String { // engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type // slot — that's why nodes showed node_type="episodic". Use the full, correct contract.) let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]" - let discard_id: String = engram_node_full( + let utterance_id: String = engram_node_full( clean_response, "Conversation", "soul:utterance", el_from_float(0.6), el_from_float(0.6), el_from_float(0.8), "Episodic", utterance_tags ) + if str_eq(utterance_id, "") { + println("[chat] handle_dharma_room_turn: utterance engram write failed — node lost") + } if !str_eq(snap_path, "") { - let discard_save: String = engram_save(snap_path) + let save_result: String = engram_save(snap_path) + if str_eq(save_result, "") { + println("[chat] handle_dharma_room_turn: engram_save failed for " + snap_path) + } } let safe_response: String = json_safe(clean_response) @@ -1142,7 +1166,7 @@ fn auto_persist(req: String, resp: String) -> Void { + ",\"label\":\"chat:" + ts_str + "\"}" let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]" - engram_node_full( + let persist_id: String = engram_node_full( content, "Conversation", "chat:" + ts_str, @@ -1152,6 +1176,9 @@ fn auto_persist(req: String, resp: String) -> Void { "Episodic", tags ) + if str_eq(persist_id, "") { + println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")") + } } // strengthen_chat_nodes — strengthen the engram nodes that were activated during a chat. diff --git a/memory.el b/memory.el index b468327..eae12c6 100644 --- a/memory.el +++ b/memory.el @@ -46,7 +46,10 @@ fn mem_consolidate() -> String { } fn mem_save(path: String) -> Void { - engram_save(path) + let save_result: String = engram_save(path) + if str_eq(save_result, "") { + println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete") + } } fn mem_load(path: String) -> Void { @@ -76,11 +79,14 @@ fn mem_boot_count_inc() -> Int { let next: Int = current + 1 let content: String = "soul:boot_count:" + int_to_str(next) let tags: String = "[\"soul-meta\",\"boot-counter\"]" - let discard: String = engram_node_full( + let boot_node_id: String = engram_node_full( content, "Memory", "soul:boot_count", el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), "Canonical", tags ) + if str_eq(boot_node_id, "") { + println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")") + } return next } diff --git a/neuron-api.el b/neuron-api.el index f95d30e..1e6b78b 100644 --- a/neuron-api.el +++ b/neuron-api.el @@ -400,6 +400,7 @@ fn handle_api_log_state_event(body: String) -> String { let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual", el_from_float(0.85), el_from_float(0.85), el_from_float(0.9), "Episodic", tags) + if !api_persisted(id) { return api_not_persisted(id) } return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}" } @@ -452,6 +453,7 @@ fn handle_api_tune_config(body: String) -> String { let id: String = engram_node_full(content, "ConfigEntry", key, el_from_float(0.85), el_from_float(0.85), el_from_float(0.9), "Canonical", tags) + if !api_persisted(id) { return api_not_persisted(id) } return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}" } @@ -651,17 +653,23 @@ fn handle_api_consolidate(body: String) -> String { let summary: String = json_get(body, "summary") let snap: String = state_get("soul_snapshot_path") if !str_eq(snap, "") { - engram_save(snap) + let save_result: String = engram_save(snap) + if str_eq(save_result, "") { + println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync") + } } if !str_eq(summary, "") { let safe_summary: String = str_replace(summary, "\"", "'") let tags: String = "[\"SessionSummary\",\"consolidate\"]" - let discard: String = engram_node_full( + let summary_id: String = engram_node_full( "[session-summary] " + safe_summary, "SessionSummary", "session:summary", el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), "Episodic", tags ) + if str_eq(summary_id, "") { + println("[api] consolidate: session summary engram write failed — summary node lost") + } } return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}" } diff --git a/soul.el b/soul.el index 0147f2a..50e3784 100644 --- a/soul.el +++ b/soul.el @@ -212,8 +212,13 @@ fn seed_persona_from_env() -> Void { let h: Map = {} map_set(h, "Content-Type", "application/json") let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h) - if str_contains(resp, "\"error\"") { + // Check for empty response (timeout/network error), explicit error, or missing id. + if str_eq(resp, "") { + println("[soul] persona HTTP write-back failed: empty response (timeout or network error) — in-memory only this session") + } else if str_contains(resp, "\"error\"") { println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp) + } else if !str_contains(resp, "\"id\"") { + println("[soul] persona HTTP write-back: unexpected response (no id field) — in-memory only this session: " + resp) } else { println("[soul] persona persisted to HTTP engram at " + engram_url) } @@ -246,11 +251,14 @@ fn emit_session_start_event() -> Void { + ",\"ts\":" + int_to_str(ts) + "}" let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]" - let discard: String = engram_node_full( + let session_event_id: String = engram_node_full( payload, "InternalStateEvent", "session-start", el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), "Episodic", tags ) + if str_eq(session_event_id, "") { + println("[soul] emit_session_start_event: engram write failed — session-start event lost") + } println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")") }