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

111 lines
4.8 KiB
Python

"""Turn-completion ("idle") signal for the cursor-native harness.
cursor-agent fires its ``stop`` hook once per completed turn (see
:func:`omnigent.cursor_native_bridge.build_hooks_config`, which registers the
:mod:`omnigent.cursor_native_usage` recorder). That hook is the ONLY
authoritative "this turn just finished" signal cursor-agent exposes: its SQLite
chat store (tailed by :mod:`omnigent.cursor_native_forwarder`) carries no
turn-boundary marker, and the runner's PTY-activity watcher only drives the web
"Working…" spinner (a ``session.status`` edge) — which never wakes a parent
orchestrator.
So the stop hook ALSO appends a turn-end marker line here (alongside its usage
line), and the runner-owned
:func:`omnigent.cursor_native_forwarder.forward_cursor_store_to_session` poll
loop tails it and POSTs an ``external_session_status: idle`` event — the SAME
server contract claude-/codex-/opencode-native use to mark a sub-agent turn
terminal and wake its parent's inbox. Without this a cursor-native sub-agent
finished silently and never notified the orchestrator.
Stdlib-only (no httpx) so importing it from the stop hook keeps the hook fast —
cursor blocks the turn end on the hook.
"""
from __future__ import annotations
import contextlib
import json
import os
import time
from pathlib import Path
#: Append-only log of per-turn completion markers written by the cursor ``stop``
#: hook (one JSON object per completed turn) and tailed by the forwarder.
TURN_END_FILE = "cursor_turn_end.jsonl"
#: Durable poster state: how many turn-end markers 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 = "cursor_status_forwarder.json"
def record_turn_end(bridge_dir: Path, payload: object | None = None) -> None:
"""Append one turn-completion marker (called from the cursor ``stop`` hook).
Fires on EVERY completed turn — including turns with no billable token usage,
which :func:`omnigent.cursor_native_usage.record_usage_payload` skips — so the
parent wake never depends on a turn having produced usage. Best-effort and
stdlib-only; the caller swallows failures so usage/idle capture never breaks
the agent turn.
:param bridge_dir: The cursor-native bridge dir (where the forwarder reads).
:param payload: The cursor ``stop`` hook payload, if any — its
``generation_id`` is recorded for traceability (not required).
"""
line: dict[str, object] = {"ts": time.time()}
if isinstance(payload, dict):
gen_id = payload.get("generation_id") or payload.get("conversation_id")
if isinstance(gen_id, str) and gen_id:
line["generation_id"] = gen_id
bridge_dir.mkdir(parents=True, exist_ok=True)
# O_APPEND keeps a fast-firing hook's short JSON line from interleaving.
with open(bridge_dir / TURN_END_FILE, "a", encoding="utf-8") as handle:
handle.write(json.dumps(line, sort_keys=True) + "\n")
def count_turn_ends(bridge_dir: Path) -> int:
"""Return how many turn-end markers have been recorded (0 if none/unreadable).
The marker file is append-only, so the line count is the number of turns that
have completed since the terminal was (re-)created.
"""
try:
text = (bridge_dir / TURN_END_FILE).read_text(encoding="utf-8")
except OSError:
return 0
return sum(1 for raw in text.splitlines() if raw.strip())
def read_posted_count(bridge_dir: Path) -> int:
"""Load the count of turn-ends 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 turn-ends already POSTed as idle.
Persisted only AFTER a successful idle POST so a failed flush is retried (the
unreported turn-ends 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_cursor_status_state(bridge_dir: Path) -> None:
"""Remove the turn-end marker + poster state so a re-created terminal starts clean.
Sibling of :func:`omnigent.cursor_native_usage.clear_cursor_usage_state`: the
runner calls this when it re-creates a cursor terminal so a stale marker count
from a prior terminal can't make the new forwarder skip (or re-fire) idle.
"""
for name in (TURN_END_FILE, _STATE_FILE):
with contextlib.suppress(OSError):
(bridge_dir / name).unlink()