Close eight open SDK tasks: the pidfile, the log, cards, names and a live curve
Each was a loose end recorded under `### SDK` in the notepad. `serve` takes its own pidfile down on SIGTERM. uvicorn restores the handler it found and re-raises the signal it stopped on, so the default handler ended the process without unwinding and the `finally` never ran — which is what a stop sends, and what left `serve.pid` behind. `serve.log` is cut back past 5 MB by the engine rather than by the screen that started it, so an adopted engine is bounded too. Gated on its own stdout being an appended regular file, which is what makes the cut safe: the kernel then puts the next write at the new end. Cards are counted from `/dev/nvidia[0-9]*`, so `FLOW_GPUS`/`--gpus` of 0 means "work it out" the way `FLOW_CPUS` always has. The engine counts, not the accountant — a remote worker builds one of those from its own inventory, and detecting there would hand it the engine host's cards. The worker counts last: what a batch job says it was granted still wins. `GET /runs/metrics/names` is the distinct over a selection that `--list` and the terminal's metric picker were approximating by reading the newest run that had measured anything, which missed a name only an older run ever wrote. `MetricSink` announces each batch it has written (`run_metric`, carrying the names). Not a per-point event: one covers up to 500 points or two seconds of them, and the rows stay the record. The terminal comparison fills in as the first readings land instead of staying blank until reopened, and the browser refetches the run and any comparison rather than the list behind them. `retry --group` pages the list route by `before` instead of stopping at 500. The terminal dashboard takes the terminal's colours (`ansi-dark`), and the web UI can re-pair from Settings without disconnecting first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRQ9bmTvCbqCwXo9mxZzzV
This commit is contained in:
+47
-17
@@ -59,9 +59,17 @@ MAX_PICKED = 20
|
||||
RUNS_SHOWN = 50
|
||||
|
||||
#: What the dashboard subscribes to. `message_value` is most of what the bus
|
||||
#: carries and none of what this screen draws.
|
||||
#: carries and none of what this screen draws. `run_metric` says a run has
|
||||
#: written readings, which is the only thing that moves a curve.
|
||||
KINDS = frozenset(
|
||||
{"run_started", "run_finished", "flow_paused", "engine_fatal", "flow_changed"}
|
||||
{
|
||||
"run_started",
|
||||
"run_finished",
|
||||
"flow_paused",
|
||||
"engine_fatal",
|
||||
"flow_changed",
|
||||
"run_metric",
|
||||
}
|
||||
)
|
||||
|
||||
#: How long an event waits for the ones behind it. A sweep starting twenty
|
||||
@@ -72,17 +80,13 @@ COALESCE_S = 0.25
|
||||
#: rather than a pipe because the screen is meant to be closed while the
|
||||
#: engine keeps running: a pipe whose reader has gone breaks the next write,
|
||||
#: and a node's `print` is one of those writes. It also means the log of an
|
||||
#: engine this screen only *adopted* can be read here.
|
||||
#: engine this screen only *adopted* can be read here. The engine cuts it back
|
||||
#: when it grows (`cli.LOG_KEEP_BYTES`), so the bound holds whoever started it.
|
||||
LOG_NAME = "serve.log"
|
||||
|
||||
#: How much of it to read back when the screen opens.
|
||||
LOG_TAIL_BYTES = 64 * 1024
|
||||
|
||||
#: What it is truncated to when this screen starts an engine of its own.
|
||||
#: ponytail: a size check, not rotation — add rotation when somebody wants
|
||||
#: yesterday's log.
|
||||
LOG_KEEP_BYTES = 5 * 1024 * 1024
|
||||
|
||||
#: How long the tail waits when the file has nothing new. Also how long
|
||||
#: closing the screen waits for that thread.
|
||||
LOG_POLL_S = 0.25
|
||||
@@ -299,13 +303,18 @@ class RunsTable(DataTable[str]):
|
||||
class ServeApp(App[int]):
|
||||
"""Three tabs: how the engine is, what has run, and what it is saying."""
|
||||
|
||||
#: The two modals take a border rather than a fill: on the ansi theme the
|
||||
#: surface *is* the terminal's background, so an edge is what stands them
|
||||
#: apart from what they are drawn over.
|
||||
CSS = """
|
||||
TabbedContent { height: 1fr; }
|
||||
TabPane { height: 1fr; padding: 0; }
|
||||
#overview { padding: 0 1; }
|
||||
#enroll { width: 60; height: auto; padding: 1 2; background: $surface; }
|
||||
#enroll { width: 60; height: auto; padding: 1 2; background: $surface;
|
||||
border: round $primary; }
|
||||
#enroll #note { color: $text-muted; height: auto; }
|
||||
#artifacts { width: 80; height: auto; padding: 1 2; background: $surface; }
|
||||
#artifacts { width: 80; height: auto; padding: 1 2; background: $surface;
|
||||
border: round $primary; }
|
||||
#artifacts DataTable { height: auto; max-height: 14; }
|
||||
"""
|
||||
|
||||
@@ -334,6 +343,8 @@ class ServeApp(App[int]):
|
||||
#: Set by the socket thread, drained by the coalescing timer — so a
|
||||
#: cascade of events costs one read of the engine rather than one each.
|
||||
self.stirred = False
|
||||
#: The same, for readings: a comparison redraws, the panels do not.
|
||||
self.plotted = False
|
||||
self.stop_stream = threading.Event()
|
||||
self.stop_log = threading.Event()
|
||||
#: The credential's mtime when the engine under this screen was
|
||||
@@ -355,6 +366,12 @@ class ServeApp(App[int]):
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# The screen is drawn inside somebody's terminal and should look like
|
||||
# it: this theme paints no ground of its own and names its colours by
|
||||
# their ANSI slots, so the palette is whatever they have configured —
|
||||
# which is also what the chart's own hues and the status colours have
|
||||
# always been.
|
||||
self.theme = "ansi-dark"
|
||||
self.title = f"fluksio — {self.data_dir}"
|
||||
table = self.query_one("#runs", RunsTable)
|
||||
table.add_column(" ", width=1)
|
||||
@@ -435,10 +452,10 @@ class ServeApp(App[int]):
|
||||
# What the credential looked like before the child wrote its own, so
|
||||
# the wait below cannot mistake the last engine's url for this one's.
|
||||
self.config_stamp = self._config_stamp()
|
||||
path = self.log_path()
|
||||
if path.exists() and path.stat().st_size > LOG_KEEP_BYTES:
|
||||
path.write_text("")
|
||||
handle = path.open("ab", buffering=0)
|
||||
# Opened for appending, which is what lets the engine cut it back when
|
||||
# it grows: every write then lands at the new end rather than at the
|
||||
# offset this process had.
|
||||
handle = self.log_path().open("ab", buffering=0)
|
||||
try:
|
||||
self.child = subprocess.Popen( # noqa: S603
|
||||
child_argv(sys.argv[1:]),
|
||||
@@ -568,13 +585,20 @@ class ServeApp(App[int]):
|
||||
|
||||
# -- the engine's own events ----------------------------------------------
|
||||
|
||||
def heard(self, event: dict[str, Any]) -> None:
|
||||
"""One event, from the socket thread. Two flags, drained on a timer."""
|
||||
if event.get("type") == "run_metric":
|
||||
self.plotted = True
|
||||
else:
|
||||
self.stirred = True
|
||||
|
||||
@work(thread=True, exclusive=True, group="stream")
|
||||
def watch_engine(self, url: str, token: str) -> None:
|
||||
"""The bus, over the websocket the browser subscribes to."""
|
||||
stream.subscribe(
|
||||
url,
|
||||
token,
|
||||
lambda event: setattr(self, "stirred", True),
|
||||
self.heard,
|
||||
self.stop_stream,
|
||||
kinds=KINDS,
|
||||
on_error=lambda exc: self.call_from_thread(
|
||||
@@ -584,14 +608,20 @@ class ServeApp(App[int]):
|
||||
|
||||
def drain(self) -> None:
|
||||
"""Whatever the bus said since the last tick, as one read."""
|
||||
showing = self.screen if isinstance(self.screen, CompareScreen) else None
|
||||
if self.plotted:
|
||||
self.plotted = False
|
||||
# Readings landed. Only the screen drawing them cares.
|
||||
if showing is not None:
|
||||
showing.refresh_live()
|
||||
if not self.stirred:
|
||||
return
|
||||
self.stirred = False
|
||||
self.refresh_panels()
|
||||
# A run finishing is exactly when a comparison's numbers become final,
|
||||
# and the screen showing them cannot hear the bus itself.
|
||||
if isinstance(self.screen, CompareScreen):
|
||||
self.screen.action_reload()
|
||||
if showing is not None:
|
||||
showing.action_reload()
|
||||
|
||||
# -- what the panels show -------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user