File size: 12,265 Bytes
62fd03f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | """Processes: every worker thread with what it does, its state, start/stop, autostart and recent activity; the event log.
The workers are threads inside the console process (ops/supervisor.py). Every control here goes through ``runtime.sup()``,
so an observer console (``fly-trader ui --observe``) refuses them all; the guards the page shows by disabling a button
(already running, running outside the console, already stopping) are enforced again in the actions."""
from __future__ import annotations
import json
import threading
from contextlib import nullcontext
from ... import config
from ...db.apilog import record_event
from ...db.connection import transaction
from ...db.queries import q, q1
from ...logging_setup import scrub
from ...ops.supervisor import WORKERS
from .. import runtime
from ..hub import ActionError, TopicError, action, topic
from ..state import ROLE_LABEL, WORKER_INFO, WORKER_TYPE, jv, worker_order
GROUPS = ("memecoins", "prediction markets", "system") # the menu's section order
LEVELS = ("info", "warning", "error")
ENGINES = ("runner", "kalshi_runner") # a Safety reset must never run under them (safety.no_reset)
STOP_NOTE = { # what stopping costs, beyond "it finishes its current step first"
"runner": "While it is stopped nothing is bought or sold: open positions stay open until it runs again.",
"pumpstream": "Without it the Solana feed goes stale within 3 minutes and the trading engine holds: no Solana entries or exits until it runs again.",
"rh_stream": "Without it the Robinhood Chain feed goes stale within 3 minutes and the engine's Robinhood books hold: no entries or exits there until it runs again.",
"kalshi_runner": "No new Kalshi entries while it is stopped; open positions still settle when their markets close.",
"kalshi_stream": "Without it the Kalshi feed goes stale and the Kalshi engine holds: no entries until it runs again.",
"vault": "The vault's jobs (flows, claims, the trading float, NAV, weekly settlement, public stats) pause until it runs again.",
}
_AUTOSTART = threading.Lock() # the autostart map is read-modify-written: one writer at a time
def _title(name: str) -> str:
return WORKER_INFO.get(name, (name,))[0]
def _state(s: dict, external: bool = False) -> str:
"""running / stopping / crashed / stopped for a console thread; ``external`` when only a CLI process runs the worker."""
if s.get("alive"):
return "stopping" if s.get("stopping") else "running"
return "external" if external else "crashed" if s.get("error") else "stopped"
def _start_note(name: str) -> str | None:
"""What starting this worker sets off that the operator should know first (resets, real money)."""
notes = []
if name == "runner":
if config.RESET_ON_START:
notes.append("RESET_ON_START is on: it first archives and clears the books other than the race books and the live books.")
if config.LIVE_ENABLED:
notes.append("LIVE_ENABLED=1: once the fly holds the selector's seat it also trades the bot wallet with real SOL.")
if config.RH_ENABLED and config.RH_LIVE_ENABLED:
notes.append("RH_LIVE_ENABLED=1: once the RH fly holds the seat it also trades the Robinhood Chain wallet with real ETH.")
elif name == "kalshi_runner" and config.KALSHI_LIVE_ENABLED and not config.kalshi_live_prerequisites_missing():
notes.append(f"Kalshi live is on: both arms mirror onto subaccount {config.KALSHI_SUBACCOUNT} with real money (cap ${config.KALSHI_CAPITAL_USD:g}).")
return " ".join(notes) or None
def _host_pid(sup) -> int | None:
"""The pid of the console that hosts the workers (an observer's own pid is not it)."""
if not isinstance(sup, runtime.Observer):
return sup.pid
r = q1("SELECT pid FROM processes WHERE cmd[1] = 'thread' AND stopped_at IS NULL ORDER BY started_at DESC LIMIT 1")
return int(r["pid"]) if r else None
def _hosting(sup) -> None:
"""Worker controls belong to the console that hosts the workers (procs.stop and the autostart row do not go through the
Supervisor, so they would slip past the observer's refusal)."""
if isinstance(sup, runtime.Observer):
raise runtime.ObserverMode("this console only observes (fly-trader ui --observe): start and stop workers from the console that hosts them")
def _worker(body: dict, shown: bool = True) -> str:
"""The worker an action names: one of the cards on the page (``shown``), or any worker the Supervisor knows."""
name = body.get("name")
if not isinstance(name, str) or name not in (worker_order() if shown else WORKERS):
raise ActionError(f"no worker {name!r} on this page", 400)
return name
# ---------------------------------------------------------------- reads
@topic("processes.workers", every=3)
def workers(params: dict) -> dict:
"""One card per worker (grouped as the menu is), the external CLI processes, and which console hosts the threads."""
sup = runtime.sup(); status = sup.status(); ext = sup.external()
auto = jv((q1("SELECT value FROM ui_settings WHERE key = 'autostart'") or {}).get("value"))
cards = []
for name in worker_order():
title, icon, desc, role = WORKER_INFO[name]; s = status.get(name) or {}
err = (s.get("error") or "").splitlines()
cards.append({"name": name, "title": title, "icon": icon, "desc": desc, "role": role, "role_label": ROLE_LABEL[role],
"group": WORKER_TYPE.get(name, "system"), "state": _state(s, name in ext), "alive": bool(s.get("alive")), "stopping": bool(s.get("stopping")),
"started_at": s.get("started_at"), "stopped_at": s.get("stopped_at"),
"error": err[0][:300] if err and not s.get("alive") else None, "external": name in ext, "autostart": bool(auto.get(name)),
"ext_pid": (ext.get(name) or {}).get("pid"), "ext_started_at": (ext.get(name) or {}).get("started_at"),
"start_note": _start_note(name), "stop_note": STOP_NOTE.get(name)})
groups = [{"key": g, "title": g.capitalize(), "workers": [c for c in cards if c["group"] == g]} for g in GROUPS]
counts = {k: sum(c["state"] == k for c in cards) for k in ("running", "stopping", "crashed", "stopped", "external")}
return {"pid": sup.pid, "observer": isinstance(sup, runtime.Observer), "host_pid": _host_pid(sup),
"external": [{"name": n, "title": _title(n), "pid": a.get("pid"), "started_at": a.get("started_at")} for n, a in ext.items()],
"groups": [g for g in groups if g["workers"]],
"counts": {**counts, "total": len(cards), "autostart": sum(c["autostart"] for c in cards),
"trade_needed": sum(c["role"] == "trade" for c in cards), "trade_up": sum(c["role"] == "trade" and c["state"] in ("running", "external") for c in cards)}}
@topic("processes.activity", every=3)
def activity(params: dict) -> dict:
"""A worker's last 12 log lines, newest first (the in-memory ring of this console, or logs/<name>.log for an observer)."""
name = params.get("worker")
if name not in WORKERS:
raise TopicError(f"no worker {name!r}")
rows = []
for ln in [ln for ln in runtime.sup().log_tail(name, 200).splitlines() if ln.strip()][-12:]:
try:
d = json.loads(ln)
except ValueError:
d = None
if isinstance(d, dict):
rows.append({"time": str(d.get("ts", ""))[11:19], "level": str(d.get("level", "")), "what": scrub(str(d.get("msg", "")))[:300]})
else:
rows.append({"time": "", "level": "", "what": scrub(ln)[:300]})
return {"name": name, "rows": rows[::-1]}
@topic("processes.events", every=10)
def events(params: dict) -> dict:
"""The last 200 events at the chosen levels (none chosen: errors)."""
lv = params.get("levels")
levels = [x for x in LEVELS if isinstance(lv, list) and x in lv] or ["error"]
rows = q("SELECT ts, level, source, message FROM events WHERE level = ANY(%s) ORDER BY id DESC LIMIT 200", (levels,))
return {"levels": levels, "rows": rows}
@topic("processes.event_stats", every=30)
def event_stats(params: dict) -> dict:
"""The last 24 hours of events: how many per level, and warnings and errors per hour (24 buckets, oldest first)."""
by_level = {r["level"]: int(r["n"]) for r in q("SELECT level, count(*) AS n FROM events WHERE ts > now() - interval '24 hours' GROUP BY level")}
rows = q("""SELECT h.h, COALESCE(sum(CASE WHEN e.level = 'warning' THEN 1 END), 0) AS warning, COALESCE(sum(CASE WHEN e.level = 'error' THEN 1 END), 0) AS error
FROM generate_series(date_trunc('hour', now()) - interval '23 hours', date_trunc('hour', now()), interval '1 hour') AS h(h)
LEFT JOIN events e ON e.ts >= h.h AND e.ts < h.h + interval '1 hour' AND e.level IN ('warning', 'error')
GROUP BY h.h ORDER BY h.h""")
return {"levels": {lv: by_level.get(lv, 0) for lv in LEVELS} | {k: v for k, v in by_level.items() if k not in LEVELS},
"hours": [r["h"] for r in rows], "warning": [int(r["warning"]) for r in rows], "error": [int(r["error"]) for r in rows]}
# ---------------------------------------------------------------- controls
@action("processes.start")
def start(body: dict) -> dict:
name = _worker(body); sup = runtime.sup(); _hosting(sup); title = _title(name)
if sup.alive(name):
raise ActionError(f"{title} is already running.")
ext = sup.external()
if name in ext:
raise ActionError(f"{title} is running outside the console (pid {ext[name].get('pid')}): stop the external process first.")
from . import safety
with safety.no_reset() if name in ENGINES else nullcontext(): # never start an engine while a reset clears its books
try:
started = sup.start(name, started_by="console")
except RuntimeError as e:
raise ActionError(str(e)) from None
if not started:
raise ActionError(f"{title} is already running.")
return {"message": f"Started {title}"}
@action("processes.stop")
def stop(body: dict) -> dict:
name = _worker(body, shown=False); sup = runtime.sup(); _hosting(sup); title = _title(name)
s = sup.status().get(name) or {}
if not s.get("alive"):
raise ActionError(f"{title} is not running.")
if s.get("stopping"):
raise ActionError(f"{title} is already stopping: it finishes its current step first.")
if not sup.request_stop(name, by="console"):
raise ActionError(f"{title} is not running.")
return {"message": f"Stop requested: {title} finishes its current step, then stops."}
@action("processes.stop_external")
def stop_external(body: dict) -> dict:
"""A worker started from the command line (`fly-trader worker start`): SIGTERM, 30 s, then SIGKILL (ops/procs.stop)."""
name = _worker(body, shown=False); sup = runtime.sup(); _hosting(sup)
a = sup.external().get(name)
if not a:
raise ActionError(f"No external {name} process is running.")
from ...ops import procs
if not procs.stop(name):
return {"message": f"The external {name} process had already exited"}
return {"message": f"Stopped the external {name} process (pid {a.get('pid')})"}
@action("processes.autostart")
def autostart(body: dict) -> dict:
"""Whether a worker starts with the console: the whole ``autostart`` map is read, changed and written back."""
name = _worker(body); on = body.get("on")
if not isinstance(on, bool):
raise ActionError("on must be true or false", 400)
_hosting(runtime.sup())
with _AUTOSTART:
r = q1("SELECT value FROM ui_settings WHERE key = 'autostart'"); val = jv(r["value"]) if r else {}
val[name] = on
with transaction() as conn:
conn.execute("INSERT INTO ui_settings (key, value) VALUES ('autostart', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
(json.dumps(val),))
record_event("info", "console", "autostart changed", val)
return {"message": f"Autostart {'on' if on else 'off'} for {_title(name)}"}
|