alissa-tools-github-devloop 0.8.20__tar.gz → 0.8.22__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.
- {alissa_tools_github_devloop-0.8.20/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.22}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/__main__.py +29 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/alissa_client.py +26 -4
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/config.py +57 -17
- alissa_tools_github_devloop-0.8.22/src/main/alissa/tools/github/devloop/fleet_vitals.py +390 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/loop.py +64 -12
- alissa_tools_github_devloop-0.8.22/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/sources.py +12 -2
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +96 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.22}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -312,6 +312,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
312
312
|
"(the default: the push is opt-in)",
|
|
313
313
|
)
|
|
314
314
|
|
|
315
|
+
fleet_vitals = over.add_mutually_exclusive_group()
|
|
316
|
+
fleet_vitals.add_argument(
|
|
317
|
+
"--fleet-vitals",
|
|
318
|
+
dest="fleet_vitals_enabled",
|
|
319
|
+
action="store_true",
|
|
320
|
+
default=None,
|
|
321
|
+
help="push one console snapshot (version/drift, pass durations, the "
|
|
322
|
+
"tmux roster with CPU/RSS, GitHub rate, memory, queue depth, inbox) "
|
|
323
|
+
"per poll pass to Studio's POST /v1/loop/fleet-vitals on the alissa "
|
|
324
|
+
"endpoint, after the loop-events push — best-effort, never fatal; "
|
|
325
|
+
"the console sidecar need not run. ALISSA_DEV_FLEET_VITALS_ENABLED "
|
|
326
|
+
"(non-blank) overrides even this flag",
|
|
327
|
+
)
|
|
328
|
+
fleet_vitals.add_argument(
|
|
329
|
+
"--no-fleet-vitals",
|
|
330
|
+
dest="fleet_vitals_enabled",
|
|
331
|
+
action="store_false",
|
|
332
|
+
help="never push fleet vitals, even if the config enables it "
|
|
333
|
+
"(the default: the push is opt-in)",
|
|
334
|
+
)
|
|
335
|
+
|
|
315
336
|
janitor = over.add_mutually_exclusive_group()
|
|
316
337
|
janitor.add_argument(
|
|
317
338
|
"--janitor",
|
|
@@ -418,6 +439,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
418
439
|
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
419
440
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
420
441
|
"loop_events_enabled": args.loop_events_enabled,
|
|
442
|
+
"fleet_vitals_enabled": args.fleet_vitals_enabled,
|
|
421
443
|
"janitor_enabled": args.janitor_enabled,
|
|
422
444
|
"janitor_grace_minutes": args.janitor_grace_minutes,
|
|
423
445
|
"janitor_interval_minutes": args.janitor_interval_minutes,
|
|
@@ -539,6 +561,13 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
539
561
|
f"/v1/loop-events, best-effort)"
|
|
540
562
|
if config.loop_events_enabled else "",
|
|
541
563
|
)
|
|
564
|
+
log.info(
|
|
565
|
+
"fleet_vitals=%s%s",
|
|
566
|
+
config.fleet_vitals_enabled,
|
|
567
|
+
f" (one snapshot per pass → POST {config.alissa_endpoint}"
|
|
568
|
+
f"/v1/loop/fleet-vitals, best-effort)"
|
|
569
|
+
if config.fleet_vitals_enabled else "",
|
|
570
|
+
)
|
|
542
571
|
log.debug("hub_template: %s", config.hub_template)
|
|
543
572
|
log.debug(
|
|
544
573
|
"cache_paths: %s",
|
|
@@ -22,10 +22,11 @@ already authenticates — the same transport, endpoints and taxonomy orcloop's
|
|
|
22
22
|
`urllib` only: the distribution ships no third-party runtime dependency, and
|
|
23
23
|
this must not be the change that adds one.
|
|
24
24
|
|
|
25
|
-
Since issue #101 the adapter also carries the daemon's
|
|
26
|
-
``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Since issue #101 the adapter also carries the daemon's writes —
|
|
26
|
+
``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push, and
|
|
27
|
+
since issue #116 ``POST /v1/loop/fleet-vitals`` (`post_fleet_vitals`), the
|
|
28
|
+
per-pass vitals snapshot — over the same transport, token and taxonomy, so
|
|
29
|
+
the daemon still reads and writes Alissa through a single REST idiom.
|
|
29
30
|
|
|
30
31
|
Endpoints, the reads both verified against the live API:
|
|
31
32
|
|
|
@@ -62,6 +63,7 @@ import os
|
|
|
62
63
|
import urllib.error
|
|
63
64
|
import urllib.request
|
|
64
65
|
from dataclasses import dataclass
|
|
66
|
+
from typing import Mapping
|
|
65
67
|
|
|
66
68
|
log = logging.getLogger(__name__)
|
|
67
69
|
|
|
@@ -262,6 +264,26 @@ class AlissaClient:
|
|
|
262
264
|
)
|
|
263
265
|
return payload if isinstance(payload, dict) else {}
|
|
264
266
|
|
|
267
|
+
def post_fleet_vitals(self, snapshot: "Mapping[str, object]") -> "dict":
|
|
268
|
+
"""Push one fleet-vitals snapshot (issue #116).
|
|
269
|
+
|
|
270
|
+
``POST /v1/loop/fleet-vitals`` (fahera-mx/studio.alissa.app,
|
|
271
|
+
TASK-182095034): the body is ONE snapshot for ONE seat, and the
|
|
272
|
+
server REPLACES the caller's previous snapshot for that seat — no
|
|
273
|
+
history, no dedupe key. The shape, the 50-entry list caps and the
|
|
274
|
+
64 KB body cap are the CALLER's contract (`fleet_vitals.py` builds
|
|
275
|
+
and trims); this is the transport, returning the server's
|
|
276
|
+
``{"seat", "receivedAt", "replaced"}`` verbatim.
|
|
277
|
+
|
|
278
|
+
Failures leave as the taxonomy like every other call. The soft
|
|
279
|
+
activation gate answers ``403 {"error": "not_activated",
|
|
280
|
+
"activateUrl": …}`` — an `AlissaAuthError` whose `code` the caller
|
|
281
|
+
reads to warn once per boot rather than once per pass."""
|
|
282
|
+
payload = self._request(
|
|
283
|
+
"/v1/loop/fleet-vitals", method="POST", body=dict(snapshot)
|
|
284
|
+
)
|
|
285
|
+
return payload if isinstance(payload, dict) else {}
|
|
286
|
+
|
|
265
287
|
def list_bodies_of_work(self) -> "list[BodyOfWork]":
|
|
266
288
|
"""Every Body of Work this actor owns OR collaborates on — ONE call.
|
|
267
289
|
|
|
@@ -22,7 +22,8 @@ var > `--repos-source` > the config key. A BLANK env var means "not set" here
|
|
|
22
22
|
platform variable reference renders as the empty string, and that must fall
|
|
23
23
|
through to the config rather than fail the daemon's boot on a mode of `''`.
|
|
24
24
|
`loop_events_enabled` rides the same shape of rail through
|
|
25
|
-
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule
|
|
25
|
+
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule, and
|
|
26
|
+
`fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
29
|
from __future__ import annotations
|
|
@@ -102,9 +103,38 @@ DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
|
102
103
|
# staring at a variable that does nothing.
|
|
103
104
|
ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
|
|
104
105
|
|
|
106
|
+
# The fleet-vitals rail (issue #116): `fleet_vitals_enabled` ridden exactly
|
|
107
|
+
# the way ENV_LOOP_EVENTS rides loop_events_enabled — same spellings, same
|
|
108
|
+
# non-blank-wins / blank-falls-through rule, same config error on a typo.
|
|
109
|
+
ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
|
|
110
|
+
|
|
105
111
|
_ENV_BOOL_TRUE = frozenset(("1", "true"))
|
|
106
112
|
_ENV_BOOL_FALSE = frozenset(("0", "false"))
|
|
107
113
|
|
|
114
|
+
|
|
115
|
+
def _env_bool_rail(
|
|
116
|
+
environ: "Mapping[str, str]", name: str, value: bool
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""`value` unless env var `name` is set NON-BLANK, in which case the
|
|
119
|
+
variable decides: 1/true or 0/false, case-insensitive, anything else a
|
|
120
|
+
config error naming the variable (a typo silently read as false would
|
|
121
|
+
leave an operator staring at a variable that does nothing). Blank falls
|
|
122
|
+
through — an unset platform variable reference renders as "" and must not
|
|
123
|
+
decide anything. The one rule behind ENV_LOOP_EVENTS and
|
|
124
|
+
ENV_FLEET_VITALS, stated once."""
|
|
125
|
+
raw = environ.get(name, "").strip()
|
|
126
|
+
if not raw:
|
|
127
|
+
return value
|
|
128
|
+
lowered = raw.casefold()
|
|
129
|
+
if lowered in _ENV_BOOL_TRUE:
|
|
130
|
+
return True
|
|
131
|
+
if lowered in _ENV_BOOL_FALSE:
|
|
132
|
+
return False
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"{name} must be one of 1/true/0/false (case-insensitive), got {raw!r}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
108
138
|
# What an Alissa actor id looks like: a 32-character opaque handle of
|
|
109
139
|
# lower-case letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id
|
|
110
140
|
# the API mints has this shape, and `bow_owners` is checked against it so a
|
|
@@ -157,6 +187,7 @@ CONFIG_KEYS = (
|
|
|
157
187
|
"orphan_grace_minutes",
|
|
158
188
|
"activity_claims_enabled",
|
|
159
189
|
"loop_events_enabled",
|
|
190
|
+
"fleet_vitals_enabled",
|
|
160
191
|
"janitor_enabled",
|
|
161
192
|
"janitor_grace_minutes",
|
|
162
193
|
"janitor_interval_minutes",
|
|
@@ -557,6 +588,21 @@ class Config:
|
|
|
557
588
|
# wins over the CLI and the file, repos_source's rail exactly.
|
|
558
589
|
loop_events_enabled: bool = False
|
|
559
590
|
|
|
591
|
+
# The fleet-vitals push (issue #116): at the end of each poll pass, AFTER
|
|
592
|
+
# the loop-events push, POST one console snapshot — version/drift, recent
|
|
593
|
+
# pass durations, the tmux roster with per-session CPU/RSS, the cached
|
|
594
|
+
# GitHub rate, container memory, queue depth and the operator inbox — to
|
|
595
|
+
# Studio's /v1/loop/fleet-vitals on `alissa_endpoint`, over the same REST
|
|
596
|
+
# client and token. The Factory's /fleet renders it for a deployment it
|
|
597
|
+
# cannot pull from. Built in-process from the worker console's own
|
|
598
|
+
# builders, so the console sidecar need not run. Same posture as the
|
|
599
|
+
# loop events: best-effort, never fatal (one WARNING per failed pass, no
|
|
600
|
+
# retry queue — the next pass sends a fresher snapshot), nothing sent
|
|
601
|
+
# under dry_run. Default OFF; disabled, no request is ever made.
|
|
602
|
+
# ALISSA_DEV_FLEET_VITALS_ENABLED (non-blank) wins over the CLI and the
|
|
603
|
+
# file, loop_events_enabled's rail exactly.
|
|
604
|
+
fleet_vitals_enabled: bool = False
|
|
605
|
+
|
|
560
606
|
# The workspace janitor (issue #74): the filesystem sibling of the session
|
|
561
607
|
# reaper. Every worker session materializes a per-task workspace under its
|
|
562
608
|
# repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
|
|
@@ -837,23 +883,16 @@ class Config:
|
|
|
837
883
|
# ALISSA_DEV_LOOP_EVENTS_ENABLED wins over the CLI and the file, and
|
|
838
884
|
# blank falls through. Validated by NAME like the enums — a typo read
|
|
839
885
|
# as false would silently disable a push the operator asked for.
|
|
840
|
-
|
|
841
|
-
|
|
886
|
+
env_map = os.environ if environ is None else environ
|
|
887
|
+
loop_events = _env_bool_rail(
|
|
888
|
+
env_map, ENV_LOOP_EVENTS,
|
|
889
|
+
bool(raw.get("loop_events_enabled", cls.loop_events_enabled)),
|
|
890
|
+
)
|
|
891
|
+
# The fleet-vitals rail (issue #116), the same shape again.
|
|
892
|
+
fleet_vitals = _env_bool_rail(
|
|
893
|
+
env_map, ENV_FLEET_VITALS,
|
|
894
|
+
bool(raw.get("fleet_vitals_enabled", cls.fleet_vitals_enabled)),
|
|
842
895
|
)
|
|
843
|
-
env_loop_events = (os.environ if environ is None else environ).get(
|
|
844
|
-
ENV_LOOP_EVENTS, ""
|
|
845
|
-
).strip()
|
|
846
|
-
if env_loop_events:
|
|
847
|
-
lowered = env_loop_events.casefold()
|
|
848
|
-
if lowered in _ENV_BOOL_TRUE:
|
|
849
|
-
loop_events = True
|
|
850
|
-
elif lowered in _ENV_BOOL_FALSE:
|
|
851
|
-
loop_events = False
|
|
852
|
-
else:
|
|
853
|
-
raise ValueError(
|
|
854
|
-
f"{ENV_LOOP_EVENTS} must be one of 1/true/0/false "
|
|
855
|
-
f"(case-insensitive), got {env_loop_events!r}"
|
|
856
|
-
)
|
|
857
896
|
|
|
858
897
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
859
898
|
|
|
@@ -1083,6 +1122,7 @@ class Config:
|
|
|
1083
1122
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
1084
1123
|
),
|
|
1085
1124
|
loop_events_enabled=loop_events,
|
|
1125
|
+
fleet_vitals_enabled=fleet_vitals,
|
|
1086
1126
|
janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
|
|
1087
1127
|
janitor_grace_minutes=janitor_grace,
|
|
1088
1128
|
janitor_interval_minutes=janitor_interval,
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Fleet vitals — the Studio push of one console snapshot per pass (issue #116).
|
|
2
|
+
|
|
3
|
+
The Factory's `/fleet` screen PULLS each daemon console's `/api/state` over
|
|
4
|
+
private networking with passcodes held on the hosted Factory — per-deployment
|
|
5
|
+
by construction, so a Dark Factory customer running their own devloop sees
|
|
6
|
+
`not_configured`. Studio's ``POST /v1/loop/fleet-vitals`` (fahera-mx/
|
|
7
|
+
studio.alissa.app, TASK-182095034) turns that into a PUSH: one snapshot per
|
|
8
|
+
user×seat, replaced on every POST, rendered by the Factory whenever it has no
|
|
9
|
+
console URL for the seat. This module is the devloop half: at the end of every
|
|
10
|
+
completed poll pass, build the snapshot and POST it once.
|
|
11
|
+
|
|
12
|
+
Three rules, all downstream of "the console sidecar does not need to run":
|
|
13
|
+
|
|
14
|
+
* **Built in-process from the console's own builders.** The snapshot is the
|
|
15
|
+
worker console's dashboard payload, re-shaped to the wire contract:
|
|
16
|
+
`webui.sources.Sources` (ledgers, roster, cached GitHub rate, drift) and
|
|
17
|
+
`webui.sysinfo` (per-session CPU/RSS, memory) do the reading. Nothing is
|
|
18
|
+
measured twice — the pass's own ``alissa tmux ls`` listing (the reaper's,
|
|
19
|
+
see `DevWatcher._sweep_and_budget`) is the roster, handed in rather than
|
|
20
|
+
re-listed — and the GitHub rate read keeps the console's 60 s cache, so an
|
|
21
|
+
enabled push adds no ``gh api rate_limit`` per pass.
|
|
22
|
+
* **Same posture as loop events.** Opt-in (`fleet_vitals_enabled`, default
|
|
23
|
+
off), best-effort, never fatal: a failed POST is ONE WARNING naming the
|
|
24
|
+
status and the pass completes. No retry queue — the next pass sends a
|
|
25
|
+
fresher snapshot, which is the whole point of a snapshot. `403
|
|
26
|
+
not_activated` (the operator has not activated the stacked app) warns once
|
|
27
|
+
per boot with the activate URL and then drops to DEBUG. Under `dry_run`
|
|
28
|
+
nothing is sent.
|
|
29
|
+
* **Built to the contract exactly.** The API is STRICT (an unknown top-level
|
|
30
|
+
key is a 400) and caps `sessionList` and `inbox` at LIST_CAP entries and the
|
|
31
|
+
body at BODY_MAX bytes, so the caps are applied HERE, before sending.
|
|
32
|
+
Optional fields ride as explicit nulls, never as missing keys, so the body
|
|
33
|
+
shape is the same every pass.
|
|
34
|
+
|
|
35
|
+
The roster rule the contract cares most about: `sessions` and `sessionList`
|
|
36
|
+
are `null` — not `{live: 0, managed: 0}` — when the listing could not be
|
|
37
|
+
read. A machine whose tmux blinked is not an idle machine, and the Factory
|
|
38
|
+
must be able to tell the two apart.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import time
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
from typing import Callable, Mapping, Sequence
|
|
48
|
+
|
|
49
|
+
from .alissa_client import AlissaAuthError, AlissaClient, AlissaError
|
|
50
|
+
from .config import Config
|
|
51
|
+
from .version import version as running_version
|
|
52
|
+
from .webui import sources as sources_mod
|
|
53
|
+
from .webui import sysinfo
|
|
54
|
+
from .webui.sources import Sources
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
SEAT = "devloop"
|
|
59
|
+
SCHEMA_VERSION = 1
|
|
60
|
+
ENDPOINT_PATH = "/v1/loop/fleet-vitals"
|
|
61
|
+
|
|
62
|
+
# The ingest contract's caps: 50 entries per list (a 51st is a 400 naming the
|
|
63
|
+
# path) and 64 KB for the whole body. Mirrors Studio's FLEET_VITALS_LIST_CAP.
|
|
64
|
+
LIST_CAP = 50
|
|
65
|
+
BODY_MAX = 64 * 1024
|
|
66
|
+
|
|
67
|
+
# The error code Studio's soft gate answers a POST with when the operator has
|
|
68
|
+
# not activated the stacked app. Permanent until the operator acts, so it is
|
|
69
|
+
# warned once per boot (with the URL the answer carries) and DEBUG after.
|
|
70
|
+
NOT_ACTIVATED = "not_activated"
|
|
71
|
+
|
|
72
|
+
# The pass-outcome tokens the poll summary quotes (`vitals: …`).
|
|
73
|
+
PUSHED = "pushed"
|
|
74
|
+
SKIPPED = "skipped"
|
|
75
|
+
FAILED = "failed"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _session_grammar() -> "Callable[[str], object]":
|
|
79
|
+
"""The daemon's own session-name parser, resolved late.
|
|
80
|
+
|
|
81
|
+
`loop` imports this module (it owns the pass hook), so a top-level import
|
|
82
|
+
of `loop.parse_worker_session` here would be a cycle. The grammar stays
|
|
83
|
+
stated ONCE, in `loop`, and is read at first use."""
|
|
84
|
+
from .loop import parse_worker_session
|
|
85
|
+
return parse_worker_session
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def iso(ts: "float | int") -> str:
|
|
89
|
+
"""A unix timestamp as the contract's ISO-8601 UTC instant (whole
|
|
90
|
+
seconds, `Z` suffix)."""
|
|
91
|
+
return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime(
|
|
92
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- the snapshot ---------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _session_row(
|
|
100
|
+
entry: "Mapping",
|
|
101
|
+
*,
|
|
102
|
+
repo_by_session: "Mapping[str, str]",
|
|
103
|
+
parse_session: "Callable[[str], object]",
|
|
104
|
+
) -> "dict":
|
|
105
|
+
"""One `sessionList` row from one console session record
|
|
106
|
+
(`Sources.sessions` shape). The name is the FULL tmux session (the
|
|
107
|
+
managed name under its `ali-` prefix — what an operator would `tmux
|
|
108
|
+
attach` to); edge, number and attempt come from devloop's own grammar
|
|
109
|
+
(a name that does not parse is a stranger's: edge/number/attempt null);
|
|
110
|
+
the repo comes from the ledger row behind the name, never from the name
|
|
111
|
+
slug (which is lossy), and the URL follows the repo."""
|
|
112
|
+
managed_name = str(entry.get("name") or "")
|
|
113
|
+
worker = parse_session(managed_name) if managed_name else None
|
|
114
|
+
edge = number = attempt = None
|
|
115
|
+
if worker is not None:
|
|
116
|
+
edge = str(worker.edge) # type: ignore[attr-defined]
|
|
117
|
+
number = int(worker.number) # type: ignore[attr-defined]
|
|
118
|
+
attempt = int(worker.attempt) # type: ignore[attr-defined]
|
|
119
|
+
repo = repo_by_session.get(managed_name)
|
|
120
|
+
url = None
|
|
121
|
+
if repo and number is not None:
|
|
122
|
+
leaf = "issues" if edge == "develop" else "pull"
|
|
123
|
+
url = f"https://github.com/{repo}/{leaf}/{number}"
|
|
124
|
+
return {
|
|
125
|
+
"name": str(entry.get("session") or managed_name),
|
|
126
|
+
"edge": edge,
|
|
127
|
+
"repo": repo,
|
|
128
|
+
"number": number,
|
|
129
|
+
"round": None,
|
|
130
|
+
"attempt": attempt,
|
|
131
|
+
"ageS": entry.get("age_seconds"),
|
|
132
|
+
"cpu": entry.get("cpu_percent"),
|
|
133
|
+
"rssBytes": entry.get("rss_bytes"),
|
|
134
|
+
"live": bool(entry.get("live")),
|
|
135
|
+
"managed": True,
|
|
136
|
+
"url": url,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _inbox_row(row: "Mapping") -> "dict":
|
|
141
|
+
"""One `inbox` row from one console inbox record (`Sources._inbox`
|
|
142
|
+
shape). A foreign-session page has no artifact: its subject is the kind
|
|
143
|
+
(which names the session), repo and number null."""
|
|
144
|
+
kind = str(row.get("kind") or "")
|
|
145
|
+
foreign = kind.startswith(sources_mod.FOREIGN_SESSION_KIND)
|
|
146
|
+
repo = None if foreign else row.get("repo_slug")
|
|
147
|
+
number = None if foreign else row.get("number")
|
|
148
|
+
subject = kind if foreign else f"{repo}#{number}"
|
|
149
|
+
return {
|
|
150
|
+
"kind": kind,
|
|
151
|
+
"subject": subject,
|
|
152
|
+
"repo": repo,
|
|
153
|
+
"number": number,
|
|
154
|
+
"isPr": bool(row.get("is_pr")),
|
|
155
|
+
"ageS": row.get("age_seconds"),
|
|
156
|
+
"url": row.get("url"),
|
|
157
|
+
# The escalations ledger carries no lever column today; the field is
|
|
158
|
+
# on the contract for the seats that do, so it rides as null.
|
|
159
|
+
"lever": None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _rate(rate: "Mapping | None") -> "dict | None":
|
|
164
|
+
if not rate:
|
|
165
|
+
return None
|
|
166
|
+
reset = rate.get("reset")
|
|
167
|
+
return {
|
|
168
|
+
"remaining": rate.get("remaining"),
|
|
169
|
+
"limit": rate.get("limit"),
|
|
170
|
+
"resetAt": iso(reset) if isinstance(reset, (int, float)) else None,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _memory(mem: "Mapping | None") -> "dict | None":
|
|
175
|
+
if not mem:
|
|
176
|
+
return None
|
|
177
|
+
return {
|
|
178
|
+
"residentBytes": mem.get("resident_bytes"),
|
|
179
|
+
"reclaimableBytes": mem.get("reclaimable_bytes"),
|
|
180
|
+
"limitBytes": mem.get("limit_bytes"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_snapshot(
|
|
185
|
+
sources: Sources,
|
|
186
|
+
*,
|
|
187
|
+
roster: "Sequence[Mapping] | None",
|
|
188
|
+
heartbeat_at: "float | int",
|
|
189
|
+
now: "float | int | None" = None,
|
|
190
|
+
memory: "Mapping | None" = None,
|
|
191
|
+
parse_session: "Callable[[str], object] | None" = None,
|
|
192
|
+
) -> "dict":
|
|
193
|
+
"""The contract body for one pass, from the console's builders.
|
|
194
|
+
|
|
195
|
+
`roster` is the pass's own ``alissa tmux ls`` listing (raw rows), or None
|
|
196
|
+
when the listing failed — which is the ONE input that makes `sessions`
|
|
197
|
+
and `sessionList` null. `heartbeat_at` is the pass's completion time;
|
|
198
|
+
`now` (default: the same instant) stamps `asOf` and the ages. `memory` is
|
|
199
|
+
the `sysinfo.memory_usage` reading, taken by the caller so a test can
|
|
200
|
+
hand in a fixture. Pure assembly over the sources: the only IO is what
|
|
201
|
+
`Sources` does (state.db, `tmux list-panes` + `/proc` per live session,
|
|
202
|
+
the cached rate and PyPI reads)."""
|
|
203
|
+
parse = parse_session or _session_grammar()
|
|
204
|
+
now_ts = heartbeat_at if now is None else now
|
|
205
|
+
|
|
206
|
+
snaps = sources.snapshots(sources_mod.SPARK_POINTS)
|
|
207
|
+
latest = snaps[0] if snaps else None
|
|
208
|
+
ledgers = sources.ledgers()
|
|
209
|
+
|
|
210
|
+
repo_by_session: "dict[str, str]" = {}
|
|
211
|
+
for table in ("spawns", "fix_spawns", "maintain_spawns"):
|
|
212
|
+
for row in ledgers[table]:
|
|
213
|
+
session = row.get("session")
|
|
214
|
+
if session and session not in repo_by_session:
|
|
215
|
+
repo_by_session[str(session)] = str(row["repo_slug"])
|
|
216
|
+
|
|
217
|
+
sessions_block: "dict | None" = None
|
|
218
|
+
session_list: "list[dict] | None" = None
|
|
219
|
+
if roster is not None:
|
|
220
|
+
records = sources.sessions(listing=[dict(r) for r in roster])
|
|
221
|
+
sessions_block = {
|
|
222
|
+
"live": sum(1 for r in records if r.get("live")),
|
|
223
|
+
"managed": len(records),
|
|
224
|
+
}
|
|
225
|
+
session_list = [
|
|
226
|
+
_session_row(r, repo_by_session=repo_by_session, parse_session=parse)
|
|
227
|
+
for r in records[:LIST_CAP]
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
live, _settled = sources._partition_inbox(ledgers["escalations"], latest)
|
|
231
|
+
inbox_rows = sources._inbox(live)
|
|
232
|
+
# Newest first: the ledger reads newest-first already, but the order is
|
|
233
|
+
# part of the contract ("newest first, ≤ 50"), so it is enforced rather
|
|
234
|
+
# than inherited.
|
|
235
|
+
inbox_rows.sort(key=lambda r: r.get("age_seconds") or 0)
|
|
236
|
+
inbox = [_inbox_row(r) for r in inbox_rows[:LIST_CAP]]
|
|
237
|
+
|
|
238
|
+
durations = [s["duration_ms"] for s in reversed(snaps)] if snaps else None
|
|
239
|
+
|
|
240
|
+
queue_depth = None
|
|
241
|
+
if latest:
|
|
242
|
+
queue_depth = (
|
|
243
|
+
int(latest.get("issue_candidates") or 0)
|
|
244
|
+
+ int(latest.get("pr_candidates") or 0)
|
|
245
|
+
+ int(latest.get("maintain_candidates") or 0)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
drift = sources.drift()
|
|
249
|
+
return {
|
|
250
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
251
|
+
"seat": SEAT,
|
|
252
|
+
"asOf": iso(now_ts),
|
|
253
|
+
"heartbeatAt": iso(heartbeat_at),
|
|
254
|
+
"pollIntervalS": int(sources.config.poll_interval),
|
|
255
|
+
"version": drift["running"],
|
|
256
|
+
"drift": drift["latest"] if drift["state"] == "behind" else None,
|
|
257
|
+
"pollDurationsMs": durations,
|
|
258
|
+
"sessions": sessions_block,
|
|
259
|
+
"sessionList": session_list,
|
|
260
|
+
"rate": _rate(sources.rate_limit()),
|
|
261
|
+
"memory": _memory(memory),
|
|
262
|
+
"queueDepth": queue_depth,
|
|
263
|
+
"kpis": None,
|
|
264
|
+
"inbox": inbox,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def encoded_size(snapshot: "Mapping") -> int:
|
|
269
|
+
"""The body's wire size, as the client serialises it."""
|
|
270
|
+
return len(json.dumps(snapshot).encode("utf-8"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def fit_to_body_cap(snapshot: "dict", cap: int = BODY_MAX) -> "dict":
|
|
274
|
+
"""The snapshot trimmed until it fits the body cap.
|
|
275
|
+
|
|
276
|
+
Fifty sessions and fifty inbox rows are ~20 KB, so this is a backstop,
|
|
277
|
+
not the normal path. The lists shrink first (halving `sessionList`, then
|
|
278
|
+
`inbox`, alternately) and the durations series last; the scalar fields
|
|
279
|
+
are never touched. The counts in `sessions` stay whole — they describe
|
|
280
|
+
the roster, not the list."""
|
|
281
|
+
out = dict(snapshot)
|
|
282
|
+
while encoded_size(out) > cap:
|
|
283
|
+
for key in ("sessionList", "inbox", "pollDurationsMs"):
|
|
284
|
+
rows = out.get(key)
|
|
285
|
+
if rows:
|
|
286
|
+
keep = len(rows) // 2
|
|
287
|
+
out[key] = rows[-keep:] if key == "pollDurationsMs" else rows[:keep]
|
|
288
|
+
break
|
|
289
|
+
else:
|
|
290
|
+
break # nothing left to trim: the scalars alone must do
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -- the pusher ---------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class FleetVitalsPusher:
|
|
298
|
+
"""One snapshot per completed pass, best-effort.
|
|
299
|
+
|
|
300
|
+
Owns the long-lived `Sources` (so the console's caches — the 60 s rate
|
|
301
|
+
read, the 10 min PyPI read — persist across passes exactly as they do in
|
|
302
|
+
the sidecar) and the once-per-boot `not_activated` latch. Never raises
|
|
303
|
+
from `push_pass`: the daemon's job is spawning sessions, and a snapshot
|
|
304
|
+
must never cost a poll pass."""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
config: Config,
|
|
309
|
+
client: AlissaClient,
|
|
310
|
+
*,
|
|
311
|
+
sources: "Sources | None" = None,
|
|
312
|
+
memory_reader: "Callable[[], Mapping | None]" = sysinfo.memory_usage,
|
|
313
|
+
clock: "Callable[[], float]" = time.time,
|
|
314
|
+
):
|
|
315
|
+
self.config = config
|
|
316
|
+
self.client = client
|
|
317
|
+
self.sources = sources or Sources(
|
|
318
|
+
config=config, running_version=running_version.value
|
|
319
|
+
)
|
|
320
|
+
self._memory = memory_reader
|
|
321
|
+
self._clock = clock
|
|
322
|
+
self._not_activated_warned = False
|
|
323
|
+
self.last: "dict | None" = None
|
|
324
|
+
|
|
325
|
+
def push_pass(self, roster: "Sequence[Mapping] | None") -> str:
|
|
326
|
+
"""Build this pass's snapshot and POST it once. Returns PUSHED or
|
|
327
|
+
FAILED (the caller quotes it in the poll summary). A snapshot that
|
|
328
|
+
cannot be BUILT is a failure too — one WARNING, nothing sent."""
|
|
329
|
+
heartbeat = self._clock()
|
|
330
|
+
try:
|
|
331
|
+
snapshot = fit_to_body_cap(build_snapshot(
|
|
332
|
+
self.sources, roster=roster, heartbeat_at=heartbeat,
|
|
333
|
+
memory=self._memory(),
|
|
334
|
+
))
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
log.warning(
|
|
337
|
+
"fleet vitals: could not build the snapshot (%s) — nothing "
|
|
338
|
+
"pushed this pass; the next pass builds a fresh one", exc,
|
|
339
|
+
)
|
|
340
|
+
return FAILED
|
|
341
|
+
self.last = snapshot
|
|
342
|
+
return self._post(snapshot)
|
|
343
|
+
|
|
344
|
+
def _post(self, snapshot: "dict") -> str:
|
|
345
|
+
try:
|
|
346
|
+
self.client.post_fleet_vitals(snapshot)
|
|
347
|
+
except AlissaAuthError as exc:
|
|
348
|
+
if exc.code == NOT_ACTIVATED:
|
|
349
|
+
self._warn_not_activated(exc)
|
|
350
|
+
return FAILED
|
|
351
|
+
self._warn_failed(exc)
|
|
352
|
+
return FAILED
|
|
353
|
+
except AlissaError as exc:
|
|
354
|
+
self._warn_failed(exc)
|
|
355
|
+
return FAILED
|
|
356
|
+
sessions = snapshot.get("sessions") or {}
|
|
357
|
+
log.debug(
|
|
358
|
+
"fleet vitals: pushed (%s live / %s managed session(s), %d inbox "
|
|
359
|
+
"row(s), %d bytes)",
|
|
360
|
+
sessions.get("live", "?"), sessions.get("managed", "?"),
|
|
361
|
+
len(snapshot.get("inbox") or []), encoded_size(snapshot),
|
|
362
|
+
)
|
|
363
|
+
return PUSHED
|
|
364
|
+
|
|
365
|
+
def _warn_failed(self, exc: AlissaError) -> None:
|
|
366
|
+
log.warning(
|
|
367
|
+
"fleet vitals: POST %s failed (status %s: %s) — the pass "
|
|
368
|
+
"completes; the next pass sends a fresher snapshot",
|
|
369
|
+
ENDPOINT_PATH, exc.status, exc.detail,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _warn_not_activated(self, exc: AlissaAuthError) -> None:
|
|
373
|
+
"""The soft gate's answer is permanent until the operator acts, so it
|
|
374
|
+
is one WARNING per boot — with the activate URL the answer carries —
|
|
375
|
+
and DEBUG on every pass after."""
|
|
376
|
+
detail = exc.detail if isinstance(exc.detail, dict) else {}
|
|
377
|
+
url = detail.get("activateUrl") or "(no activate URL in the answer)"
|
|
378
|
+
if self._not_activated_warned:
|
|
379
|
+
log.debug(
|
|
380
|
+
"fleet vitals: still not_activated (activate at %s)", url
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
self._not_activated_warned = True
|
|
384
|
+
log.warning(
|
|
385
|
+
"fleet vitals: POST %s refused with 403 %s — the stacked app is "
|
|
386
|
+
"not activated for this operator; activate it at %s (this is "
|
|
387
|
+
"logged once per boot; every later pass logs at DEBUG until it "
|
|
388
|
+
"succeeds)",
|
|
389
|
+
ENDPOINT_PATH, NOT_ACTIVATED, url,
|
|
390
|
+
)
|
|
@@ -437,6 +437,7 @@ from pathlib import Path
|
|
|
437
437
|
from typing import Callable
|
|
438
438
|
|
|
439
439
|
from . import janitor
|
|
440
|
+
from . import fleet_vitals as fleet_vitals_mod
|
|
440
441
|
from . import loop_events as loop_events_mod
|
|
441
442
|
from .alissa import Alissa
|
|
442
443
|
from .alissa_client import AlissaClient
|
|
@@ -1896,6 +1897,18 @@ class SessionLiveness:
|
|
|
1896
1897
|
reaped: "set[str] | None" = None,
|
|
1897
1898
|
):
|
|
1898
1899
|
self.listed = sessions is not None
|
|
1900
|
+
# The raw rows, kept for the fleet-vitals snapshot (issue #116) so
|
|
1901
|
+
# the pass's ONE listing is also the roster it reports -- minus the
|
|
1902
|
+
# names the sweep killed, which are not sessions any more. None when
|
|
1903
|
+
# the listing did not happen, which the snapshot reports as null
|
|
1904
|
+
# (never as an empty roster).
|
|
1905
|
+
self.entries: "list[dict] | None" = None
|
|
1906
|
+
if sessions is not None:
|
|
1907
|
+
killed = set(reaped or ())
|
|
1908
|
+
self.entries = [
|
|
1909
|
+
dict(entry) for entry in sessions
|
|
1910
|
+
if str(entry.get("name") or "") not in killed
|
|
1911
|
+
]
|
|
1899
1912
|
self._live: set[str] = set()
|
|
1900
1913
|
for entry in sessions or ():
|
|
1901
1914
|
name = str(entry.get("name") or "")
|
|
@@ -2605,6 +2618,7 @@ class DevWatcher:
|
|
|
2605
2618
|
envelope_verdict: "Callable[[str, str, int], str | None] | None" = None,
|
|
2606
2619
|
repo_source: "BowRepoSource | None" = None,
|
|
2607
2620
|
loop_events: "loop_events_mod.LoopEventEmitter | None" = None,
|
|
2621
|
+
fleet_vitals: "fleet_vitals_mod.FleetVitalsPusher | None" = None,
|
|
2608
2622
|
):
|
|
2609
2623
|
self.config = config
|
|
2610
2624
|
self.github = github or GitHub(config.developer_login)
|
|
@@ -2635,6 +2649,17 @@ class DevWatcher:
|
|
|
2635
2649
|
if config.loop_events_enabled
|
|
2636
2650
|
else None
|
|
2637
2651
|
)
|
|
2652
|
+
# The fleet-vitals push (issue #116), or None when
|
|
2653
|
+
# fleet_vitals_enabled is off -- the loop-events shape exactly: no
|
|
2654
|
+
# client built, no request ever made, same endpoint and token,
|
|
2655
|
+
# injectable so a test never opens a socket.
|
|
2656
|
+
self.fleet_vitals = fleet_vitals or (
|
|
2657
|
+
fleet_vitals_mod.FleetVitalsPusher(
|
|
2658
|
+
config, AlissaClient(base=config.alissa_endpoint)
|
|
2659
|
+
)
|
|
2660
|
+
if config.fleet_vitals_enabled
|
|
2661
|
+
else None
|
|
2662
|
+
)
|
|
2638
2663
|
# The reaper's name-slug -> (owner, repo) index (see `_anchor_repo`),
|
|
2639
2664
|
# rebuilt only when the allowlist itself changes rather than per
|
|
2640
2665
|
# session per sweep.
|
|
@@ -7939,10 +7964,26 @@ class DevWatcher:
|
|
|
7939
7964
|
self.run_janitor(roster)
|
|
7940
7965
|
|
|
7941
7966
|
counts = Counter(decision.action for _, decision in results)
|
|
7967
|
+
self._write_snapshot(
|
|
7968
|
+
issue_results,
|
|
7969
|
+
pr_results,
|
|
7970
|
+
maintain_results,
|
|
7971
|
+
sweep,
|
|
7972
|
+
counts,
|
|
7973
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
7974
|
+
)
|
|
7975
|
+
# The two Studio pushes close the pass, loop events FIRST (issue
|
|
7976
|
+
# #116 fixes the order: the vitals snapshot reads the ledgers the
|
|
7977
|
+
# events were derived from, and the summary line quotes its
|
|
7978
|
+
# outcome). Both are best-effort and neither can raise.
|
|
7979
|
+
self._emit_loop_events(
|
|
7980
|
+
issue_results, pr_results, maintain_results, sweep
|
|
7981
|
+
)
|
|
7982
|
+
vitals = self._push_fleet_vitals(roster)
|
|
7942
7983
|
log.info(
|
|
7943
7984
|
"poll summary: %d candidate(s) — %d spawned, %d in-flight, "
|
|
7944
7985
|
"%d skipped, %d escalated, %d capped, %d deferred; "
|
|
7945
|
-
"%d worker session(s) reaped, %d foreign",
|
|
7986
|
+
"%d worker session(s) reaped, %d foreign; vitals: %s",
|
|
7946
7987
|
len(results),
|
|
7947
7988
|
counts[Action.SPAWNED],
|
|
7948
7989
|
counts[Action.IN_FLIGHT],
|
|
@@ -7952,20 +7993,31 @@ class DevWatcher:
|
|
|
7952
7993
|
counts[Action.DEFERRED],
|
|
7953
7994
|
sweep.reaped,
|
|
7954
7995
|
sweep.foreign_reaped,
|
|
7955
|
-
|
|
7956
|
-
self._write_snapshot(
|
|
7957
|
-
issue_results,
|
|
7958
|
-
pr_results,
|
|
7959
|
-
maintain_results,
|
|
7960
|
-
sweep,
|
|
7961
|
-
counts,
|
|
7962
|
-
duration_ms=int((time.monotonic() - started) * 1000),
|
|
7963
|
-
)
|
|
7964
|
-
self._emit_loop_events(
|
|
7965
|
-
issue_results, pr_results, maintain_results, sweep
|
|
7996
|
+
vitals,
|
|
7966
7997
|
)
|
|
7967
7998
|
return results
|
|
7968
7999
|
|
|
8000
|
+
def _push_fleet_vitals(self, roster: SessionLiveness) -> str:
|
|
8001
|
+
"""The pass's fleet-vitals push (issue #116): one console snapshot
|
|
8002
|
+
to Studio, built from the pass's own roster and the ledgers the pass
|
|
8003
|
+
just wrote. Returns the outcome token the summary quotes --
|
|
8004
|
+
`skipped` when the feature is off or under dry_run (the push writes
|
|
8005
|
+
to an external service, and a dry pass takes no side effects: it
|
|
8006
|
+
logs what it would have sent instead), else the pusher's `pushed` /
|
|
8007
|
+
`failed`. Runs AFTER the loop-events push and never raises."""
|
|
8008
|
+
if self.fleet_vitals is None:
|
|
8009
|
+
return fleet_vitals_mod.SKIPPED
|
|
8010
|
+
if self.config.dry_run:
|
|
8011
|
+
log.info(
|
|
8012
|
+
"[dry-run] would push fleet vitals (seat %s, %s roster "
|
|
8013
|
+
"session(s) → POST %s%s)",
|
|
8014
|
+
fleet_vitals_mod.SEAT,
|
|
8015
|
+
len(roster.entries) if roster.entries is not None else "unlisted",
|
|
8016
|
+
self.config.alissa_endpoint, fleet_vitals_mod.ENDPOINT_PATH,
|
|
8017
|
+
)
|
|
8018
|
+
return fleet_vitals_mod.SKIPPED
|
|
8019
|
+
return self.fleet_vitals.push_pass(roster.entries)
|
|
8020
|
+
|
|
7969
8021
|
def _emit_loop_events(
|
|
7970
8022
|
self,
|
|
7971
8023
|
issue_results: "list[tuple[str, Decision]]",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.22
|
|
@@ -312,8 +312,18 @@ class Sources:
|
|
|
312
312
|
except ValueError:
|
|
313
313
|
return None
|
|
314
314
|
|
|
315
|
-
def sessions(self) -> "list[dict]":
|
|
316
|
-
|
|
315
|
+
def sessions(self, listing: "list[dict] | None" = None) -> "list[dict]":
|
|
316
|
+
"""The managed sessions with their edge, age and `/proc` footprint.
|
|
317
|
+
|
|
318
|
+
`listing` is an ``alissa tmux ls --json`` result already in hand: the
|
|
319
|
+
daemon's fleet-vitals push (issue #116) hands in the pass's own
|
|
320
|
+
reaper listing so the roster is read ONCE per pass, while the
|
|
321
|
+
console (no listing) lists for itself. Either way the rows go
|
|
322
|
+
through the same shaping, so the two never disagree about what a
|
|
323
|
+
session record looks like."""
|
|
324
|
+
raw = listing if listing is not None else (
|
|
325
|
+
self._safe_json(["alissa", "tmux", "ls", "--json"]) or []
|
|
326
|
+
)
|
|
317
327
|
if not isinstance(raw, list):
|
|
318
328
|
return []
|
|
319
329
|
now = int(self._wall())
|
|
@@ -148,6 +148,102 @@ def tree_usage(
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
def _read_int(path: Path) -> "int | None":
|
|
152
|
+
"""One integer file (`memory.current`, `memory.max`, …), or None when
|
|
153
|
+
it is absent, unreadable, or not a number (`max` for an unlimited
|
|
154
|
+
cgroup reads as None — no limit)."""
|
|
155
|
+
try:
|
|
156
|
+
return int(path.read_text().split()[0])
|
|
157
|
+
except (OSError, ValueError, IndexError):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_stat_field(path: Path, field: str) -> "int | None":
|
|
162
|
+
"""One `key value` line of a cgroup `memory.stat`, or None."""
|
|
163
|
+
try:
|
|
164
|
+
for line in path.read_text().splitlines():
|
|
165
|
+
key, _, value = line.partition(" ")
|
|
166
|
+
if key == field:
|
|
167
|
+
return int(value.strip())
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
return None
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _read_meminfo(path: Path) -> "dict[str, int]":
|
|
174
|
+
"""`/proc/meminfo` as `{key: kB}`, empty when unreadable."""
|
|
175
|
+
out: "dict[str, int]" = {}
|
|
176
|
+
try:
|
|
177
|
+
for line in path.read_text().splitlines():
|
|
178
|
+
key, _, rest = line.partition(":")
|
|
179
|
+
parts = rest.split()
|
|
180
|
+
if key and parts and parts[0].isdigit():
|
|
181
|
+
out[key.strip()] = int(parts[0])
|
|
182
|
+
except OSError:
|
|
183
|
+
return {}
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def memory_usage(
|
|
188
|
+
*,
|
|
189
|
+
cgroup_root: "str | os.PathLike[str]" = "/sys/fs/cgroup",
|
|
190
|
+
proc_root: "str | os.PathLike[str]" = "/proc",
|
|
191
|
+
) -> "dict | None":
|
|
192
|
+
"""The container's memory footprint for the fleet-vitals snapshot
|
|
193
|
+
(issue #116): `resident_bytes` (what is charged right now),
|
|
194
|
+
`reclaimable_bytes` (the inactive page cache the kernel would drop under
|
|
195
|
+
pressure — the part of "resident" that is not really the workers'), and
|
|
196
|
+
`limit_bytes` (the cgroup cap, or None when unlimited). Or None when no
|
|
197
|
+
accounting can be read at all.
|
|
198
|
+
|
|
199
|
+
Three sources, in the order a container is likeliest to expose them:
|
|
200
|
+
cgroup v2 (`memory.current` / `memory.stat inactive_file` / `memory.max`,
|
|
201
|
+
where `max` means no limit), cgroup v1 (`memory/memory.usage_in_bytes` /
|
|
202
|
+
`memory.stat total_inactive_file` / `memory.limit_in_bytes`, where the
|
|
203
|
+
kernel's "no limit" is a near-2^63 sentinel), and `/proc/meminfo` for a
|
|
204
|
+
bare host (resident = MemTotal − MemAvailable, reclaimable = MemAvailable
|
|
205
|
+
− MemFree, limit = MemTotal). Every read is failure-tolerant like the
|
|
206
|
+
rest of this module: a field that cannot be read is None, never an
|
|
207
|
+
exception."""
|
|
208
|
+
cg = Path(cgroup_root)
|
|
209
|
+
current = _read_int(cg / "memory.current")
|
|
210
|
+
if current is not None:
|
|
211
|
+
limit = _read_int(cg / "memory.max")
|
|
212
|
+
return {
|
|
213
|
+
"resident_bytes": current,
|
|
214
|
+
"reclaimable_bytes": _read_stat_field(cg / "memory.stat", "inactive_file"),
|
|
215
|
+
"limit_bytes": limit,
|
|
216
|
+
}
|
|
217
|
+
v1 = cg / "memory"
|
|
218
|
+
usage = _read_int(v1 / "memory.usage_in_bytes")
|
|
219
|
+
if usage is not None:
|
|
220
|
+
limit = _read_int(v1 / "memory.limit_in_bytes")
|
|
221
|
+
# cgroup v1 reports "unlimited" as PAGE_COUNTER_MAX (≈ 2^63 rounded
|
|
222
|
+
# to a page); anything past a petabyte is that sentinel, not a cap.
|
|
223
|
+
if limit is not None and limit >= 1 << 50:
|
|
224
|
+
limit = None
|
|
225
|
+
return {
|
|
226
|
+
"resident_bytes": usage,
|
|
227
|
+
"reclaimable_bytes": _read_stat_field(
|
|
228
|
+
v1 / "memory.stat", "total_inactive_file"
|
|
229
|
+
),
|
|
230
|
+
"limit_bytes": limit,
|
|
231
|
+
}
|
|
232
|
+
info = _read_meminfo(Path(proc_root) / "meminfo")
|
|
233
|
+
total = info.get("MemTotal")
|
|
234
|
+
available = info.get("MemAvailable")
|
|
235
|
+
if total is None or available is None:
|
|
236
|
+
return None
|
|
237
|
+
free = info.get("MemFree")
|
|
238
|
+
return {
|
|
239
|
+
"resident_bytes": max(0, total - available) * 1024,
|
|
240
|
+
"reclaimable_bytes": (
|
|
241
|
+
max(0, available - free) * 1024 if free is not None else None
|
|
242
|
+
),
|
|
243
|
+
"limit_bytes": total * 1024,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
151
247
|
def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
|
|
152
248
|
"""Workspace volume usage for the stat tile, or None if the path is
|
|
153
249
|
unreadable. Percent is used/total, rounded -- the meter the console fills.
|
|
@@ -10,6 +10,7 @@ src/main/alissa/tools/github/devloop/alissa.py
|
|
|
10
10
|
src/main/alissa/tools/github/devloop/alissa_client.py
|
|
11
11
|
src/main/alissa/tools/github/devloop/bows.py
|
|
12
12
|
src/main/alissa/tools/github/devloop/config.py
|
|
13
|
+
src/main/alissa/tools/github/devloop/fleet_vitals.py
|
|
13
14
|
src/main/alissa/tools/github/devloop/ghclient.py
|
|
14
15
|
src/main/alissa/tools/github/devloop/janitor.py
|
|
15
16
|
src/main/alissa/tools/github/devloop/loop.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.20
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|