"""The worker's own screen: what this machine is doing, while it does it. **Why a worker needs a screen at all.** The agent had one output, which was a scrolling log on standard error. That is the right shape for a service and the wrong shape for the thing this actually is: a program somebody installs on their own laptop, turns on, and glances at. The questions a volunteer asks are "is it connected", "is it doing anything", "how much of my electricity has this used", and "is it safe". A log answers all four badly, because the answer is scattered across the last ten minutes of scrollback and the newest line is the least likely to be the one that matters. So this is a fixed screen that is redrawn, in the manner of `docker desktop` or `top`: the state is always in the same place, and history scrolls only in the pane that is meant to be history. **The shape of this module is the point.** There are three layers and they do not know about each other's business: * :class:`WorkerView` is a plain snapshot. No widgets, no worker, no clock: just the numbers and strings a screen would show, as data. * :func:`observe` takes a live :class:`~distinct_agent.worker.WorkerLoop` and produces one. It is the only function here that touches a running worker. * :class:`ActivityLog` catches what the worker prints, so a full-screen interface and a scrolling log are not fighting over the same terminal. The screen itself is `distinct_agent/tui.py`, which is Textual. It reads a :class:`WorkerView` and knows nothing about a worker; this module knows about a worker and nothing about a screen. That split is what lets every number on the display be checked in a test with no terminal attached, and it is why the formatting decisions below live here rather than in a widget. **What this screen must never do is flatter the worker.** The energy figure is self-reported and says so. A run whose energy was not measured is counted in the run total and not in the joules, and the panel shows both numbers so the total reads as a floor over N of M runs rather than as a complete sum. The containment line says what is actually applied on this machine, which on a plain Windows or Linux box is less than an operator might assume. A dashboard is a place where a green light is very easy to draw and very expensive to be wrong about. """ from __future__ import annotations import threading import time from dataclasses import dataclass, field, replace from typing import Any, Callable, Optional, Sequence #: How long a job may sit in the queue before the screen calls it slow. Nothing #: acts on this; it decides one colour, and it exists so the number in the #: colour and the number in the docstring cannot drift apart. SLOW_QUEUE_SECONDS = 60.0 #: Lines of history kept for the activity pane. Bounded because this runs for #: days on somebody's laptop and an unbounded list is a slow memory leak with a #: friendly face. ACTIVITY_LINES = 200 #: How often the screen re-reads the worker. Four times a second is smooth to #: a reader and costs nothing; the worker's own polling is on its own, much #: slower, clock and is not affected by this. REFRESH_SECONDS = 0.25 def format_duration(seconds: float) -> str: """A duration a person can read at a glance, without units they must parse. Deliberately coarse. "2h 14m" is what somebody wants from a glance; "2:14:07.318" is what they want from a log, and this is not a log. """ try: total = int(max(0.0, float(seconds))) except (TypeError, ValueError): return "unknown" if total < 60: return f"{total}s" minutes, second = divmod(total, 60) if minutes < 60: return f"{minutes}m {second:02d}s" hours, minute = divmod(minutes, 60) if hours < 24: return f"{hours}h {minute:02d}m" days, hour = divmod(hours, 24) return f"{days}d {hour:02d}h" def format_energy(joules: Optional[float]) -> str: """Joules, in the unit that keeps the number small enough to read. ``None`` is "not measured", which is not zero and must never render as zero. That distinction is the whole of this project's energy claim. """ if joules is None: return "not measured" try: value = float(joules) except (TypeError, ValueError): return "not measured" if value < 1_000: return f"{value:.0f} J" if value < 3_600_000: return f"{value / 1_000:.1f} kJ" return f"{value / 3_600_000:.2f} kWh" @dataclass(frozen=True) class JobRow: """One line of the queue table.""" job_id: str state: str model_id: str phase: str progress: float waiting_seconds: float note: str = "" @property def is_slow(self) -> bool: return self.state == "queued" and self.waiting_seconds >= SLOW_QUEUE_SECONDS @dataclass(frozen=True) class WorkerView: """Everything the screen shows, as data, with no Rich anywhere in it. Frozen and built fresh each tick rather than mutated, so a half-updated view can never be drawn: the renderer either has the previous whole snapshot or the next whole one. """ #: What the worker calls itself, and what the server calls it. name: str = "" agent_id: str = "" #: Where it is connected, in the words an operator typed. servers: tuple[str, ...] = () connected: bool = False connection_note: str = "" #: The machine. platform_name: str = "" cpu: str = "" ram_gb: float = 0.0 #: What it has agreed to run. Empty is a working state, not a failure. models: tuple[str, ...] = () tools: tuple[str, ...] = () #: The queue. jobs: tuple[JobRow, ...] = () queue_capacity: int = 0 status: str = "idle" #: Since this process started, self-reported. uptime_seconds: float = 0.0 lifetime_runs: int = 0 lifetime_measured_runs: int = 0 lifetime_joules: Optional[float] = None energy_provider: str = "" energy_scope: str = "" #: What is actually protecting this machine, in its own words. containment: str = "" request_sandbox: str = "" #: The access code this worker printed at start-up, so somebody who lost #: the scrollback can still read it off the screen. It is a capability: #: whoever has it can use this machine, which is why it is shown under a #: heading that says so rather than as a bare string in a corner. access_code: str = "" #: Newest last. activity: tuple[str, ...] = () #: Set when the worker stopped and the screen is showing why. stopped_reason: str = "" @property def active_jobs(self) -> tuple[JobRow, ...]: return tuple(row for row in self.jobs if row.state == "running") @property def queued_jobs(self) -> tuple[JobRow, ...]: return tuple(row for row in self.jobs if row.state == "queued") @property def energy_is_complete(self) -> bool: """Whether the joule total covers every run, or only some of them.""" return self.lifetime_runs > 0 and self.lifetime_measured_runs == self.lifetime_runs def observe( worker: Any, *, base: WorkerView, activity: Sequence[str] = (), now: Callable[[], float] = time.monotonic, ) -> WorkerView: """Read one live worker into one :class:`WorkerView`. The only function here that touches a running worker, and it touches it read-only. Everything it reads is either already computed for the server's benefit (``_snapshot``) or a plain attribute, so observing costs the worker nothing and cannot change what it does. Wrapped in a broad guard on purpose. A dashboard that raises takes the worker down with it, and there is no reading of this screen worth that: a tick that cannot be read shows the previous numbers and a note, and the worker carries on serving. """ try: snapshot = worker._snapshot() except Exception as error: # noqa: BLE001 - see the docstring return replace( base, activity=tuple(activity)[-ACTIVITY_LINES:], connection_note=f"the screen could not read the worker: {type(error).__name__}", ) outstanding = {job.id: job for job in worker.outstanding_jobs()} started = dict(snapshot.started_at or {}) progress = dict(snapshot.progress or {}) phases = dict(snapshot.progress_phase or {}) notes = dict(snapshot.progress_notes or {}) current = now() rows: list[JobRow] = [] for job_id in tuple(snapshot.active_job_ids) + tuple(snapshot.queued_job_ids): job = outstanding.get(job_id) state = "running" if job_id in snapshot.active_job_ids else "queued" began = started.get(job_id) rows.append( JobRow( job_id=job_id, state=state, model_id=getattr(job, "model_id", "") or "", phase=str(phases.get(job_id, "")), progress=float(progress.get(job_id, 0.0) or 0.0), waiting_seconds=max(0.0, current - began) if isinstance(began, (int, float)) else 0.0, note=str(notes.get(job_id, "")), ) ) return replace( base, agent_id=snapshot.agent_id or base.agent_id, jobs=tuple(rows), queue_capacity=snapshot.queue_capacity or base.queue_capacity, status=str(getattr(snapshot.status, "value", snapshot.status) or "idle"), uptime_seconds=float(snapshot.uptime_seconds or 0.0), lifetime_runs=int(snapshot.lifetime_runs or 0), lifetime_measured_runs=int(snapshot.lifetime_measured_runs or 0), lifetime_joules=snapshot.lifetime_joules, energy_scope=snapshot.energy_scope or base.energy_scope, activity=tuple(activity)[-ACTIVITY_LINES:], ) @dataclass class ActivityLog: """A bounded, thread-safe list of lines, and a sink the worker can write to. The worker reports by printing to standard error, which a full-screen dashboard cannot share: the two would draw over each other. So the dashboard takes those lines instead of letting them reach the terminal, and shows them in the pane that is meant to scroll. **``mirror`` exists because of the gap before the screen opens.** Joining a server means pairing, fetching a catalogue, an approval question, and sometimes a four-hundred-megabyte model download. All of that happens before there is a dashboard to draw it on. A log that swallowed those lines would give somebody a blank window for ten minutes and no way to tell a slow download from a hung program. So writes go to the real stream as well until the screen takes over, and :meth:`detach` is what takes it over. """ limit: int = ACTIVITY_LINES mirror: Any = None _lines: list[str] = field(default_factory=list) _lock: threading.Lock = field(default_factory=threading.Lock) def detach(self) -> None: """Stop echoing to the mirror, because something is drawing there now.""" with self._lock: self.mirror = None def write(self, text: str) -> int: """Accept a write the way a file object would, so it can replace one.""" if not text: return 0 with self._lock: mirror = self.mirror for line in str(text).splitlines(): stripped = line.rstrip() if stripped: self._lines.append(stripped) if len(self._lines) > self.limit: del self._lines[: len(self._lines) - self.limit] if mirror is not None: # Outside the lock: a mirror that blocks must not stop the worker, # and a mirror that raises (a closed pipe, a detached console on # Windows) must not either. try: mirror.write(text) mirror.flush() except Exception: # noqa: BLE001 - a mirror is a convenience self.detach() return len(text) def flush(self) -> None: """Present because a file object has one, and something will call it.""" def isatty(self) -> bool: """False, and deliberately so. `distinct_agent/cli.py` asks `sys.stdin.isatty()` before offering the approval prompt, and other code asks the same of its output. Claiming to be a terminal here would invite something to try to draw on a sink that is not one. """ return False def lines(self) -> tuple[str, ...]: with self._lock: return tuple(self._lines)