111 lines
4.8 KiB
Python
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()
|