Adds OperatorCommandEvent + a closed command vocabulary (flatten_all, flatten_symbol, pause_entries, resume_entries). POST /api/command emits it (token-required: 503 when DASHBOARD_TOKEN unset — commands never run unauthenticated); the live loop's new 5s "command" step consumes them via OperatorCommandConsumer with three replay-safety layers (in-session nonce set, persisted operator_command_done reload, 10-min staleness window). flatten_all routes through close_session DIRECTLY (never step_eod_flatten) so it cannot trip plan 003's daily-restart exit. Pause is checked at the top of BOTH request_approvals_for_ready_signals AND submit_breakout_now (missing either leaks pause on that path). qabot gains an authorized-chat-only /flatten slash command (prose "flatten" still refuses via _ACTION_WORDS). Snapshot exposes broker truth + command_bus + a defensive experiment ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.8 KiB
Python
131 lines
5.8 KiB
Python
"""Operator command bus consumer (plan 007).
|
|
|
|
The dashboard / qabot emit ``operator_command`` events (a guarded kill switch);
|
|
the live loop polls this consumer on its 5s guard cadence and executes each new
|
|
command through the execution service's existing protective-close helpers, then
|
|
echoes an ``operator_command_done`` event so a restart never re-runs it.
|
|
|
|
Replay safety (three layers):
|
|
1. an in-session consumed-nonce set (deduped within the process),
|
|
2. persisted ``operator_command_done`` events reloaded on boot (deduped across
|
|
restarts),
|
|
3. a staleness window — commands older than ``stale_seconds`` are ignored, so a
|
|
command issued while the loop was down does not fire on the next boot.
|
|
|
|
Deliberately minimal: a closed command vocabulary (OPERATOR_COMMANDS), no
|
|
parameters beyond an optional symbol. flatten_all routes through
|
|
``close_session`` DIRECTLY (never ``step_eod_flatten``) so it can never trip
|
|
plan 003's daily-restart exit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from storage.event_schema import EventMode, OperatorCommandEvent, EventType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STALE_SECONDS = 600 # ignore commands older than 10 minutes (boot-replay guard)
|
|
|
|
|
|
class OperatorCommandConsumer:
|
|
def __init__(self, store, execution, *, session_id=None, now_fn=None,
|
|
stale_seconds: float = STALE_SECONDS, mode: EventMode = EventMode.PAPER):
|
|
self.store = store
|
|
self.execution = execution
|
|
self.session_id = session_id
|
|
self._now = now_fn or datetime.now
|
|
self._stale = float(stale_seconds)
|
|
self._mode = mode
|
|
# nonces already handled — seeded from persisted _done events so a restart
|
|
# doesn't re-execute a command the previous process already ran.
|
|
self._consumed: set[str] = self._load_done_nonces()
|
|
|
|
def _cutoff_iso(self) -> str:
|
|
return (self._now() - timedelta(seconds=self._stale)).isoformat()
|
|
|
|
def _load_done_nonces(self) -> set[str]:
|
|
done: set[str] = set()
|
|
try:
|
|
for e in self.store.query_events(
|
|
event_type=EventType.OPERATOR_COMMAND_DONE.value,
|
|
since=self._cutoff_iso(), limit=None,
|
|
):
|
|
nonce = json.loads(e.get("payload_json") or "{}").get("nonce")
|
|
if nonce:
|
|
done.add(str(nonce))
|
|
except Exception: # noqa: BLE001 — a rebuild failure must not block boot
|
|
logger.warning("operator-command done-nonce reload skipped", exc_info=True)
|
|
return done
|
|
|
|
def poll(self) -> list[str]:
|
|
"""Execute any new, in-window operator commands. Returns nonces executed."""
|
|
try:
|
|
events = self.store.query_events(
|
|
event_type=EventType.OPERATOR_COMMAND.value,
|
|
since=self._cutoff_iso(), limit=None,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("operator-command poll failed", exc_info=True)
|
|
return []
|
|
executed: list[str] = []
|
|
for e in events:
|
|
p = json.loads(e.get("payload_json") or "{}")
|
|
nonce = str(p.get("nonce") or "")
|
|
if not nonce or nonce in self._consumed:
|
|
continue
|
|
self._consumed.add(nonce) # mark BEFORE executing so a raising command can't loop
|
|
result = self._execute(p)
|
|
self._emit_done(p, result)
|
|
executed.append(nonce)
|
|
logger.info("operator command %s (%s) -> %s",
|
|
p.get("command"), p.get("symbol") or "-", result)
|
|
return executed
|
|
|
|
def _execute(self, p: dict) -> str:
|
|
cmd = p.get("command")
|
|
sym = p.get("symbol")
|
|
ex = self.execution
|
|
if ex is None:
|
|
return "error: no execution service"
|
|
try:
|
|
if cmd == "flatten_all":
|
|
# DIRECT close_session — never step_eod_flatten, so this cannot
|
|
# request plan 003's daily-restart exit. close_session also blocks
|
|
# re-entry for the session (the kill-switch semantic).
|
|
res = ex.close_session("operator_flatten")
|
|
errs = res.get("errors") if isinstance(res, dict) else None
|
|
closed = ", ".join(res.get("closed_positions") or []) if isinstance(res, dict) else ""
|
|
return f"error: {errs}" if errs else f"ok: flattened [{closed or 'none'}]"
|
|
if cmd == "flatten_symbol":
|
|
if not sym:
|
|
return "error: flatten_symbol requires a symbol"
|
|
res = ex.flatten_symbol(sym)
|
|
return "ok" if res.get("ok") else f"error: {res.get('error')}"
|
|
if cmd == "pause_entries":
|
|
ex.set_operator_paused(True)
|
|
return "ok: entries paused"
|
|
if cmd == "resume_entries":
|
|
ex.set_operator_paused(False)
|
|
return "ok: entries resumed"
|
|
return f"error: unknown command {cmd!r}"
|
|
except Exception as exc: # noqa: BLE001 — one bad command must not kill the loop
|
|
logger.warning("operator command %s failed", cmd, exc_info=True)
|
|
return f"error: {exc}"
|
|
|
|
def _emit_done(self, p: dict, result: str) -> None:
|
|
try:
|
|
self.store.emit(OperatorCommandEvent(
|
|
timestamp=self._now(), mode=self._mode, correlation_id=self.session_id,
|
|
event_type=EventType.OPERATOR_COMMAND_DONE,
|
|
message=f"operator command {p.get('command')} done: {result}",
|
|
command=str(p.get("command") or ""), symbol=p.get("symbol"),
|
|
requested_by=str(p.get("requested_by") or "operator"),
|
|
nonce=str(p.get("nonce") or ""), result=result,
|
|
))
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("operator-command done-echo emit failed", exc_info=True)
|