Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson d008649c3e fix(reliability): engram-connection
Neuron Soul CI / build (pull_request) Has been cancelled
- entrypoint.sh: extend engram health-check timeout 30->60s; set
  EL_HTTP_TIMEOUT_MS=10000 and EL_HTTP_CONNECT_TIMEOUT_MS=3000 to bound
  awareness loop blocking window to 10s/call (down from 60s default)
- soul.el: 3-attempt retry loop for boot-time /api/nodes+/api/edges fetch;
  validate non-empty JSON array before loading to prevent silent zero-node
  identity graph from transient post-healthcheck network hiccup
- awareness.el: soft circuit-breaker in ise_post (opens after 3 failures,
  30s backoff, half-open probe); /api/sync refresh skips HTTP call when
  breaker is open; error-JSON detection on sync response

TODOs: full async dispatch, connection pooling (require EL futures/persistent curl)
2026-06-22 11:57:20 -05:00
will.anderson aa70c5dde6 fix(reliability): safety-resilience — bell augmentation, safe mode, dedup logging, tab escaping, handle_chat coverage 2026-06-22 11:54:40 -05:00
6 changed files with 135 additions and 147 deletions
-4
View File
@@ -134,10 +134,6 @@ jobs:
-lssl -lcrypto -lcurl -lpthread -lm \
-o dist/neuron
# Strip debug symbols and non-essential symbol table entries.
# -s removes the symbol table + relocation info (max size reduction).
# Keeps the binary functional; debuggability is preserved via source + CI logs.
strip -s dist/neuron
ls -lh dist/neuron
- name: Smoke test
+33 -3
View File
@@ -40,7 +40,32 @@ fn ise_post(content: String) -> Void {
let safe3: String = str_replace(safe2, "\n", "\\n")
let safe4: String = str_replace(safe3, "\r", "\\r")
let body: String = "{\"content\":\"" + safe4 + "\"}"
let discard: String = http_post_json(engram_url + "/api/neuron/state-events", body)
// Soft circuit-breaker: skip HTTP call when engram is known-down (30s backoff).
// Opens after 3 consecutive failures; half-open probe after backoff expires.
// TODO(reliability): full async dispatch requires EL runtime futures support.
let cb_open: String = state_get("engram_cb_open")
if str_eq(cb_open, "1") {
let cb_ts_s: String = state_get("engram_cb_open_ts")
let cb_ts: Int = if str_eq(cb_ts_s, "") { 0 } else { str_to_int(cb_ts_s) }
let cb_elapsed: Int = time_now() - cb_ts
if cb_elapsed < 30000 { return "" }
state_set("engram_cb_open", "0")
}
let resp: String = http_post_json(engram_url + "/api/neuron/state-events", body)
let cb_failed: Bool = str_eq(resp, "") || str_starts_with(resp, "{"error":")
if cb_failed {
let fn_s: String = state_get("engram_cb_fails")
let fn_n: Int = if str_eq(fn_s, "") { 0 } else { str_to_int(fn_s) }
let fn_n = fn_n + 1
state_set("engram_cb_fails", int_to_str(fn_n))
if fn_n >= 3 {
state_set("engram_cb_open", "1")
state_set("engram_cb_open_ts", int_to_str(time_now()))
println("[awareness] engram circuit-breaker OPEN after " + int_to_str(fn_n) + " failures")
}
} else {
state_set("engram_cb_fails", "0")
}
return ""
}
@@ -540,9 +565,14 @@ fn awareness_run() -> Void {
let should_refresh: Bool = refresh_elapsed >= refresh_ms
if should_refresh {
let engram_url: String = state_get("soul_engram_url")
if !str_eq(engram_url, "") {
let sc: String = state_get("engram_cb_open")
let sc_ts_s: String = state_get("engram_cb_open_ts")
let sc_ts: Int = if str_eq(sc_ts_s, "") { 0 } else { str_to_int(sc_ts_s) }
let sc_elapsed: Int = now_ts - sc_ts
let sync_allowed: Bool = !str_eq(sc, "1") || sc_elapsed >= 30000
if !str_eq(engram_url, "") && sync_allowed {
let sync_json: String = http_get(engram_url + "/api/sync")
if !str_eq(sync_json, "") && !str_eq(sync_json, "{}") {
if !str_eq(sync_json, "") && !str_eq(sync_json, "{}") && !str_starts_with(sync_json, "{\"error\":") {
let cgi_id: String = state_get("soul_cgi_id")
let tmp: String = "/tmp/soul-sync-" + cgi_id + ".json"
fs_write(tmp, sync_json)
+22 -123
View File
@@ -94,39 +94,18 @@ fn hist_append(hist: String, role: String, content: String) -> String {
return "[" + inner + "," + entry + "]"
}
// hist_trim drop the oldest two entries from a history JSON array.
//
// Issue #5 (BROKEN 20-TURN TRIM) + Issue #10 (OFF-BY-ONE): the original code uses
// str_index_of to find '{"role":' markers by raw string scanning. If any message content
// contains the literal string '{"role":' (e.g. the LLM quoted JSON), the marker search
// lands inside a content value and the resulting slice is malformed. Additionally, the
// function had no minimum-retained-count guard.
//
// Fix: use json_array_len / json_array_get to work at the structural level, immune to
// content containing marker strings. Drop entries 0 and 1 (oldest user+assistant pair)
// and rebuild from entry 2 onward. Minimum retained count: 2 entries (never over-trim).
fn hist_trim(hist: String) -> String {
let total: Int = json_array_len(hist)
// Safety: never trim below 2 entries. If already at or below the minimum, return unchanged.
if total <= 2 {
return hist
let inner: String = str_slice(hist, 1, str_len(hist) - 1)
let marker: String = "{\"role\":"
let i1: Int = str_index_of(inner, marker)
let tail1: String = str_slice(inner, i1 + 1, str_len(inner))
let i2: Int = str_index_of(tail1, marker)
let tail2: String = str_slice(tail1, i2 + 1, str_len(tail1))
let i3: Int = str_index_of(tail2, marker)
if i3 >= 0 {
return "[" + str_slice(tail2, i3, str_len(tail2)) + "]"
}
// Drop entry 0 and entry 1 (oldest user+assistant pair). Rebuild from entry 2 onward.
let result: String = ""
let i: Int = 2
while i < total {
let entry: String = json_array_get(hist, i)
let result = if str_eq(result, "") {
entry
} else {
result + "," + entry
}
let i = i + 1
}
if str_eq(result, "") {
return hist
}
return "[" + result + "]"
return hist
}
// clean_llm_response strips GPT-2 BPE byte-to-unicode artifacts that vLLM
@@ -145,72 +124,29 @@ fn clean_llm_response(s: String) -> String {
}
// conv_history_persist save conversation history to engram for cross-restart continuity.
// Stores as a Conversation node with label "conv:history".
//
// Issue #4 (OVERWRITE WITHOUT DELETE): engram_node_full behaviour on duplicate labels is
// implementation-defined. If it appends rather than upserts, stale older nodes accumulate.
// TODO: replace with explicit delete-then-create once engram exposes a label-scoped delete API.
//
// Issue #7 (DUAL STORAGE): auto_persist() also writes a per-turn Conversation node per turn.
// Both run every turn for different purposes (rolling array vs. Q&A snapshot). Documented here.
// Stores as a Conversation node. Overwrites by using consistent label "conv:history".
fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "") { return "" }
if str_eq(hist, "[]") { return "" }
// Issue #6 (PARTIAL-WRITE GUARD): refuse to persist a blob that is not a complete JSON
// array. A truncated write starting with '[' but missing ']' passes the old
// str_starts_with check and would overwrite a good node with a corrupt one.
if !str_starts_with(hist, "[") { return "" }
if !str_contains(hist, "]") { return "" }
let ts: Int = time_now()
let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full(
let discard: 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
)
// Issue #2 (SILENT FAILURE): surface write failures in logs rather than dropping silently.
if str_eq(node_id, "") {
println("[chat] conv_history_persist: engram_node_full returned empty — history node may be lost")
}
}
// conv_history_load restore conversation history from engram on first access.
//
// Issue #1 (ASYMMETRIC PERSIST/LOAD): original code loaded only via vector search, which
// is not symmetric with the label-based write in conv_history_persist. A cold or corrupt
// vector index returns [] even when the node exists on disk. Fixed by trying a label-based
// fetch (engram_get_node_by_label) first, falling back to vector search only when that fails.
//
// Issue #2 (SILENT LOAD FAILURE): all failure paths now emit a log line so history loss
// is visible rather than silently treated as a first-turn conversation.
//
// Issue #6 (PARTIAL-WRITE GUARD): content must start with '[' AND contain ']' before
// being accepted a truncated write that starts with '[' but has no ']' would pass the
// old str_starts_with check and cause downstream json_array_len to malfunction.
// Returns the most recent "conv:history" node content, or "" if none found.
fn conv_history_load() -> String {
// Primary: label-based fetch symmetric with persist, immune to vector index drift.
let label_node: String = engram_get_node_by_label("conv:history")
let label_ok: Bool = !str_eq(label_node, "") && !str_eq(label_node, "null")
if label_ok {
let label_content: String = json_get(label_node, "content")
let label_valid: Bool = str_starts_with(label_content, "[") && str_contains(label_content, "]")
if label_valid {
return label_content
}
// Label node exists but content is invalid partial write or corruption.
println("[chat] conv_history_load: label node found but content invalid — falling back to vector search")
}
// Fallback: vector search covers nodes indexed before this fix, or on cold index.
let results: String = engram_search_json("conv:history", 3)
if str_eq(results, "") { return "" }
if str_eq(results, "[]") { return "" }
let node: String = json_array_get(results, 0)
let content: String = json_get(node, "content")
// Issue #6: full partial-write guard require both '[' prefix AND ']' presence.
if !str_starts_with(content, "[") || !str_contains(content, "]") {
println("[chat] conv_history_load: vector search result content invalid — treating as first turn")
return ""
}
// Validate it looks like a JSON array
if !str_starts_with(content, "[") { return "" }
return content
}
@@ -221,13 +157,6 @@ fn handle_chat(body: String) -> String {
}
// Load history BEFORE compiling context so we can anchor activation to the thread.
// Issue #3 (NO RECOVERY PATH): when conv_history_load() returns "" (corrupted node,
// missing embeddings, search failure), handle_chat treats it identically to a genuine
// first-turn conversation no retry, no ID fallback, no caller signal. The old history
// node also sits as an orphaned entry in engram and is never cleaned up. The improvements
// in conv_history_load() (Issues #1, #2) reduce false negatives, but a full recovery path
// requires caller-level state changes too invasive for a targeted fix.
// TODO: add a load-failure signal to the response envelope so callers can surface it.
let state_hist: String = state_get("conv_history")
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
@@ -257,11 +186,8 @@ fn handle_chat(body: String) -> String {
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
// Safety augmentation on the main chat path. Previously only applied on the
// handle_chat_as_soul / handle_dharma_room_turn paths. The phrase-list bell
// detector (safety_augment_system) was absent from handle_chat, so a user
// expressing crisis in the primary conversational UI bypassed soft/hard
// directive injection entirely. Applying it here before every llm_call_system.
// ISSUE 9: add safety_augment_system to primary /api/chat path.
// handle_chat was the only LLM path missing bell directive injection.
let full_system = safety_augment_system(full_system, message)
let raw_response: String = llm_call_system(model, full_system, message)
@@ -278,11 +204,6 @@ fn handle_chat(body: String) -> String {
let updated_hist: String = hist_append(stored_hist, "user", message)
let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response)
// Issue #8 (NO MAX SIZE GUARD): the 20-turn count limit bounds entry count, but individual
// messages can be arbitrarily large (up to max_tokens = 4096 tokens each). At 20 turns the
// history blob can reach ~80KB before trim fires. engram_node_full has no apparent size cap.
// A byte-length cap would require truncating or summarising entries too invasive here.
// TODO: add a byte-length cap (e.g. 32KB) that drops oldest entries until under limit.
let final_hist: String = if json_array_len(updated_hist2) > 20 {
hist_trim(updated_hist2)
} else {
@@ -592,17 +513,12 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
let path: String = json_get(tool_input, "path")
let old_text: String = json_get(tool_input, "old_text")
let new_text: String = json_get(tool_input, "new_text")
let root: String = agent_workspace_root()
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
let resolved: String = resolve_in_root(path, root)
let content: String = fs_read(resolved)
let content: String = fs_read(path)
if str_eq(content, "") {
return json_safe("{\"error\":\"file not found\"}")
}
let updated: String = str_replace(content, old_text, new_text)
fs_write(resolved, updated)
fs_write(path, updated)
return json_safe("{\"ok\":true}")
}
if str_eq(tool_name, "remember") {
@@ -763,23 +679,12 @@ fn handle_chat_agentic(body: String) -> String {
// Persist the exchange to session/global history for thread continuity on next turn.
// Only save when the loop completed (reply present), not when tool_pending.
//
// Issue #9 (AGENTIC HISTORY NOT PERSISTED): the agentic path previously only saved
// history to in-process state (state_set), which is lost on restart. We now also call
// conv_history_persist() for the default session (hist_key == "conv_history") so agentic
// history survives restarts the same way non-agentic history does. Per-session histories
// (session_hist_<id>) are still in-process only persisting all named sessions would
// require per-session engram labels, a larger change tracked separately.
let reply_text: String = json_get(result, "reply")
let discard_hist: Bool = if !str_eq(reply_text, "") {
let updated: String = hist_append(agentic_hist, "user", message)
let updated2: String = hist_append(updated, "assistant", reply_text)
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
state_set(hist_key, trimmed)
// Only persist the default global session to engram named sessions are ephemeral.
if str_eq(hist_key, "conv_history") {
conv_history_persist(trimmed)
}
true
} else { false }
@@ -1153,19 +1058,13 @@ 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 utterance_id: String = engram_node_full(
let discard_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 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 discard_save: String = engram_save(snap_path)
}
let safe_response: String = json_safe(clean_response)
+8 -4
View File
@@ -24,19 +24,23 @@ ENGRAM_DATA_DIR="$ENGRAM_DATA_DIR" \
ENGRAM_PID=$!
# Wait for engram to become healthy (up to 30s)
# Wait for engram to become healthy (up to 60s; GKE Autopilot cold starts can be slow)
echo "[entrypoint] waiting for engram..."
TRIES=0
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
TRIES=$((TRIES + 1))
if [ "$TRIES" -ge 30 ]; then
echo "[entrypoint] ERROR: engram did not become healthy after 30s" >&2
if [ "$TRIES" -ge 60 ]; then
echo "[entrypoint] ERROR: engram did not become healthy after 60s" >&2
kill "$ENGRAM_PID" 2>/dev/null || true
exit 1
fi
sleep 1
done
echo "[entrypoint] engram ready"
echo "[entrypoint] engram ready after ${TRIES}s"
# Tune EL HTTP runtime: reduce per-call timeout 60s->10s, connect timeout 3s.
export EL_HTTP_TIMEOUT_MS="${EL_HTTP_TIMEOUT_MS:-10000}"
export EL_HTTP_CONNECT_TIMEOUT_MS="${EL_HTTP_CONNECT_TIMEOUT_MS:-3000}"
# Start soul — it takes over as PID 1's foreground process.
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
+26 -3
View File
@@ -144,17 +144,22 @@ fn safety_screen(input: String, history: String) -> String {
if score >= soft {
let summary: String = str_slice(input, 0, 80)
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
// ISSUE 7 fix: escape tab chars in addition to backslash/quote/newline/CR.
// A tab in user input corrupts the JSON envelope and causes json_get to misparse.
let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n")
let safe_input: String = str_replace(e3, "\r", "\\r")
let e4: String = str_replace(e3, "\r", "\\r")
let safe_input: String = str_replace(e4, "\t", "\\t")
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
}
// ISSUE 7 fix: escape tab chars (see soft_bell branch above for rationale).
let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n")
let safe_input: String = str_replace(e3, "\r", "\\r")
let e4: String = str_replace(e3, "\r", "\\r")
let safe_input: String = str_replace(e4, "\t", "\\t")
return "{\"action\":\"pass\",\"content\":\"" + safe_input + "\"}"
}
@@ -195,7 +200,11 @@ fn safety_validate(output: String, action: String) -> String {
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
let discard: String = engram_node_full(
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed.
// Emit a fallback println so the bell event leaves at least a log trace even
// when engram is degraded. This does not replace engram persistence -- it is a
// last-resort audit trail when the primary write cannot be confirmed.
let node_id: String = engram_node_full(
content,
"BellEvent",
"bell:" + level,
@@ -205,6 +214,9 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
"Episodic",
tags
)
if str_eq(node_id, "") {
println("[safety] WARN: bell event engram write failed -- fallback log: " + content)
}
return ""
}
@@ -235,6 +247,17 @@ fn safety_soft_phrases() -> String {
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\"]"
}
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
// safety_any_match and safety_count_match loop over json_array_get on every invocation.
// A compiled/cached representation would reduce per-message overhead and also guard against
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks).
// Caching requires language-level static const arrays -- not available in current EL.
// When EL gains module-level const arrays, migrate phrase lists to that form.
//
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
// safety_any_match / safety_count_match. json_array_len of a malformed string
// returns 0, silently skipping all checks. Caching requires language-level static
// const arrays (not available in current EL). Migrate when EL gains that feature.
// Matching helpers (single loops only el escapes while-body mutation via
// top-level let rebinds; nested loops would not advance) ────────────────────
+46 -10
View File
@@ -5,13 +5,9 @@ import "stewardship.el"
import "imprint.el"
import "awareness.el"
import "chat.el"
import "safety.el"
import "studio.el"
import "elp-input.el"
import "routes.el"
import "safety.el"
import "stewardship.el"
import "imprint.el"
cgi "neuron-soul" {
dharma_id: "ntn-genesis@http://localhost:7770",
@@ -265,19 +261,32 @@ fn layered_cycle(raw_input: String) -> String {
let screen_result: String = safety_screen(raw_input, history)
let screen_action: String = json_get(screen_result, "action")
// ISSUE 4: safe-mode guard -- if safety_screen returned invalid/empty action,
// refuse the turn rather than silently passing unscreened input to upper layers.
// Valid actions: "hard_bell", "soft_bell", "pass". Anything else = corrupt envelope.
let valid_action: Bool = str_eq(screen_action, "hard_bell")
|| str_eq(screen_action, "soft_bell")
|| str_eq(screen_action, "pass")
if !valid_action {
println("[soul] layered_cycle: safety_screen invalid action -- safe mode refusal")
return safety_validate("", "hard_bell")
}
// Hard bell: bypass all upper layers, log and escalate.
// Intentionally does NOT update conversation_history or call auto_persist():
// hard bell events are security-sensitive and must not appear in engram conversation
// history where they could leak context to subsequent turns. They are persisted
// separately by safety_log_bell() into the Episodic tier with restricted labels.
//
// ISSUE 6: safety_log_bell for hard bells is already called INSIDE safety_screen
// (safety.el line 140). Do NOT call it again here -- double-log avoided.
//
// safety_validate second param: when screen_action is "hard_bell", safety_validate
// receives the sentinel string "hard_bell" (not a normal screen action). The safety
// layer contract requires it to return a fixed refusal regardless of the output arg.
// On the normal path, safety_validate receives the original screen_action ("pass")
// so it can apply action-specific post-output checks.
if str_eq(screen_action, "hard_bell") {
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(raw_input, 0, 80))
return safety_validate("", "hard_bell")
}
@@ -312,6 +321,16 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to")
}
// ISSUE 1: apply pre-LLM bell augmentation on layered_cycle path.
// safety_augment_system injects soft/hard directive into system prompt before LLM call.
// Stored in state so imprint_respond can consume it.
// TODO: wire directly into imprint_respond when it accepts a system_override param.
// ISSUE 3 TODO: no semantic/embedding crisis detection. Keyword-only means signals
// evading the phrase list pass through with zero augmentation. Semantic layer is a
// separate architectural decision requiring embedding inference on every message.
let augmented_addendum: String = safety_augment_system("", raw_input)
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
// L3: imprint responds
let output: String = imprint_respond(aligned, imprint_id)
@@ -351,12 +370,29 @@ let snapshot_usable: Bool = local_node_count > 50
if using_http_engram && !snapshot_usable {
// First boot or empty/corrupt snapshot: seed from HTTP Engram.
// Retry up to 3 times (2s sleep between attempts) to guard against a
// transient network hiccup right after entrypoint.sh health check passes.
// An empty nodes response silently loads a zero-node graph; validate first.
// TODO(reliability): replace sleep_ms retry with non-blocking backoff.
println("[soul] engram -> HTTP " + engram_url_raw + " (no local snapshot, first boot)")
let nodes_json: String = http_get(engram_url_raw + "/api/nodes?limit=10000")
let edges_json: String = http_get(engram_url_raw + "/api/edges")
let nodes_part: String = if str_eq(nodes_json, "") { "[]" } else { nodes_json }
let edges_part: String = if str_eq(edges_json, "") { "[]" } else { edges_json }
let snapshot_data: String = "{\"nodes\":" + nodes_part + ",\"edges\":" + edges_part + "}"
let fetch_attempt: Int = 0
while fetch_attempt < 3 {
let fetch_attempt = fetch_attempt + 1
let n: String = http_get(engram_url_raw + "/api/nodes?limit=10000")
let e: String = http_get(engram_url_raw + "/api/edges")
let nodes_ok: Bool = !str_eq(n, "") && str_starts_with(n, "[") && str_len(n) > 2
if nodes_ok {
state_set("_boot_nodes_json", n)
state_set("_boot_edges_json", e)
let fetch_attempt = 3
} else {
println("[soul] boot HTTP fetch attempt " + int_to_str(fetch_attempt) + " failed --- retrying in 2s")
sleep_ms(2000)
}
}
let nodes_json: String = state_get("_boot_nodes_json")
let edges_json: String = state_get("_boot_edges_json")
let snapshot_data: String = "{\"nodes\":" + nodes_part + ",\"edges\":" + edges_part + "}"
let tmp_path: String = "/tmp/soul-engram-" + soul_cgi_id + ".json"
fs_write(tmp_path, snapshot_data)
engram_load(tmp_path)