feat: dream-cycle time budget + default per-call timeout (guard C)
Belt-and-suspenders so no dream pass can run unchecked for hours: - llm.complete() now always bounds the OpenAI/mi50 request: default 300s + max_retries=0 instead of the SDK's 600s x2 (~30 min). One change bounds every consolidation/introspection call (profile/era/narrative/reflect/think), not just summaries. Live chat (chat_call*) is a separate path, unaffected. - dream_cycle() enforces a 20-min wall-clock budget, checked between stages; once past it, remaining stages are skipped, it logs 'stopped early (over budget)', and notify.push() pings Brian. Paired with the host watchdog (A) as an independent fallback. Tests: default timeout/max_retries threaded into complete(); an over-budget pass skips later stages + pings. 178 pass, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
This commit is contained in:
+30
-4
@@ -26,7 +26,8 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import (
|
||||
config, era, feeds, logbus, memory, narrative, poker, profile, self_state, summary, thoughts,
|
||||
config, era, feeds, logbus, memory, narrative, notify, poker, profile, self_state,
|
||||
summary, thoughts,
|
||||
)
|
||||
from lyra.llm import Backend
|
||||
from lyra.summary import SUMMARIZE_AFTER
|
||||
@@ -34,6 +35,17 @@ from lyra.summary import SUMMARIZE_AFTER
|
||||
# A drive at/above this has built up enough to act on.
|
||||
THRESHOLD = 0.6
|
||||
|
||||
# Wall-clock ceiling for a single pass. Every consolidation/introspection call is
|
||||
# individually bounded (llm.complete's default timeout), but this caps the whole
|
||||
# pass: once exceeded, remaining stages are skipped and Brian is pinged — so a slow
|
||||
# or wedged MI50 can never grind for hours unattended. The host watchdog (A) is the
|
||||
# independent fallback if this ever fails to fire.
|
||||
DREAM_CYCLE_BUDGET_SEC = 20 * 60
|
||||
|
||||
|
||||
def _over_budget(deadline: float) -> bool:
|
||||
return time.monotonic() > deadline
|
||||
|
||||
# How much backlog saturates each pressure (the drive reaches ~1.0 at this level).
|
||||
CONTINUITY_FULL = 4 # ripe (summary-needing) sessions
|
||||
COHERENCE_FULL = 10 # gists not yet folded into the profile
|
||||
@@ -96,9 +108,12 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
logbus.log("error", "daily digest failed", error=str(exc)[:160])
|
||||
|
||||
actions: list[str] = []
|
||||
# Cap the whole pass: skip any stage we reach after the deadline (checked
|
||||
# between stages; each call is already individually bounded).
|
||||
deadline = time.monotonic() + DREAM_CYCLE_BUDGET_SEC
|
||||
|
||||
# --- continuity: compact raw sessions into gists ---
|
||||
if force or drives["continuity"] >= THRESHOLD:
|
||||
if (force or drives["continuity"] >= THRESHOLD) and not _over_budget(deadline):
|
||||
report = summary.summarize_all(backend=backend)
|
||||
actions.append(f"consolidated {report['summarized']} sessions")
|
||||
drives["continuity"] = 0.0
|
||||
@@ -108,7 +123,7 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL)
|
||||
|
||||
# --- coherence: fold gists up into profile / eras / narrative ---
|
||||
if force or drives["coherence"] >= THRESHOLD:
|
||||
if (force or drives["coherence"] >= THRESHOLD) and not _over_budget(deadline):
|
||||
profile.rebuild_profile(backend=backend)
|
||||
era.rebuild_eras(backend=backend)
|
||||
narrative.rebuild_narrative(backend=backend)
|
||||
@@ -124,7 +139,7 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
|
||||
|
||||
# --- curiosity: reflect and evolve the self, then advance the thought loop ---
|
||||
if force or drives["curiosity"] >= THRESHOLD:
|
||||
if (force or drives["curiosity"] >= THRESHOLD) and not _over_budget(deadline):
|
||||
# reflect()/think() self-resolve to the *introspection* backend (her voice),
|
||||
# which can differ from the consolidation backend above — don't pass `backend`.
|
||||
self_state.reflect(source="dream") # writes state + journal itself
|
||||
@@ -139,6 +154,17 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
logbus.log("error", "thought loop failed", error=str(exc)[:200])
|
||||
drives["curiosity"] = CURIOSITY_FLOOR
|
||||
|
||||
if _over_budget(deadline):
|
||||
logbus.log("error", "dream cycle over budget — stopped early",
|
||||
budget_min=DREAM_CYCLE_BUDGET_SEC // 60, done=actions)
|
||||
actions.append("stopped early (over budget)")
|
||||
notify.push(
|
||||
"Lyra — dream cycle over budget",
|
||||
f"A dream pass ran past {DREAM_CYCLE_BUDGET_SEC // 60} min and stopped early. "
|
||||
"The MI50 backend may be slow or wedged — worth a look.",
|
||||
tags="warning",
|
||||
)
|
||||
|
||||
if not actions:
|
||||
actions.append("rested (nothing past threshold)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user