Skip to content

fix(photon): persist send and reaction state - #100

Open
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-56006
Open

fix(photon): persist send and reaction state#100
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-56006

Conversation

@hashbender

Copy link
Copy Markdown
Owner

What does this PR do?

Photon can now survive adapter restarts without forgetting the messages it sent, the latest inbound target per chat, or active reaction IDs needed for later removal. This makes send/reaction handling durable across the sidecar and standalone send paths, and exposes a small hermes photon status state summary so operators can see whether local state is healthy.

The change also lets deferred bundled platform CLIs load when their matching top-level command is invoked, so hermes photon ... can resolve the Photon plugin CLI without eagerly loading every bundled platform.

Related Issue

Related to NousResearch#43726, NousResearch#53451, and NousResearch#55105.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • Add bounded local Photon state for send audit records, sent message IDs, inbound targets, and active reactions.
  • Persist sidecar and standalone text/attachment sends, including failure records, so status can report recent state problems.
  • Persist returned reaction IDs and use them for /unreact, including DM alias fallback and restart recovery.
  • Ignore synthetic inbound reaction lifecycle events so Hermes does not react to reaction notifications.
  • Show Photon state health/counts in hermes photon status.
  • Load deferred bundled platform plugin CLIs when their matching command is invoked.

How to Test

  1. scripts/run_tests.sh tests/plugins/platforms/photon tests/hermes_cli/test_plugins.py::TestPluginDiscovery::test_deferred_bundled_platform_cli_loads_on_matching_command -q
  2. git diff --check
  3. python -m py_compile plugins/platforms/photon/adapter.py plugins/platforms/photon/state.py plugins/platforms/photon/cli.py hermes_cli/plugins.py hermes_cli/main.py
  4. node --check plugins/platforms/photon/sidecar/index.mjs
  5. uv run hermes photon --help

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Not applicable; this is Photon state/reaction persistence and CLI loading hardening.


Mirror-of: NousResearch#56006
NousResearch#56006

@tenki-reviewer

tenki-reviewer Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 11
Findings: 3

By Severity:

  • 🟡 Medium: 2
  • 🟢 Low: 1

The PR adds Photon platform state persistence and deferred plugin loading. Three issues found: a schema version strict-equality check silently discards state on any version bump, two PhotonStateStore instances can race on the shared state file, and a dead write_error code path in the status command.

Files Reviewed (11 files)
hermes_cli/main.py
hermes_cli/plugins.py
plugins/platforms/photon/adapter.py
plugins/platforms/photon/cli.py
plugins/platforms/photon/state.py
tests/hermes_cli/test_plugins.py
tests/plugins/platforms/photon/test_markdown.py
tests/plugins/platforms/photon/test_outbound_media.py
tests/plugins/platforms/photon/test_reactions.py
tests/plugins/platforms/photon/test_state.py
tests/plugins/platforms/photon/test_status.py

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: 🟡 Medium (48/100) — 2 medium findings, 1 low · 1142 LOC across 11 files


Summary

This PR introduces persistent state tracking for the Photon messaging platform adapter (PhotonStateStore) and adds deferred plugin loading in hermes_cli/plugins.py. The state store manages sent-message IDs, reaction handles, inbound trackers, and audit records that survive adapter restarts.

Findings

finding-001 (Medium): Schema version strict-equality check silently discards all state on any version bump

state.py:99-100 uses if payload.get('schema_version') != SCHEMA_VERSION: raise ValueError. The exception is caught and resets to _empty_state(), silently discarding all persisted correlation data. A future bump of the SCHEMA_VERSION constant from 1→2 would wipe every user's state file with no migration path.

finding-002 (Medium): Two PhotonStateStore instances race on shared state file

Both __init__ (line 267) and _standalone_send (line 1771) create separate PhotonStateStore() instances pointing to the same photon_state_path() file. Concurrent load-modify-write cycles can interleave, producing classic lost-update races that silently drop audit records, sent-message tracking, and reaction state.

finding-003 (Low): Dead code: write_error display in photon status never triggers

cli.py:333 checks health.get('write_error') but write_error is never persisted to the state file — it is only an instance attribute set during _persist() failures. The status command creates a fresh store and calls load(), so write_error is always None and the guard is dead code.

Assessment

No critical or high-severity issues. The two medium findings are real bugs that would manifest on version upgrades and under gateway concurrency. Recommended to fix before merging to avoid silent data loss paths.

Comment on lines +99 to +100
if payload.get("schema_version") != SCHEMA_VERSION:
raise ValueError("unsupported schema version")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Schema version strict-equality check silently discards all state on any version bump (bug)

PhotonStateStore.load() at state.py:99-100 uses if payload.get('schema_version') != SCHEMA_VERSION: raise ValueError('unsupported schema version'). The exception is caught at line 102, which logs a warning and resets to _empty_state() at line 109 — silently discarding all persistent correlation data. Because SCHEMA_VERSION is a module-level constant (line 21, currently 1), any future bump from 1→2 causes every existing user's state file to fail the check and be wiped. The adapter depends on this state for reaction rehydration (adapter.py:287-305 _hydrate_persistent_state), and the loss breaks cross-restart reaction correlation.

💡 Suggestion: Replace the strict-equality check with a version-range gate. At minimum, accept schema_version <= SCHEMA_VERSION (backward-compatible read). Better: add an _UPGRADERS dict mapping old→new version transformers, called in load() before _normalize(). For forward-compatibility (downgrade scenario), accept schema_version > SCHEMA_VERSION with a warning rather than data loss.

📋 Prompt for AI Agents

In plugins/platforms/photon/state.py, replace line 99 (if payload.get('schema_version') != SCHEMA_VERSION: raise ValueError('unsupported schema version')) with a version-range check. Accept schema versions 1 through SCHEMA_VERSION (i.e., >= 1 and <= SCHEMA_VERSION). If stored version < current, run a migration transformer before _normalize(). If stored version > current, log a warning but proceed without discarding state.

Comment on lines +1771 to +1772
state = PhotonStateStore()
state.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two PhotonStateStore instances race on shared state file, risking lost updates (bug)

The adapter's __init__ creates self._photon_state = PhotonStateStore() at line 267, and _standalone_send creates its own state = PhotonStateStore() at line 1771. Both resolve to the same photon_state_path() file via get_hermes_home(). Each instance loads the file into its own in-memory dict, modifies it independently, and persists — the last writer silently overwrites the other's changes. In a gateway process, send_message (which invokes _standalone_send via the standalone_sender_fn registry) can interleave with adapter-driven state updates at await points, creating a lost-update race. Audit records, sent-message tracking, and reaction state can be lost.

💡 Suggestion: Share a single PhotonStateStore instance between the adapter and _standalone_send. Options: (a) pass the adapter's self._photon_state into the standalone send function via a closure or module-level reference; (b) make _standalone_send accept an optional state_store parameter; (c) add file-level advisory locking (fcntl.flock) around load-modify-write sequences.

📋 Prompt for AI Agents

In plugins/platforms/photon/adapter.py, refactor _standalone_send to use a shared PhotonStateStore rather than creating its own (line 1771). The simplest fix: accept an optional state_store parameter and pass self._photon_state from the adapter's standalone_sender_fn registration (around line 1924). Alternatively, add a module-level _shared_state_store singleton initialized on first use, and guard load-modify-write cycles with threading.Lock or fcntl.flock to serialize concurrent access.

Comment on lines +333 to +334
if health.get("write_error"):
emit(f" state write failure : {health['write_error']}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Dead code: write_error display in photon status never triggers (bug)

_print_state_summary() in plugins/platforms/photon/cli.py (line 321) creates a fresh PhotonStateStore() at line 324, calls store.load(), and then checks health.get('write_error') at line 333. However, write_error is an instance attribute initialized to None (state.py:75) and only set during _persist() failures (state.py:266). It is never persisted to the state file and never restored in load(). Since _print_state_summary never triggers _persist(), write_error is always None, making the if health.get('write_error'): guard at cli.py:333 always False. The 'state write failure' message at cli.py:334 can never be emitted.

💡 Suggestion: Either persist write_error to the state file so load() can restore it (add to _empty_state(), _normalize(), and load()), or remove the dead write_error check if write errors are only meant to be surfaced within the active adapter instance's lifecycle.

📋 Prompt for AI Agents

In plugins/platforms/photon/state.py, modify _persist() (line 253) to store self.write_error into self._state['write_error'] before writing; update _empty_state() (line 78) to include 'write_error': None; update _normalize() (line 269) to preserve the 'write_error' key from the payload; and update load() (line 89) to restore self.write_error from the loaded state. This makes write errors visible to fresh store instances like the status command.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant