Files
2026-07-13 13:12:00 +08:00

77 lines
3.6 KiB
Python

"""Turn-completion ("idle") poster state for the hermes-native harness.
The completion->parent-wake path needs a harness to POST an
``external_session_status: idle`` event to the Sessions API: the server maps
that edge to a sub-agent turn-terminal and wakes the parent orchestrator's
inbox (the SAME contract claude-/codex-/opencode-/cursor-native use). Hermes'
PTY-activity watcher only emits a ``session.status: idle`` SSE edge that drives
the web "Working…" spinner and never wakes a parent — so without an explicit
post a hermes-native sub-agent finishes silently and the orchestrator hangs.
Unlike cursor-agent, hermes-agent exposes NO per-turn ``stop`` hook (only a
``pre_tool_call`` hook, used for policy enforcement), so there is no separate
process writing a turn-end marker. Instead the runner-owned
:func:`omnigent.hermes_native_forwarder.forward_hermes_store_to_session` poll
loop *derives* turn completion from Hermes' ``state.db`` itself — an
``assistant`` row with no ``tool_calls`` is the agentic loop's terminal step,
i.e. one completed turn (see ``_count_completed_turns`` in that module). The
``messages`` table is the append-only "marker store"; this module owns only the
*poster* state: how many of those completed turns have already been turned into
an ``external_session_status: idle`` post.
It is the hermes analog of the poster-state half of
:mod:`omnigent.cursor_native_status`. Persisting the posted-count means a
supervisor restart never re-wakes the parent for a turn it already reported.
Stdlib-only (no httpx) so it stays a cheap, dependency-free state file.
"""
from __future__ import annotations
import contextlib
import json
import os
from pathlib import Path
#: Durable poster state: how many completed turns the forwarder has already
#: turned into an ``external_session_status: idle`` post. Persisted so a
#: supervisor restart never re-wakes the parent for a turn it already reported.
_STATE_FILE = "hermes_status_forwarder.json"
def read_posted_count(bridge_dir: Path) -> int:
"""Load the count of completed turns already POSTed as idle (0 on cold/unreadable)."""
try:
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return 0
posted = data.get("posted") if isinstance(data, dict) else None
return posted if isinstance(posted, int) and posted >= 0 else 0
def write_posted_count(bridge_dir: Path, posted: int) -> None:
"""Atomically persist the count of completed turns already POSTed as idle.
Persisted only AFTER a successful idle POST so a failed flush is retried (the
unreported turns stay unreported until the post lands).
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
tmp = bridge_dir / (_STATE_FILE + ".tmp")
tmp.write_text(json.dumps({"posted": posted}), encoding="utf-8")
os.replace(tmp, bridge_dir / _STATE_FILE)
def clear_hermes_status_state(bridge_dir: Path) -> None:
"""Remove the idle poster state so a re-created terminal starts clean.
Sibling of :func:`omnigent.hermes_native_forwarder.clear_hermes_bridge_state`:
the runner calls this when it re-creates a hermes terminal so a stale
posted-count from a prior terminal can't make the new forwarder skip (or
re-fire) the ``external_session_status: idle`` parent-wake edge. The
completed-turn count is derived per ``hermes_session_id`` (not per terminal),
so after an in-session compaction re-pins to a forked child the forwarder
rebases this posted-count to the child's current count; only the poster state
is persisted here, so only it needs clearing.
"""
with contextlib.suppress(OSError):
(bridge_dir / _STATE_FILE).unlink()