Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 38 additions & 27 deletions hindsight-all/hindsight/embedded.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,37 @@ def __init__(
self._memories_api: Optional[MemoriesAPI] = None

def _ensure_started(self):
"""Ensure daemon is running (thread-safe)."""
"""Ensure daemon is running (thread-safe), restarting if crashed."""
if self._started and self._client is not None:
return
if self._manager.is_running(self.profile):
return
# Daemon crashed — reset state and fall through to restart
logger.warning(
"Daemon for profile '%s' is no longer responsive, restarting...",
self.profile,
)
try:
self._client.close()
except Exception:
logger.debug("Error closing stale client", exc_info=True)
self._client = None
self._started = False

with self._lock:
# Double-check after acquiring lock
if self._started and self._client is not None:
return
if self._manager.is_running(self.profile):
return
logger.warning(
"Daemon for profile '%s' is no longer responsive (lock path), restarting...",
self.profile,
)
try:
self._client.close()
except Exception:
logger.debug("Error closing stale client", exc_info=True)
self._client = None
self._started = False

if self._closed:
raise RuntimeError(
Expand Down Expand Up @@ -253,23 +276,10 @@ def __getattr__(self, name: str):
This allows HindsightEmbedded to expose all HindsightClient methods
without manually wrapping each one.
"""
# Ensure server is started before proxying
# Ensure server is started (and restart if crashed) before proxying
self._ensure_started()

# Get the attribute from the underlying client
attr = getattr(self._client, name)

# If it's a callable, wrap it to ensure server is started
# (shouldn't be needed since _ensure_started already called, but defensive)
if callable(attr):

def wrapper(*args, **kwargs):
self._ensure_started()
return attr(*args, **kwargs)

return wrapper

return attr
return getattr(self._client, name)

def __enter__(self):
"""Context manager entry - ensures server is started."""
Expand Down Expand Up @@ -394,11 +404,8 @@ def client(self) -> Hindsight:
"""
Get the underlying Hindsight client for direct access.

WARNING: Using this property directly means daemon restarts won't be
handled automatically. Prefer using the API namespaces (banks, mental_models,
directives, memories) or direct method calls on HindsightEmbedded instead.

Ensures daemon is started before returning the client.
Ensures daemon is started (and restarts it if it has crashed) before
returning the client.

Returns:
Hindsight: The underlying client instance
Expand All @@ -409,9 +416,8 @@ def client(self) -> Hindsight:

embedded = HindsightEmbedded(profile="myapp", ...)

# Direct access (not recommended - daemon crashes won't be handled)
client = embedded.client
banks = client.list_banks() # If daemon crashes, this will fail
banks = client.list_banks()
```
"""
self._ensure_started()
Expand All @@ -425,8 +431,13 @@ def url(self) -> str:

@property
def is_running(self) -> bool:
"""Check if the client is initialized."""
return self._started and not self._closed and self._client is not None
"""Check if the client is initialized and the daemon is responsive."""
return (
self._started
and not self._closed
and self._client is not None
and self._manager.is_running(self.profile)
)

@property
def ui_url(self) -> str:
Expand Down
39 changes: 39 additions & 0 deletions hindsight-all/tests/test_embedded.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,42 @@ def test_embedded_ui_flag(llm_config):

finally:
client.close()


def test_embedded_daemon_crash_recovery(llm_config):
"""
Test that HindsightEmbedded recovers when the daemon crashes.

Simulates a crash by stopping the daemon, then verifies
that the next operation transparently restarts it.
"""
profile = f"test_crash_{uuid.uuid4().hex[:8]}"
bank_id = f"bank_{uuid.uuid4().hex[:8]}"

client = HindsightEmbedded(profile=profile, log_level="info", **llm_config)

try:
# Start daemon and store a memory
result = client.retain(bank_id=bank_id, content="Before crash")
assert result.success, "Initial retain should succeed"
assert client.is_running, "Daemon should be running"

original_url = client.url

# Simulate daemon crash by stopping it
client._manager.stop(client.profile)
assert not client._manager.is_running(client.profile), (
"Daemon should be stopped after simulated crash"
)

# Next operation should transparently restart the daemon
result2 = client.retain(bank_id=bank_id, content="After crash recovery")
assert result2.success, "Retain after crash recovery should succeed"
assert client.is_running, "Daemon should be running again after recovery"

# Verify recall still works
recall_result = client.recall(bank_id=bank_id, query="crash")
assert isinstance(recall_result.results, list), "Recall should return results"

finally:
client.close()