Repository navigation
feat(plugins): on_session_archived hook for deliberate session archives - #132639
pieralukasz wants to merge 1 commit into
Conversation
Archiving a session is a common "this is done" signal, but plugins could only learn about it by polling state.db. Fire an observer hook after a deliberate archive commits, once per real unarchived -> archived transition: Desktop/dashboard PATCH, TUI session.archive (including deferred drafts) and hermes sessions archive. The idle auto-archive sweep, profile adoption, repeats and un-archives stay silent, and a failing callback never fails the archive.
|
…e archives Finn's state-layer hook missed the paths Desktop actually uses: single delete and archive go through the canonical mutation receipt (mutate_runtime_session), not SessionDB.delete_session, so a plugin never heard about either. Archive (the "this is done" signal #132639 asked for) fired nowhere. - mutate_runtime_session fires after its write commits for operation 'delete' and for a real unarchived -> archived sidebar flip; an exact retry replays the receipt and stays silent. - set_session_archived fires reason="archive" on a real flip, so TUI session.archive, `hermes sessions archive` and deferred TUI archives are covered from one place. Repeats, un-archives, the idle auto-archive sweep and profile adoption (notify=False) stay silent. - Payload is now session_id + reason + profile on every path (profile from the store's home), deleted_ids when more than one row went; primary id is the one the caller asked for, not the alphabetically first. - One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook directly; the extra notify_session_deleted wrapper is gone. - Tests trimmed to two invariants against the real hook registry. Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
…e archives Finn's state-layer hook missed the paths Desktop actually uses: single delete and archive go through the canonical mutation receipt (mutate_runtime_session), not SessionDB.delete_session, so a plugin never heard about either. Archive (the "this is done" signal #132639 asked for) fired nowhere. - mutate_runtime_session fires after its write commits for operation 'delete' and for a real unarchived -> archived sidebar flip; an exact retry replays the receipt and stays silent. - set_session_archived fires reason="archive" on a real flip, so TUI session.archive, `hermes sessions archive` and deferred TUI archives are covered from one place. Repeats, un-archives, the idle auto-archive sweep and profile adoption (notify=False) stay silent. - Payload is now session_id + reason + profile on every path (profile from the store's home), deleted_ids when more than one row went; primary id is the one the caller asked for, not the alphabetically first. - One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook directly; the extra notify_session_deleted wrapper is gone. - Tests trimmed to two invariants against the real hook registry. Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
…e archives Finn's state-layer hook missed the paths Desktop actually uses: single delete and archive go through the canonical mutation receipt (mutate_runtime_session), not SessionDB.delete_session, so a plugin never heard about either. Archive (the "this is done" signal #132639 asked for) fired nowhere. - mutate_runtime_session fires after its write commits for operation 'delete' and for a real unarchived -> archived sidebar flip; an exact retry replays the receipt and stays silent. - set_session_archived fires reason="archive" on a real flip, so TUI session.archive, `hermes sessions archive` and deferred TUI archives are covered from one place. Repeats, un-archives, the idle auto-archive sweep and profile adoption (notify=False) stay silent. - Payload is now session_id + reason + profile on every path (profile from the store's home), deleted_ids when more than one row went; primary id is the one the caller asked for, not the alphabetically first. - One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook directly; the extra notify_session_deleted wrapper is gone. - Tests trimmed to two invariants against the real hook registry. Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
|
Thanks @pieralukasz. This landed in #140995 (merged at 627ce03), folded into a single What carried over from your design:
The difference: there's one hook with a Closing as superseded. |
…e archives Finn's state-layer hook missed the paths Desktop actually uses: single delete and archive go through the canonical mutation receipt (mutate_runtime_session), not SessionDB.delete_session, so a plugin never heard about either. Archive (the "this is done" signal NousResearch#132639 asked for) fired nowhere. - mutate_runtime_session fires after its write commits for operation 'delete' and for a real unarchived -> archived sidebar flip; an exact retry replays the receipt and stays silent. - set_session_archived fires reason="archive" on a real flip, so TUI session.archive, `hermes sessions archive` and deferred TUI archives are covered from one place. Repeats, un-archives, the idle auto-archive sweep and profile adoption (notify=False) stay silent. - Payload is now session_id + reason + profile on every path (profile from the store's home), deleted_ids when more than one row went; primary id is the one the caller asked for, not the alphabetically first. - One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook directly; the extra notify_session_deleted wrapper is gone. - Tests trimmed to two invariants against the real hook registry. Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
What does this PR do?
Adds an
on_session_archivedobserver hook. It fires after a deliberate session archive commits, once per real unarchived → archived transition.Archiving is a common "this is done" signal. Concrete consumer: an automation that opens a Hermes session per to-do task (Todoist) and should complete the task when the user archives that session. Today such a plugin can only find out by polling
state.db(every few minutes in my setup). With the hook, the plugin can react immediately and needs no polling worker.Related Issue
No existing issue or PR found (searched PRs/issues for
on_session_archived,session archived hook,archive hook,session_archived).Type of Change
Changes Made
hermes_cli/plugins.py: registeron_session_archivedinVALID_HOOKS(observer).hermes_cli/lifecycle.py:archive_session(db, session_id, archived, *, surface, profile)wrapsSessionDB.set_session_archived. It reads the prior flag, writes, then fires the hook only when the row went from unarchived to archived.notify_session_archivednever raises.PATCH /api/sessions/{id}(hermes_cli/web_routers/sessions.py,surface="dashboard", passesbody.profile)session.archiveRPC (tui_gateway/methods_session.py,surface="tui") and the deferredpending_archivedpath in_ensure_session_db_row(tui_gateway/session_workdir.py)hermes sessions archive(hermes_cli/sessions_cmd.py,surface="cli", through a new optionalarchive=callback onSessionDB.archive_sessions; default behaviour unchanged)archive_stale_sessions/_auto_archive_lineage), profile adoption retiring donor segments, repeated archives, and un-archives. Those are housekeeping, not a user's decision.hermes_cli/hooks.py: synthetic payload forhermes hooks test on_session_archived.website/docs/user-guide/features/hooks.md, and the observer list inplugins.md.Payload:
session_id,surface("dashboard"|"tui"|"cli"),profile(Nonefor the serving profile).How to Test
tests/hermes_cli/test_session_archived_hook.pyruns against a realSessionDBunder the isolated testHERMES_HOME, with the real plugin hook registry:session.archivefires once (with a repeat).hermes sessions archivefires once per archived session.Without the source change, the new file gives 1 passed / 5 failed. Only the "sweep does not fire" case passes, as expected.
Broader run (
tests/tui_gateway,tests/agent/test_shell_hooks.py,tests/hermes_cli/test_{hooks,sessions,plugins,lifecycle}*,tests/test_hermes_state*): 2767 passed, 28 failed. Exactly the same 28 tests fail on unmodifiedmainin my local env (PM-freeuv syncvenv on macOS: subprocess/profile-listing tests). Compared withdiffof the FAILED sets, they are identical.Checklist
Code
pytest tests/ -qand all tests pass (targeted + broader suites above; full suite not run locally)