alissa-tools-github-revloop 0.29.0__tar.gz → 0.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {alissa_tools_github_revloop-0.29.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.30.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__main__.py +19 -0
  3. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa_client.py +25 -2
  4. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/config.py +56 -13
  5. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/fleet_vitals.py +504 -0
  6. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop.py +57 -3
  7. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sources.py +58 -9
  9. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +19 -0
  10. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/version +0 -1
  13. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/LICENSE +0 -0
  14. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/MANIFEST.in +0 -0
  15. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/NOTICE +0 -0
  16. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/README.md +0 -0
  17. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/requirements.txt +0 -0
  18. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/setup.cfg +0 -0
  19. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/setup.py +0 -0
  20. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  21. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  22. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  23. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  24. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  25. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  26. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  28. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  29. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  30. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  31. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  32. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  33. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  34. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  35. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  36. {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.29.0
3
+ Version: 0.30.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -13,6 +13,7 @@ from .alissa_client import AlissaClient, AlissaError
13
13
  from .config import (
14
14
  BOW_OWNERS_ENV,
15
15
  BOWS_REFRESH_POLLS_ENV,
16
+ FLEET_VITALS_ENV,
16
17
  HUB_ADD,
17
18
  HUB_SKIP,
18
19
  LOOP_EVENTS_ENV,
@@ -253,6 +254,23 @@ def build_parser() -> argparse.ArgumentParser:
253
254
  action="store_false",
254
255
  help="do not push loop telemetry even if the config enables it",
255
256
  )
257
+ vitals = over.add_mutually_exclusive_group()
258
+ vitals.add_argument(
259
+ "--fleet-vitals",
260
+ dest="fleet_vitals_enabled",
261
+ action="store_true",
262
+ default=None,
263
+ help="push one fleet-vitals snapshot (heartbeat, sessions, inbox, "
264
+ "rate, memory) to Studio's POST /v1/loop/fleet-vitals at the end of "
265
+ "every completed poll pass — best-effort, never fatal. "
266
+ f"Overridden by ${FLEET_VITALS_ENV}",
267
+ )
268
+ vitals.add_argument(
269
+ "--no-fleet-vitals",
270
+ dest="fleet_vitals_enabled",
271
+ action="store_false",
272
+ help="do not push fleet vitals even if the config enables it",
273
+ )
256
274
  over.add_argument(
257
275
  "--alissa-endpoint",
258
276
  dest="alissa_endpoint",
@@ -307,6 +325,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
307
325
  "task_list_self_scope": args.task_list_self_scope,
308
326
  "task_list_bow_id": args.task_list_bow_id,
309
327
  "loop_events_enabled": args.loop_events_enabled,
328
+ "fleet_vitals_enabled": args.fleet_vitals_enabled,
310
329
  "alissa_endpoint": args.alissa_endpoint,
311
330
  "dry_run": args.dry_run,
312
331
  }
@@ -1,5 +1,6 @@
1
- """Alissa REST access — loop telemetry's one write (issue #112) and the two
2
- reads `repos_source: bows` needs (issue #119).
1
+ """Alissa REST access — loop telemetry's write (issue #112), the fleet-vitals
2
+ write beside it (issue #126) and the two reads `repos_source: bows` needs
3
+ (issue #119).
3
4
 
4
5
  This is the SECOND Alissa adapter in the package, and the split is deliberate.
5
6
  `alissa.py` shells out to the `alissa` CLI, which is the daemon's established
@@ -62,6 +63,12 @@ ENV_TOKEN = "ALISSA_API_TOKEN"
62
63
  # rather than silently posting a request the API will 400.
63
64
  MAX_EVENTS_PER_POST = 200
64
65
 
66
+ # The `error` code a 403 carries when the token's user has not activated the
67
+ # stacked app that owns the loop ingest. Advisory (classification keys on the
68
+ # status), but the vitals pusher reads it to warn ONCE with the activate URL
69
+ # instead of once per pass.
70
+ NOT_ACTIVATED = "not_activated"
71
+
65
72
 
66
73
  class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
67
74
  """Refuse every redirect instead of following it (PR #113 round 1).
@@ -246,6 +253,22 @@ class AlissaClient:
246
253
  payload = self._request("/v1/loop-events", {"events": events})
247
254
  return payload if isinstance(payload, dict) else {}
248
255
 
256
+ def post_fleet_vitals(self, snapshot: dict) -> dict:
257
+ """Replace this seat's fleet-vitals snapshot (`POST
258
+ /v1/loop/fleet-vitals`, issue #126).
259
+
260
+ One body, one seat, replaced on every call — there is no batching
261
+ and no dedupe key, because the API keeps only the LATEST snapshot per
262
+ user×seat (loop events are the history). Returns the API's
263
+ `{"seat", "receivedAt", "replaced"}` payload (empty dict when the
264
+ body was empty), for the caller's debug line. The API is strict —
265
+ unknown keys 400, lists cap at 50, the body at 64 KB — and the
266
+ BUILDER (`fleet_vitals`) owns fitting the snapshot to that, so this
267
+ method sends exactly what it is handed.
268
+ """
269
+ payload = self._request("/v1/loop/fleet-vitals", snapshot)
270
+ return payload if isinstance(payload, dict) else {}
271
+
249
272
  def ping(self) -> Identity:
250
273
  """The identity this token acts as (`GET /v1/ping`).
251
274
 
@@ -15,6 +15,9 @@ site. `loop_events_enabled` (`ALISSA_REV_LOOP_EVENTS_ENABLED`, see
15
15
  `env_loop_events_enabled`) has it because a container deployment toggles
16
16
  telemetry with one variable and no config-file edit (issue #112); the env wins
17
17
  over both other layers so the two env-backed keys share one precedence story.
18
+ `fleet_vitals_enabled` (`ALISSA_REV_FLEET_VITALS_ENABLED`, see
19
+ `env_fleet_vitals_enabled`) is the second Studio push and rides the same rail
20
+ through the same boolean reader (issue #126).
18
21
  The three `repos_source: bows` keys (issue #119) ride the same rail for the
19
22
  same container reason: `repos_source` (`ALISSA_REVIEW_REPOS_SOURCE`),
20
23
  `bows_refresh_polls` (`ALISSA_REVIEW_BOWS_REFRESH_POLLS`) and `bow_owners`
@@ -191,6 +194,7 @@ CONFIG_KEYS = (
191
194
  "task_list_self_scope",
192
195
  "task_list_bow_id",
193
196
  "loop_events_enabled",
197
+ "fleet_vitals_enabled",
194
198
  "alissa_endpoint",
195
199
  "dry_run",
196
200
  )
@@ -206,6 +210,11 @@ TASK_LIST_BOW_ENV = "ALISSA_REVIEW_TASK_BOW"
206
210
  # module docstring for the shared precedence story.
207
211
  LOOP_EVENTS_ENV = "ALISSA_REV_LOOP_EVENTS_ENABLED"
208
212
 
213
+ # The environment variable toggling the fleet-vitals push (issue #126): one
214
+ # snapshot of this daemon's live state per completed pass, to Studio's
215
+ # `POST /v1/loop/fleet-vitals`. Same rail and same reader as LOOP_EVENTS_ENV.
216
+ FLEET_VITALS_ENV = "ALISSA_REV_FLEET_VITALS_ENABLED"
217
+
209
218
  # The three `repos_source: bows` rails (issue #119). Same precedence as the two
210
219
  # above -- env > file > flag -- and the same blank-falls-through rule, because
211
220
  # the container bakes every knob as an ENV that renders `""` when unset.
@@ -258,18 +267,21 @@ def _validate_alissa_endpoint(endpoint: str) -> str:
258
267
  )
259
268
 
260
269
 
261
- def env_loop_events_enabled(
262
- environ: "Mapping[str, str] | None" = None,
270
+ def _env_bool(
271
+ name: str, environ: "Mapping[str, str] | None" = None,
263
272
  ) -> "bool | None":
264
- """The loop-events toggle from the environment, or None when unset.
265
-
266
- None and empty are the SAME answer an exported-but-empty variable is how
267
- a container renders "unset" (the Dockerfile bakes empty ENV defaults), and
268
- it must fall through to the file/CLI layers rather than read as false. A
269
- non-boolean spelling raises: the startup phase turns a ValueError into
270
- `config error` + exit 2, which is where a typo belongs.
273
+ """One boolean env rail: True/False, or None when unset.
274
+
275
+ Shared by every boolean the environment can set (`LOOP_EVENTS_ENV`,
276
+ `FLEET_VITALS_ENV`) so the accepted spellings and the refusal are one
277
+ rule, not two copies that drift (issue #126). None and empty are the SAME
278
+ answer an exported-but-empty variable is how a container renders
279
+ "unset" (the Dockerfile bakes empty ENV defaults), and it must fall
280
+ through to the file/CLI layers rather than read as false. A non-boolean
281
+ spelling raises, naming the variable: the startup phase turns a
282
+ ValueError into `config error` + exit 2, which is where a typo belongs.
271
283
  """
272
- raw = (os.environ if environ is None else environ).get(LOOP_EVENTS_ENV)
284
+ raw = (os.environ if environ is None else environ).get(name)
273
285
  value = (raw or "").strip().lower()
274
286
  if not value:
275
287
  return None
@@ -278,11 +290,27 @@ def env_loop_events_enabled(
278
290
  if value in _ENV_FALSE:
279
291
  return False
280
292
  raise ValueError(
281
- f"{LOOP_EVENTS_ENV} must be a boolean "
293
+ f"{name} must be a boolean "
282
294
  f"(1/0, true/false, yes/no, on/off), got {raw!r}"
283
295
  )
284
296
 
285
297
 
298
+ def env_loop_events_enabled(
299
+ environ: "Mapping[str, str] | None" = None,
300
+ ) -> "bool | None":
301
+ """The loop-events toggle from the environment, or None when unset —
302
+ `_env_bool` over LOOP_EVENTS_ENV."""
303
+ return _env_bool(LOOP_EVENTS_ENV, environ)
304
+
305
+
306
+ def env_fleet_vitals_enabled(
307
+ environ: "Mapping[str, str] | None" = None,
308
+ ) -> "bool | None":
309
+ """The fleet-vitals toggle from the environment, or None when unset —
310
+ `_env_bool` over FLEET_VITALS_ENV (issue #126)."""
311
+ return _env_bool(FLEET_VITALS_ENV, environ)
312
+
313
+
286
314
  def env_task_list_bow_id(environ: "Mapping[str, str] | None" = None) -> "str | None":
287
315
  """The review BOW id from the environment, or None when it is not set.
288
316
 
@@ -786,6 +814,15 @@ class Config:
786
814
  # when on: a failed push is one WARN and the pass completes.
787
815
  loop_events_enabled: bool = False
788
816
 
817
+ # Whether the loop pushes one FLEET-VITALS snapshot — heartbeat, poll
818
+ # durations, the reviewer-session roster, rate, memory, queue depth, the
819
+ # operator inbox — to Studio's `POST /v1/loop/fleet-vitals` at the end
820
+ # of every completed pass (issue #126), so a Factory with no console URL
821
+ # for this seat can still render its card. OFF by default for the same
822
+ # reason `loop_events_enabled` is; toggled by FLEET_VITALS_ENV above the
823
+ # file and the CLI; best-effort when on (one WARN per failed pass).
824
+ fleet_vitals_enabled: bool = False
825
+
789
826
  # The Alissa/Studio API base the loop-events client posts to. One knob so
790
827
  # a staging deployment can point telemetry somewhere else; everything else
791
828
  # about the client (its bearer token) comes from the environment.
@@ -884,8 +921,8 @@ class Config:
884
921
  "not specified on the CLI" and fall through to the file / defaults.
885
922
 
886
923
  `environ` is the fourth layer and applies to the env-backed keys
887
- (`task_list_bow_id`, `loop_events_enabled`, `repos_source`,
888
- `bows_refresh_polls`, `bow_owners`); it wins over both of the others
924
+ (`task_list_bow_id`, `loop_events_enabled`, `fleet_vitals_enabled`,
925
+ `repos_source`, `bows_refresh_polls`, `bow_owners`); it wins over both of the others
889
926
  (see the module docstring). Defaults to the real environment, so
890
927
  callers that do not care pass nothing.
891
928
  """
@@ -941,6 +978,11 @@ class Config:
941
978
  env_events = env_loop_events_enabled(environ)
942
979
  if env_events is not None:
943
980
  raw["loop_events_enabled"] = env_events
981
+ # ...and the fleet-vitals toggle (issue #126), the same rail through
982
+ # the same reader.
983
+ env_vitals = env_fleet_vitals_enabled(environ)
984
+ if env_vitals is not None:
985
+ raw["fleet_vitals_enabled"] = env_vitals
944
986
 
945
987
  # The three bows rails (issue #119), same layer and same reason: the
946
988
  # container hands the daemon its mode with one variable. Each is
@@ -1175,6 +1217,7 @@ class Config:
1175
1217
  task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
1176
1218
  task_list_bow_id=bow_id,
1177
1219
  loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
1220
+ fleet_vitals_enabled=bool(raw.get("fleet_vitals_enabled", False)),
1178
1221
  alissa_endpoint=endpoint,
1179
1222
  dry_run=bool(raw.get("dry_run", False)),
1180
1223
  )
@@ -0,0 +1,504 @@
1
+ """Fleet vitals: one snapshot of this daemon's live state per completed poll
2
+ pass, pushed to Studio's `POST /v1/loop/fleet-vitals` (issue #126).
3
+
4
+ The Factory's `/fleet` screen PULLS each daemon console's `/api/state` over
5
+ private networking, with passcodes held on the hosted Factory -- which is
6
+ per-deployment by construction, so a Dark Factory customer running their own
7
+ revloop sees `not_configured`. Studio (TASK-182095034) now also accepts one
8
+ PUSHED snapshot per user×seat, replaced on every POST and rendered whenever
9
+ the Factory has no console URL for the seat. This module is the revloop half
10
+ of that: opt-in (`fleet_vitals_enabled` / `ALISSA_REV_FLEET_VITALS_ENABLED`),
11
+ in the same posture as loop events -- best-effort, never fatal, honours
12
+ dry-run -- and pushed at the end of every completed pass, AFTER the
13
+ loop-events batch.
14
+
15
+ Design rules, all load-bearing:
16
+
17
+ * **Built in-process from the console's own builders.** The snapshot is the
18
+ reviewer console's data layer (`webui.sources.Sources` -- the LIVE half of
19
+ the inbox, the session roster, the cached rate and drift reads -- and
20
+ `webui.sysinfo` for the cgroup memory split) mapped onto the wire
21
+ vocabulary, so the Factory card and the console can never disagree about
22
+ what the operator owes. The console SIDECAR is not required to be running:
23
+ the daemon holds its own `Sources` over its own config.
24
+
25
+ * **Built to the contract EXACTLY.** The API is strict -- an unknown key is a
26
+ 400, `sessionList` and `inbox` cap at 50 entries, the whole body at 64 KB --
27
+ so the builder emits only the contract's keys, caps every list here, and
28
+ trims the two lists until the body fits (`fit_body`). A snapshot the API
29
+ refuses is worth nothing, so fitting is the builder's job, not the API's.
30
+
31
+ * **`null` means "could not read", never `{0, 0}`.** A roster `alissa tmux
32
+ ls` could not list is `sessions: null` / `sessionList: null`; a memory
33
+ split off a host without cgroup v2 is `memory: null`. A Factory card that
34
+ read "0 live" over a broken tmux would be the reassuring-direction error,
35
+ and every other read here follows the same rule.
36
+
37
+ * **One push, no retry queue.** The API keeps only the latest snapshot per
38
+ seat, so a failed push is simply replaced by the next pass's -- there is
39
+ nothing to re-send. A failure is ONE WARNING naming the status and error;
40
+ the pass completes. The one refinement: a `403 not_activated` (the token's
41
+ user has not activated the loop app) warns ONCE per boot with the activate
42
+ URL and logs at DEBUG after, because it is a fixed condition the operator
43
+ resolves out of band, and the next pass after activation lands on its own.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import logging
50
+ import time
51
+ from datetime import datetime, timezone
52
+ from typing import TYPE_CHECKING, Any, Callable
53
+
54
+ from .alissa import parse_session_name, session_repo_slug
55
+ from .alissa_client import (
56
+ NOT_ACTIVATED,
57
+ AlissaAuthError,
58
+ AlissaClient,
59
+ AlissaError,
60
+ )
61
+ from .config import Config
62
+ from .proc import CommandError, run as proc_run
63
+
64
+ if TYPE_CHECKING: # pragma: no cover - typing only
65
+ from .webui.sources import Sources
66
+
67
+ log = logging.getLogger(__name__)
68
+
69
+ SEAT = "revloop"
70
+ SCHEMA_VERSION = 1
71
+ # The edge this daemon's sessions run: the Factory's vocabulary for "a
72
+ # reviewer round" (devloop's is `develop`).
73
+ EDGE = "review"
74
+ # The API's per-list cap (`sessionList`, `inbox`) and its body cap. Mirrors
75
+ # Studio's FLEET_VITALS_LIST_CAP / FLEET_VITALS_BODY_MAX; both are the wire
76
+ # contract, so the builder enforces them before sending rather than learning
77
+ # them from a 400.
78
+ LIST_CAP = 50
79
+ BODY_MAX_BYTES = 64 * 1024
80
+ # How many recent pass durations ride along -- the console's sparkline
81
+ # window (`sources.SPARK_POINTS`), restated here because this module cannot
82
+ # import `sources` at module level (sources imports loop, loop imports this).
83
+ DURATION_POINTS = 60
84
+
85
+ # The three outcomes the poll summary reports.
86
+ VITALS_PUSHED = "pushed"
87
+ VITALS_SKIPPED = "skipped"
88
+ VITALS_FAILED = "failed"
89
+
90
+ # The operator's lever per inbox kind, in the Factory's `lever` slot. The
91
+ # cap-out and the stability hold are both lifted by the same re-entry ack;
92
+ # a stalled episode is the console's retry-now (or a reap).
93
+ LEVER_REENTER = "comment `alissa-review: re-enter +N` on the PR"
94
+ LEVER_STALLED = "retry-now in the reviewer console, or reap the session"
95
+ _LEVERS = {
96
+ "cap-out": LEVER_REENTER,
97
+ "stability-held": LEVER_REENTER,
98
+ "stalled": LEVER_STALLED,
99
+ }
100
+
101
+
102
+ def iso_utc(seconds: "int | float") -> str:
103
+ """An epoch stamp as the contract's ISO-8601 UTC string (whole seconds,
104
+ `Z` suffix)."""
105
+ return datetime.fromtimestamp(int(seconds), tz=timezone.utc).strftime(
106
+ "%Y-%m-%dT%H:%M:%SZ"
107
+ )
108
+
109
+
110
+ def _int_or_none(value: object) -> "int | None":
111
+ if isinstance(value, bool):
112
+ return None
113
+ if isinstance(value, (int, float)):
114
+ return int(value)
115
+ return None
116
+
117
+
118
+ def _resolve_repo(slug: "str | None", repos: "tuple[str, ...]") -> "str | None":
119
+ """The `owner/repo` a session name's repo component denotes, or None.
120
+
121
+ A session name carries the sanitized REPO component only (no owner), so
122
+ it resolves through the allowlist exactly the way the sweep's own name
123
+ resolution does: the one `owner/repo` whose slug matches. Two matches --
124
+ two owners with the same repo name -- is no answer, not a guess."""
125
+ if not slug:
126
+ return None
127
+ matches = [
128
+ full for full in repos
129
+ if session_repo_slug(full.split("/", 1)[-1]) == slug
130
+ ]
131
+ return matches[0] if len(matches) == 1 else None
132
+
133
+
134
+ def _session_row(row: dict, repos: "tuple[str, ...]") -> dict:
135
+ """One console session row -> one contract `sessionList` entry.
136
+
137
+ The PR and round come from the spawn ledger when the console paired the
138
+ session with a row (the authoritative record); a session with no ledger
139
+ row -- a hand-spawned `review-pr-<n>` from the skill, or one whose ledger
140
+ was lost -- falls back to what its NAME says (`parse_session_name`), the
141
+ same grammar the reaper trusts. `attempt` is always null: the review
142
+ loop is round-based and has no attempt dimension."""
143
+ name = row.get("name")
144
+ ref = parse_session_name(name)
145
+ repo: "str | None" = None
146
+ number: "int | None" = None
147
+ round_: "int | None" = None
148
+ pr = row.get("pr")
149
+ if isinstance(pr, str) and "#" in pr:
150
+ head, _, tail = pr.rpartition("#")
151
+ if tail.isdigit():
152
+ repo, number = head, int(tail)
153
+ round_ = _int_or_none(row.get("round"))
154
+ if ref is not None:
155
+ if number is None:
156
+ number = ref.number
157
+ if round_ is None:
158
+ round_ = ref.round
159
+ if repo is None:
160
+ repo = _resolve_repo(ref.repo, repos)
161
+ url = (
162
+ f"https://github.com/{repo}/pull/{number}"
163
+ if repo and number is not None
164
+ else None
165
+ )
166
+ cpu = row.get("cpu_percent")
167
+ return {
168
+ "name": str(name) if name is not None else "",
169
+ "edge": EDGE,
170
+ "repo": repo,
171
+ "number": number,
172
+ "round": round_,
173
+ "attempt": None,
174
+ "ageS": _int_or_none(row.get("age_seconds")),
175
+ "cpu": float(cpu) if isinstance(cpu, (int, float)) else None,
176
+ "rssBytes": _int_or_none(row.get("rss_bytes")),
177
+ "live": bool(row.get("live")),
178
+ "managed": bool(row.get("managed")),
179
+ "url": url,
180
+ }
181
+
182
+
183
+ def _inbox_row(item: dict) -> dict:
184
+ """One console inbox item (the LIVE half) -> one contract `inbox` entry.
185
+ Every reviewer page is a PR reference, so `isPr` is always true."""
186
+ kind = str(item.get("kind"))
187
+ repo = item.get("repo_slug")
188
+ number = _int_or_none(item.get("number"))
189
+ return {
190
+ "kind": kind,
191
+ "subject": (
192
+ f"{repo}#{number}" if repo and number is not None else str(repo or "")
193
+ ),
194
+ "repo": str(repo) if repo else None,
195
+ "number": number,
196
+ "isPr": True,
197
+ "ageS": _int_or_none(item.get("age_seconds")),
198
+ "url": item.get("url") or None,
199
+ "lever": _LEVERS.get(kind),
200
+ }
201
+
202
+
203
+ def _rate(raw: "dict | None") -> "dict | None":
204
+ """The console's cached `gh api rate_limit` read in the contract's shape,
205
+ or None when the read failed or came back without the two numbers the
206
+ Factory meter needs."""
207
+ if not isinstance(raw, dict):
208
+ return None
209
+ remaining = _int_or_none(raw.get("remaining"))
210
+ limit = _int_or_none(raw.get("limit"))
211
+ if remaining is None or limit is None:
212
+ return None
213
+ reset = _int_or_none(raw.get("reset"))
214
+ return {
215
+ "remaining": remaining,
216
+ "limit": limit,
217
+ "resetAt": iso_utc(reset) if reset is not None else None,
218
+ }
219
+
220
+
221
+ def _memory(raw: "dict | None") -> "dict | None":
222
+ """The cgroup split in the contract's shape. `resident` is the one field
223
+ the Factory cannot draw without, so its absence (no cgroup v2) makes the
224
+ whole object null rather than a meter over nothing."""
225
+ if not isinstance(raw, dict):
226
+ return None
227
+ resident = _int_or_none(raw.get("resident"))
228
+ if resident is None:
229
+ return None
230
+ return {
231
+ "residentBytes": resident,
232
+ "reclaimableBytes": _int_or_none(raw.get("reclaimable")),
233
+ "limitBytes": _int_or_none(raw.get("limit")),
234
+ }
235
+
236
+
237
+ def build_snapshot(
238
+ sources: "Sources",
239
+ *,
240
+ heartbeat_at: "int | float",
241
+ poll_interval: int,
242
+ queue_depth: "int | None",
243
+ repos: "tuple[str, ...]" = (),
244
+ now: "int | float | None" = None,
245
+ ) -> dict:
246
+ """The contract body for this pass, off the console's builders.
247
+
248
+ `heartbeat_at` is the completion time of the pass that produced the
249
+ snapshot (the Factory derives `polling` / `stalled` from its age against
250
+ `poll_interval`); `queue_depth` is the watcher's count of owed rounds
251
+ waiting on the spawn gate, or None when the caller cannot say. Every
252
+ optional field degrades to null on its own -- a broken tmux does not
253
+ blank the memory split, and vice versa -- and `kpis` is always null (an
254
+ orcloop-only section).
255
+ """
256
+ stamp = time.time() if now is None else now
257
+ snaps = sources.snapshots(DURATION_POINTS)
258
+ durations = [
259
+ int(s["duration_ms"]) for s in reversed(snaps)
260
+ if _int_or_none(s.get("duration_ms")) is not None
261
+ ]
262
+ drift = sources.drift()
263
+ rows = sources.session_rows()
264
+ sessions: "dict | None"
265
+ session_list: "list[dict] | None"
266
+ if rows is None:
267
+ sessions = session_list = None
268
+ else:
269
+ sessions = {
270
+ "live": sum(1 for r in rows if r.get("live")),
271
+ "managed": sum(1 for r in rows if r.get("managed")),
272
+ }
273
+ # Live, own-grammar sessions first, so the cap drops the rows an
274
+ # operator cares least about (a gone session, another lane's).
275
+ ordered = sorted(
276
+ rows, key=lambda r: (not r.get("live"), not r.get("managed"))
277
+ )
278
+ session_list = [_session_row(r, repos) for r in ordered[:LIST_CAP]]
279
+ inbox = [_inbox_row(item) for item in sources.inbox()["live"][:LIST_CAP]]
280
+ return {
281
+ "schemaVersion": SCHEMA_VERSION,
282
+ "seat": SEAT,
283
+ "asOf": iso_utc(stamp),
284
+ "heartbeatAt": iso_utc(heartbeat_at),
285
+ "pollIntervalS": int(poll_interval),
286
+ "version": str(drift.get("running") or ""),
287
+ "drift": (
288
+ str(drift["latest"])
289
+ if drift.get("state") == "behind" and drift.get("latest")
290
+ else None
291
+ ),
292
+ "pollDurationsMs": durations or None,
293
+ "sessions": sessions,
294
+ "sessionList": session_list,
295
+ "rate": _rate(sources.rate_limit()),
296
+ "memory": _memory(sources.memory()),
297
+ "queueDepth": _int_or_none(queue_depth),
298
+ "kpis": None,
299
+ "inbox": inbox,
300
+ }
301
+
302
+
303
+ def body_bytes(snapshot: dict) -> int:
304
+ """The wire size of `snapshot`, encoded exactly as the client sends it."""
305
+ return len(json.dumps(snapshot).encode("utf-8"))
306
+
307
+
308
+ def fit_body(snapshot: dict) -> dict:
309
+ """`snapshot`, with `sessionList` and `inbox` trimmed until the encoded
310
+ body is within BODY_MAX_BYTES. The longer list is halved first, so the
311
+ two shrink together rather than one vanishing while the other stays
312
+ whole; the counts in `sessions` are left as the whole roster's, because
313
+ they describe the fleet, not the list. Everything else is bounded by
314
+ construction and is never touched."""
315
+ body = dict(snapshot)
316
+ while body_bytes(body) > BODY_MAX_BYTES:
317
+ lists = [
318
+ key for key in ("sessionList", "inbox")
319
+ if isinstance(body.get(key), list) and body[key]
320
+ ]
321
+ if not lists:
322
+ break
323
+ key = max(lists, key=lambda k: len(body[k]))
324
+ body[key] = body[key][: len(body[key]) // 2]
325
+ return body
326
+
327
+
328
+ def describe(snapshot: dict) -> str:
329
+ """The one-line summary the log carries for a snapshot."""
330
+ sessions = snapshot.get("sessions")
331
+ roster = (
332
+ f"sessions={sessions.get('live')}/{sessions.get('managed')} live/managed"
333
+ if isinstance(sessions, dict)
334
+ else "sessions=unlistable"
335
+ )
336
+ inbox = snapshot.get("inbox")
337
+ return (
338
+ f"{roster}, inbox={len(inbox) if isinstance(inbox, list) else 0}, "
339
+ f"queue={snapshot.get('queueDepth')}, {body_bytes(snapshot)} bytes"
340
+ )
341
+
342
+
343
+ class FleetVitalsPusher:
344
+ """The once-per-pass push, best-effort.
345
+
346
+ Owned by the watcher when `fleet_vitals_enabled` is on; `push_once` is
347
+ called at the end of every completed pass, after the loop-events batch,
348
+ and NEVER raises for an API or transport condition -- one WARNING, the
349
+ pass completes, and the next pass's snapshot is the whole retry story.
350
+ """
351
+
352
+ def __init__(
353
+ self,
354
+ config: Config,
355
+ sources: "Sources",
356
+ client: AlissaClient,
357
+ *,
358
+ clock: Callable[[], float] = time.time,
359
+ ):
360
+ self.config = config
361
+ self._sources = sources
362
+ self._client = client
363
+ self._clock = clock
364
+ # Latched by the first `403 not_activated`: the operator resolves it
365
+ # out of band, so one WARNING per boot names the activate URL and
366
+ # every later refusal is a DEBUG line. The push itself is still
367
+ # attempted every pass -- the pass after activation lands on its own.
368
+ self._not_activated_warned = False
369
+
370
+ def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
371
+ """This pass's contract body, fitted to the API's caps."""
372
+ return fit_body(build_snapshot(
373
+ self._sources,
374
+ heartbeat_at=heartbeat_at,
375
+ poll_interval=self.config.poll_interval,
376
+ queue_depth=queue_depth,
377
+ repos=self.config.repos,
378
+ now=self._clock(),
379
+ ))
380
+
381
+ def push_once(
382
+ self, *, heartbeat_at: "int | float", queue_depth: "int | None" = None
383
+ ) -> str:
384
+ """Build and push this pass's snapshot. Returns one of VITALS_PUSHED,
385
+ VITALS_SKIPPED (dry-run: logged, nothing sent) or VITALS_FAILED.
386
+
387
+ A builder failure is a console read gone wrong -- the same
388
+ best-effort classification the loop-events derivation has: warn and
389
+ let the pass complete. In dry-run the snapshot is still BUILT (reads
390
+ only: the ledger, tmux, /proc, the two cached checks) and described,
391
+ so the operator sees what would be sent; the POST is the act dry-run
392
+ suppresses.
393
+ """
394
+ try:
395
+ snapshot = self.build(heartbeat_at=heartbeat_at, queue_depth=queue_depth)
396
+ except Exception as exc:
397
+ log.warning(
398
+ "fleet-vitals: snapshot build failed (%s: %s) — skipping this "
399
+ "pass's vitals; the loop keeps polling",
400
+ type(exc).__name__, exc,
401
+ )
402
+ return VITALS_FAILED
403
+ if self.config.dry_run:
404
+ log.info("[dry-run] would push fleet vitals (%s)", describe(snapshot))
405
+ return VITALS_SKIPPED
406
+ try:
407
+ result = self._client.post_fleet_vitals(snapshot)
408
+ except AlissaAuthError as exc:
409
+ if exc.code == NOT_ACTIVATED:
410
+ self._note_not_activated(exc)
411
+ return VITALS_FAILED
412
+ self._warn_failed(exc)
413
+ return VITALS_FAILED
414
+ except AlissaError as exc:
415
+ self._warn_failed(exc)
416
+ return VITALS_FAILED
417
+ log.info(
418
+ "fleet-vitals: pushed (%s; replaced=%s)",
419
+ describe(snapshot), result.get("replaced"),
420
+ )
421
+ return VITALS_PUSHED
422
+
423
+ @staticmethod
424
+ def _warn_failed(exc: AlissaError) -> None:
425
+ # ONE warn per failed pass, naming the status and the error; the API
426
+ # keeps only the latest snapshot, so the next pass's push IS the
427
+ # retry and nothing is queued.
428
+ log.warning(
429
+ "fleet-vitals: push failed (status %s%s: %s) — vitals are "
430
+ "best-effort, the pass completes; the next pass sends a fresh "
431
+ "snapshot",
432
+ exc.status or "transport",
433
+ f" {exc.code}" if exc.code else "",
434
+ exc.detail,
435
+ )
436
+
437
+ def _note_not_activated(self, exc: AlissaAuthError) -> None:
438
+ detail = exc.detail if isinstance(exc.detail, dict) else {}
439
+ activate = detail.get("activateUrl") or "the activate URL in the API's response"
440
+ if self._not_activated_warned:
441
+ log.debug(
442
+ "fleet-vitals: still refused — the loop app is not activated "
443
+ "for this token's user (403 not_activated); activate at %s",
444
+ activate,
445
+ )
446
+ return
447
+ self._not_activated_warned = True
448
+ log.warning(
449
+ "fleet-vitals: Studio refused the push — the loop app is not "
450
+ "activated for this token's user (HTTP 403 not_activated). "
451
+ "Activate it at %s and the next pass lands on its own. Reported "
452
+ "once per boot; later refusals log at DEBUG",
453
+ activate,
454
+ )
455
+
456
+
457
+ def _console_runner(github: object) -> Callable[..., str]:
458
+ """The subprocess runner the in-daemon `Sources` uses: `proc.run`, with
459
+ every `gh` call placed under the REVIEWER identity's credential the way
460
+ the daemon's own GitHub client does (`GitHub._env`), so the rate read is
461
+ the reviewer's bucket and not whatever the container inherited. A
462
+ reviewer token the client cannot resolve surfaces as a CommandError,
463
+ which `Sources` degrades to a null rate -- honest, rather than a read
464
+ under the wrong login."""
465
+ env_of = getattr(github, "_env", None)
466
+
467
+ def run(argv: "list[str]", *, timeout: int = 60, **kwargs: Any) -> str:
468
+ env = None
469
+ if argv and argv[0] == "gh" and callable(env_of):
470
+ try:
471
+ env = env_of()
472
+ except Exception as exc:
473
+ raise CommandError(argv, -1, str(exc)) from None
474
+ return proc_run(argv, timeout=timeout, env=env, **kwargs)
475
+
476
+ return run
477
+
478
+
479
+ def build_pusher(
480
+ config: Config,
481
+ *,
482
+ github: object = None,
483
+ endpoint: "str | None" = None,
484
+ ) -> FleetVitalsPusher:
485
+ """The pusher the watcher wires in when `fleet_vitals_enabled` is on.
486
+
487
+ A seam, so tests build pushers over fake sources and clients while the
488
+ watcher's call stays one line. The `Sources` is the console's own data
489
+ layer over the daemon's config -- the sidecar need not be running -- and
490
+ the token comes from the environment inside `AlissaClient` (the CLI's
491
+ own `ALISSA_API_TOKEN`); its absence surfaces as the pusher's WARN, never
492
+ at construction, because a daemon must boot and poll whether or not
493
+ vitals can authenticate. The import is local because `sources` imports
494
+ `loop`, which imports this module.
495
+ """
496
+ from .version import version
497
+ from .webui.sources import Sources
498
+
499
+ sources = Sources(
500
+ config=config,
501
+ running_version=version.value,
502
+ run=_console_runner(github),
503
+ )
504
+ return FleetVitalsPusher(config, sources, AlissaClient(base=endpoint))
@@ -59,6 +59,12 @@ from .ghclient import (
59
59
  countable_rounds,
60
60
  verdict_marker,
61
61
  )
62
+ from .fleet_vitals import (
63
+ VITALS_FAILED,
64
+ VITALS_SKIPPED,
65
+ FleetVitalsPusher,
66
+ build_pusher,
67
+ )
62
68
  from .loop_events import LoopEventsEmitter, build_emitter
63
69
  from .proc import CommandError
64
70
  from .state import State
@@ -1630,6 +1636,18 @@ class ReviewWatcher:
1630
1636
  if config.loop_events_enabled
1631
1637
  else None
1632
1638
  )
1639
+ # The Studio fleet-vitals push (issue #126), or None when the knob
1640
+ # is off — same posture as the loop-events emitter above: nothing is
1641
+ # built, read or logged for the disabled default. The pusher owns
1642
+ # its own in-process `Sources` (the console's data layer), so the
1643
+ # console sidecar need not be running.
1644
+ self._fleet_vitals: FleetVitalsPusher | None = (
1645
+ build_pusher(
1646
+ config, github=self.github, endpoint=config.alissa_endpoint
1647
+ )
1648
+ if config.fleet_vitals_enabled
1649
+ else None
1650
+ )
1633
1651
  # (repo, number, comment id) of every re-entry directive already
1634
1652
  # refused in this process -- see _log_ignored_ack.
1635
1653
  self._ignored_acks: set[tuple[str, int, int]] = set()
@@ -5002,10 +5020,17 @@ class ReviewWatcher:
5002
5020
  # calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
5003
5021
  # not a side effect the daemon takes, so a future console sees dry-run
5004
5022
  # passes as well as live ones.
5005
- self._write_snapshot(
5006
- results, reaped, duration_ms=int((time.monotonic() - started) * 1000)
5007
- )
5023
+ duration_ms = int((time.monotonic() - started) * 1000)
5024
+ self._write_snapshot(results, reaped, duration_ms=duration_ms)
5008
5025
  self._emit_loop_events()
5026
+ # AFTER the loop-events push, and after the snapshot, so the vitals
5027
+ # heartbeat is the completion time of a pass whose exhaust is already
5028
+ # written and whose events are already on their way.
5029
+ vitals = self._push_fleet_vitals(completed_at=time.time())
5030
+ log.info(
5031
+ "poll summary: %d candidate(s), %d reaped, %d ms, vitals: %s",
5032
+ len(results), reaped, duration_ms, vitals,
5033
+ )
5009
5034
  return results
5010
5035
 
5011
5036
  def _emit_loop_events(self) -> None:
@@ -5033,6 +5058,35 @@ class ReviewWatcher:
5033
5058
  type(exc).__name__, exc,
5034
5059
  )
5035
5060
 
5061
+ def _push_fleet_vitals(self, *, completed_at: float) -> str:
5062
+ """Push this pass's fleet-vitals snapshot to Studio, when enabled
5063
+ (issue #126). Returns the outcome word the poll summary prints:
5064
+ `pushed`, `skipped` (disabled, or dry-run -- the pusher logs what it
5065
+ WOULD send and sends nothing) or `failed`.
5066
+
5067
+ `queue_depth` is the spawn gate's waiting set at the end of the pass:
5068
+ every round this pass deferred for a slot, pruned to live candidates
5069
+ at the pass's start and drained as slots free -- the "owed rounds
5070
+ waiting on `max_concurrent_sessions`" the Factory card shows.
5071
+
5072
+ The pusher itself never raises for an API condition (one WARNING, the
5073
+ pass completes); the guard here is the same never-fatal promise held
5074
+ against a code defect, so vitals can never take down a poll.
5075
+ """
5076
+ if self._fleet_vitals is None:
5077
+ return VITALS_SKIPPED
5078
+ try:
5079
+ return self._fleet_vitals.push_once(
5080
+ heartbeat_at=completed_at, queue_depth=len(self._waiting)
5081
+ )
5082
+ except Exception as exc:
5083
+ log.warning(
5084
+ "fleet-vitals: pusher failed unexpectedly (%s: %s) — vitals "
5085
+ "are best-effort, the pass completes",
5086
+ type(exc).__name__, exc,
5087
+ )
5088
+ return VITALS_FAILED
5089
+
5036
5090
  def _note_ledger_unwritable(self) -> None:
5037
5091
  """Report a pass refused because the ledger cannot record it.
5038
5092
 
@@ -360,8 +360,27 @@ class Sources:
360
360
  spawns: "list[dict] | None" = None,
361
361
  index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
362
362
  ) -> "list[dict]":
363
+ """The managed-session table -- `session_rows`, with an unlistable
364
+ roster degraded to the empty table the dashboard has always rendered
365
+ (a panel cannot draw None). A caller that must tell "nobody is live"
366
+ from "could not list" reads `session_rows` directly."""
367
+ return self.session_rows(spawns, index) or []
368
+
369
+ def session_rows(
370
+ self,
371
+ spawns: "list[dict] | None" = None,
372
+ index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
373
+ ) -> "list[dict] | None":
363
374
  """The managed-session table: liveness from `alissa tmux ls`, footprint
364
- from /proc, and the PR round each session is reviewing.
375
+ from /proc, and the PR round each session is reviewing -- or None
376
+ when the roster could not be LISTED at all.
377
+
378
+ None is a distinct answer from `[]`, and the fleet-vitals snapshot
379
+ (issue #126) depends on the distinction: it reports `sessions: null`
380
+ for a roster it could not read and must never report `{0, 0}` for
381
+ it, because a Factory card reading "0 live" over a broken tmux would
382
+ be the reassuring-direction error. A listing that returned anything
383
+ other than a JSON list is "could not list" too.
365
384
 
366
385
  The round comes from the spawn ledger (session name is its primary
367
386
  key), not from the session name itself: the name carries a nonce and
@@ -379,9 +398,9 @@ class Sources:
379
398
  table) the old lazy build is unchanged and a table with no live pane
380
399
  still never scans `/proc`.
381
400
  """
382
- raw = self._safe_json(["alissa", "tmux", "ls", "--json"]) or []
401
+ raw = self._safe_json(["alissa", "tmux", "ls", "--json"])
383
402
  if not isinstance(raw, list):
384
- return []
403
+ return None
385
404
  if spawns is not None:
386
405
  by_session = {row["session"]: row for row in spawns}
387
406
  else:
@@ -443,6 +462,16 @@ class Sources:
443
462
  )
444
463
  return out
445
464
 
465
+ def memory(self) -> dict:
466
+ """The container's cgroup memory split (`sysinfo.cgroup_memory`) plus
467
+ its hard `limit` (`sysinfo.cgroup_memory_limit`), every field None
468
+ off a host without cgroup v2. The fleet-vitals builder's read (issue
469
+ #126); the dashboard tile keeps reading `cgroup_memory` directly, so
470
+ its payload is untouched."""
471
+ out = dict(sysinfo.cgroup_memory(self._cgroup_root))
472
+ out["limit"] = sysinfo.cgroup_memory_limit(self._cgroup_root)
473
+ return out
474
+
446
475
  # -- two cached remote checks ------------------------------------------
447
476
 
448
477
  def rate_limit(self) -> "dict | None":
@@ -602,12 +631,7 @@ class Sources:
602
631
  # built once, so the two panels can never disagree about which PRs the
603
632
  # newest pass still had in hand.
604
633
  items = self._pipeline(latest)
605
- inbox = self._inbox(
606
- ledgers["escalations"],
607
- ledgers["pings"],
608
- ledgers.get("stability_pings", []),
609
- live_prs=self._live_prs(latest, items),
610
- )
634
+ inbox = self._inbox_for(latest, items, ledgers)
611
635
  sparklines = {
612
636
  "poll_duration_ms": [s["duration_ms"] for s in chrono],
613
637
  "active_sessions": [s["in_flight"] + s["deferred"] for s in chrono],
@@ -678,6 +702,31 @@ class Sources:
678
702
  "log": self.log_tail(),
679
703
  }
680
704
 
705
+ def inbox(self) -> dict:
706
+ """The operator inbox on its own -- the `_inbox` split (`live`,
707
+ `settled`, `settled_dropped`, `truncated`) built exactly as the
708
+ dashboard builds it, off the newest snapshot and the two inbox
709
+ ledgers, for a caller that wants no other panel. The fleet-vitals
710
+ builder's read (issue #126): the snapshot carries the LIVE half, so
711
+ the Factory card and this console can never disagree about what the
712
+ operator still owes."""
713
+ snaps = self.snapshots(1)
714
+ latest = snaps[0] if snaps else None
715
+ return self._inbox_for(latest, self._pipeline(latest), self.ledgers())
716
+
717
+ def _inbox_for(
718
+ self, latest: "dict | None", items: "list[dict]", ledgers: dict
719
+ ) -> dict:
720
+ """`_inbox` over the two ledgers, with the liveness oracle derived
721
+ from `items` -- the one place the dashboard and `inbox()` share, so
722
+ the two cannot drift on which rows are live."""
723
+ return self._inbox(
724
+ ledgers["escalations"],
725
+ ledgers["pings"],
726
+ ledgers.get("stability_pings", []),
727
+ live_prs=self._live_prs(latest, items),
728
+ )
729
+
681
730
  def _pipeline(self, latest: "dict | None") -> "list[dict]":
682
731
  """The PR-centric board: one row per PR the newest poll pass saw.
683
732
 
@@ -62,6 +62,10 @@ _TAIL_MIN_LEN = 22
62
62
  # real footprint and a charge the kernel would give back under pressure.
63
63
  _CGROUP_CURRENT = "memory.current"
64
64
  _CGROUP_STAT = "memory.stat"
65
+ # The container's hard memory limit. cgroup v2 writes the literal `max` when
66
+ # there is none, which `_read_int_file` reports as None -- the right answer
67
+ # for "no limit", and the same answer as "no cgroup v2 at all".
68
+ _CGROUP_MAX = "memory.max"
65
69
  _CGROUP_STAT_KEYS = (
66
70
  "anon",
67
71
  "file",
@@ -340,6 +344,21 @@ def cgroup_memory(
340
344
  return out
341
345
 
342
346
 
347
+ def cgroup_memory_limit(
348
+ cgroup_root: "str | os.PathLike[str]" = "/sys/fs/cgroup",
349
+ ) -> "int | None":
350
+ """The container's hard memory limit in bytes (`memory.max`), or None
351
+ when it is unlimited (`max`), unreadable, or there is no cgroup v2.
352
+
353
+ Separate from `cgroup_memory` on purpose: that payload is the console
354
+ tile's, pinned key-for-key by its tests and rendered by the page, and
355
+ the fleet-vitals snapshot (issue #126) is the only reader that wants
356
+ the limit -- the Factory draws a resident-over-limit meter from it.
357
+ Never raises, like every read here.
358
+ """
359
+ return _read_int_file(Path(cgroup_root) / _CGROUP_MAX)
360
+
361
+
343
362
  def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
344
363
  """Workspace volume usage for the stat tile, or None if the path is
345
364
  unreadable. Percent is used/total, rounded -- the meter the console fills."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.29.0
3
+ Version: 0.30.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -10,6 +10,7 @@ src/main/alissa/tools/github/revloop/alissa.py
10
10
  src/main/alissa/tools/github/revloop/alissa_client.py
11
11
  src/main/alissa/tools/github/revloop/bows.py
12
12
  src/main/alissa/tools/github/revloop/config.py
13
+ src/main/alissa/tools/github/revloop/fleet_vitals.py
13
14
  src/main/alissa/tools/github/revloop/ghclient.py
14
15
  src/main/alissa/tools/github/revloop/loop.py
15
16
  src/main/alissa/tools/github/revloop/loop_events.py