@vibelord/lanes 1.2.0 → 1.5.0
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.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -13,10 +13,23 @@ It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that t
|
|
|
13
13
|
host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
|
|
14
14
|
and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
|
|
15
15
|
or positively does not exist.
|
|
16
|
+
|
|
17
|
+
The docs cockpit (plan W10 W7): a lane row carries `docs: {n, at}` while docs/counts.json counts documents for it
|
|
18
|
+
(one stat of that file per cycle), and the document `web_origin`, the https origin the docs page answers on, once
|
|
19
|
+
main() has learnt it between two cycles (refresh_origin).
|
|
20
|
+
|
|
21
|
+
Fleet facts (spec W11 §2.2): every FLEET_INTERVAL, unless the host is critical, `fleet` — the host's cpu, memory,
|
|
22
|
+
pressure, inotify ceiling and the biggest workspace containers, from `lanes_lib.compute_health(sample=True)` and the
|
|
23
|
+
previous sample kept in the context — and on every cycle `throttle`, the Throttle pulse's `~/.lz/state/throttle.json`
|
|
24
|
+
in the daemon's container (one stat, read only when it moved). The one spawn either adds is `rearm_pulse`, between two
|
|
25
|
+
cycles: one bounded `docker exec -d` at most every PULSE_REARM_EVERY seconds while the pulse file has gone quiet.
|
|
16
26
|
"""
|
|
17
27
|
import concurrent.futures
|
|
18
28
|
import json
|
|
29
|
+
import math
|
|
19
30
|
import os
|
|
31
|
+
import re
|
|
32
|
+
import stat
|
|
20
33
|
import subprocess
|
|
21
34
|
import sys
|
|
22
35
|
import time
|
|
@@ -27,6 +40,7 @@ import lanes_lib as L # noqa: E402
|
|
|
27
40
|
import session_actions as A # noqa: E402
|
|
28
41
|
import agent_state as AS # noqa: E402
|
|
29
42
|
import guard as G # noqa: E402
|
|
43
|
+
import docs_store # noqa: E402
|
|
30
44
|
|
|
31
45
|
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
32
46
|
SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
|
|
@@ -56,12 +70,49 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
|
|
|
56
70
|
'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
|
|
57
71
|
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
58
72
|
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
73
|
+
# The docs cockpit (plan W10 W7, LNS-108). A lane row carries `docs: {n, at}` while docs/counts.json
|
|
74
|
+
# (DM-LANES-DOCS-COUNTS) holds documents for it: ONE stat of that file per cycle, the file read only when it moved.
|
|
75
|
+
# `web_origin` is the https origin Tailscale Serve answers on, the one the enrolment service announces (enrold
|
|
76
|
+
# site_facts' host name: site.conf's LANES_SERVER_NAME, else this node's MagicDNS name), with Serve's port: the sidebar
|
|
77
|
+
# builds its OSC 8 links to /lanes-docs/ on it and never forks to learn it. Resolved outside the cycle (main), at most
|
|
78
|
+
# every ORIGIN_TTL seconds — enrold's own cache of the name — and not again on a host under pressure once known.
|
|
79
|
+
WEB_PORT = 8443 # bin/lanes SERVE_PORT: `tailscale serve --https=8443` -> Caddy :8083 (web/Caddyfile)
|
|
80
|
+
ORIGIN_TTL = 300 # enrold.TAILNET_TTL
|
|
81
|
+
HOST_NAME = re.compile(r'(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
|
82
|
+
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\Z', re.ASCII)
|
|
83
|
+
# Fleet facts and the Throttle pulse (spec W11 §2.1/§2.2, DM-LANES-FLEET, DM-LANES-THROTTLE-PULSE).
|
|
84
|
+
FLEET_INTERVAL = 10 # seconds between two fleet samples (cpu rates are over this); none taken while critical
|
|
85
|
+
HOSTNAME_FILE = '/etc/hostname' # `host.name`, read once per daemon start (new_context)
|
|
86
|
+
THROTTLE_TOOL = 'tools--throttle-monitor' # lanes_lib.build_registry's tool tab: its container hosts the daemon
|
|
87
|
+
PULSE_DIR = ('home', 'coder', '.lz', 'state') # under /proc/<daemon container init>/root, walked without a link
|
|
88
|
+
PULSE_FILE = 'throttle.json'
|
|
89
|
+
PULSE_MAX = 64 << 10 # a bigger file is not the pulse's: refused, never read
|
|
90
|
+
# A healthy pulse's `at` is seen up to ~84 s old here (its 60 s HEARTBEAT is judged on an int-truncated `at`, one
|
|
91
|
+
# 10 s loop cycle late at a second boundary, a slow cycle's five 3 s GET timeouts, then statusd's own 3 s cycle):
|
|
92
|
+
# older than PULSE_STALE_S is published `stale: true` — the same 90 s after which `rearm_pulse` calls the loop stopped.
|
|
93
|
+
PULSE_STALE_S = 90
|
|
94
|
+
PULSE_REARM_S = 90 # a pulse file quiet for longer is a stopped loop ...
|
|
95
|
+
PULSE_REARM_EVERY = 600 # ... restarted at most this often, never on a critical host
|
|
96
|
+
PULSE_REARM_TIMEOUT = 10
|
|
97
|
+
DAEMON_REFRESH = 300 # site.conf is re-read this often for LANES_THROTTLE_CONTAINER (CONTAINER_REFRESH)
|
|
98
|
+
PULSE_BIN = ('home', 'coder', '.lz', 'bin')
|
|
99
|
+
PULSE_PROGRAM = 'throttle-pulse'
|
|
100
|
+
PULSE_POOLS = ('claude', 'codex', 'kimi') # throttle-pulse POOLS (W12 adds kimi; its overflow is always null)
|
|
101
|
+
PULSE_COUNTS = ('configured', 'usable', 'cooling', 'exhausted', 'benched', 'auth_failed')
|
|
102
|
+
PULSE_OVERFLOW = ('spend_today_usd', 'budget_usd', 'fraction', 'alert_level_pct')
|
|
103
|
+
PULSE_PHASE = re.compile(r'[a-z][a-z0-9_-]{0,31}\Z', re.ASCII)
|
|
104
|
+
PULSE_ERROR_MAX = 160
|
|
59
105
|
|
|
60
106
|
|
|
61
107
|
def log(where, error):
|
|
62
108
|
print(time.strftime('%FT%T'), where, type(error).__name__, error, flush=True)
|
|
63
109
|
|
|
64
110
|
|
|
111
|
+
def note(where, message):
|
|
112
|
+
"""A journal line that is no exception (the pulse re-arm and its outcome, a pulse file that went or came back)."""
|
|
113
|
+
print(time.strftime('%FT%T'), where, message, flush=True)
|
|
114
|
+
|
|
115
|
+
|
|
65
116
|
# ------------------------------------------------- the viewer rule (A4a)
|
|
66
117
|
# A Throttle Studio browser attaches with `new-session -t <lane> -s view-<studio>-<ch>`: an independent
|
|
67
118
|
# current window on the lane's windows. Such a session is a CLIENT of the lane, not a lane of its own, and
|
|
@@ -279,34 +330,92 @@ def settled(previous, lane_id, now, raw):
|
|
|
279
330
|
return AS.settle(old.get('agent') if isinstance(old, dict) else None, raw, now)
|
|
280
331
|
|
|
281
332
|
|
|
282
|
-
|
|
333
|
+
# W12 (spec §2): the harness each pane runs, read from the container's /proc and kept per pane pid between cycles —
|
|
334
|
+
# a pane whose own children did not change is not walked again (`agent_state.HarnessCache`; reads only, no spawn).
|
|
335
|
+
HARNESS_CACHE = AS.HarnessCache()
|
|
336
|
+
# The Grok Build observer's title memory between cycles (`agent_state.grok_sessions(..., titles=)`), one dict per
|
|
337
|
+
# container root ({pane key: the last title, held only while it carries `⚠ Action Required`}): unfocused, Grok blinks
|
|
338
|
+
# that prefix away half of every second, so a busy title right after a prefixed one is still an approval. Kept like
|
|
339
|
+
# HARNESS_CACHE; each cycle prunes it to the panes it passed and drops a container root it did not read.
|
|
340
|
+
GROK_TITLES = {}
|
|
341
|
+
# The harnesses whose panes go to the Grok observer: Grok Build itself; a bare shell (the observer reads its title: a
|
|
342
|
+
# Grok title over a tree with no grok left is that harness ended, `grok` / `shell`); and `unknown` WITH a harness pid —
|
|
343
|
+
# the walk's `grok` whose /proc/<pid>/exe could not be read, Grok Build only when active_sessions.json names its pid.
|
|
344
|
+
GROK_OBSERVED = ('grok', 'shell')
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def agent_signals(containers, sessions_by_server, now=None):
|
|
283
348
|
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
284
349
|
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
285
|
-
namespace and nothing is spawned. -> {container: claude_by_pane
|
|
350
|
+
namespace and nothing is spawned. -> {container: {'claude': claude_by_pane, 'kimi': kimi_by_pane,
|
|
351
|
+
'grok': grok_by_pane}}.
|
|
352
|
+
|
|
353
|
+
Sets `harness` (and `harness_pid` when one was found) on every session row whose harness could be observed, in
|
|
354
|
+
place (W12, spec §2: `agent_state.HarnessCache` over `harness_walk` — the pane's process tree in the container's
|
|
355
|
+
/proc); the Kimi Code observer (`agent_state.kimi_sessions`) reads only for the panes found running `kimi`, and the
|
|
356
|
+
Grok Build observer (`agent_state.grok_sessions` -> `harness_grok.grok_sessions`) gets the ROWS of the panes found
|
|
357
|
+
running `grok`, ending in a bare shell, or running a grok whose exe could not be read (GROK_OBSERVED), with the
|
|
358
|
+
container's GROK_TITLES memory.
|
|
286
359
|
|
|
287
360
|
Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
|
|
288
|
-
(`parse_codex_title
|
|
289
|
-
child read (`child_liveness`) tells that apart from a live harness launched under `bash -c`
|
|
290
|
-
a live Claude session file already claims is not probed (the file outranks the title)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
361
|
+
(`parse_codex_title`, gated by the observed harness) over a bare-shell foreground can be a harness that ended into
|
|
362
|
+
its shell, and only the child read (`child_liveness`) tells that apart from a live harness launched under `bash -c`
|
|
363
|
+
or a wrapper. A pane a live Claude session file already claims is not probed (the file outranks the title), nor one
|
|
364
|
+
whose walk found a harness running (alive by that very read). The key is set only for a known answer: unknown
|
|
365
|
+
liveness leaves it absent, and an absent key never demotes a title (DEF-1557). A container whose files cannot be
|
|
366
|
+
read costs only its own agents, never a lane its `state`."""
|
|
367
|
+
signals = {}
|
|
368
|
+
walked = set()
|
|
369
|
+
titled = set()
|
|
370
|
+
home = tuple(part for part in L.CONTAINER_HOME.split('/') if part)
|
|
294
371
|
for container in sorted({container for container, _socket in sessions_by_server}):
|
|
295
372
|
info = containers.get(container) or {}
|
|
296
373
|
if not info.get('pid'):
|
|
297
374
|
continue
|
|
298
375
|
root = Path(L.proc_root()) / str(info['pid']) / 'root'
|
|
299
376
|
try:
|
|
300
|
-
claude
|
|
301
|
-
root / 'proc')
|
|
377
|
+
claude = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions', root / 'proc')
|
|
302
378
|
except Exception as error:
|
|
303
379
|
log(f'agents {container}', error)
|
|
304
|
-
claude
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if str(pane.get('pane_pid') or '').isdigit()
|
|
308
|
-
|
|
309
|
-
|
|
380
|
+
claude = {}
|
|
381
|
+
signals[container] = mine = {'claude': claude, 'kimi': {}, 'grok': {}}
|
|
382
|
+
panes = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
|
|
383
|
+
for pane in sessions.values() if str(pane.get('pane_pid') or '').isdigit()]
|
|
384
|
+
observers = {'kimi': {}, 'grok': {}}
|
|
385
|
+
for pane in panes:
|
|
386
|
+
try:
|
|
387
|
+
harness, harness_pid = HARNESS_CACHE.harness(root / 'proc', int(pane['pane_pid']))
|
|
388
|
+
except Exception as error:
|
|
389
|
+
log(f'harness {container}', error)
|
|
390
|
+
continue
|
|
391
|
+
walked.add((str(root / 'proc'), int(pane['pane_pid'])))
|
|
392
|
+
if harness is None:
|
|
393
|
+
continue
|
|
394
|
+
pane['harness'] = harness
|
|
395
|
+
if harness_pid:
|
|
396
|
+
pane['harness_pid'] = harness_pid
|
|
397
|
+
if harness == 'kimi' and harness_pid:
|
|
398
|
+
observers['kimi'][AS.pane_key(pane)] = harness_pid
|
|
399
|
+
elif harness in GROK_OBSERVED or (harness == 'unknown' and harness_pid):
|
|
400
|
+
observers['grok'][AS.pane_key(pane)] = pane
|
|
401
|
+
if observers['kimi']:
|
|
402
|
+
try:
|
|
403
|
+
mine['kimi'] = AS.kimi_sessions(root, observers['kimi'], now, home)
|
|
404
|
+
except Exception as error:
|
|
405
|
+
log(f'kimi {container}', error)
|
|
406
|
+
if observers['grok']:
|
|
407
|
+
titles = GROK_TITLES.setdefault(str(root), {})
|
|
408
|
+
titled.add(str(root))
|
|
409
|
+
try:
|
|
410
|
+
mine['grok'] = AS.grok_sessions(root, observers['grok'], AS.GROK_HOME, home=L.CONTAINER_HOME,
|
|
411
|
+
titles=titles)
|
|
412
|
+
except Exception as error:
|
|
413
|
+
log(f'grok {container}', error)
|
|
414
|
+
for gone in set(titles) - set(observers['grok']):
|
|
415
|
+
del titles[gone]
|
|
416
|
+
probe = [pane for pane in panes
|
|
417
|
+
if AS.parse_codex_title(pane.get('pane_title'), pane.get('harness')) and AS._shell_foreground(pane)
|
|
418
|
+
and pane.get('harness') not in AS.AGENT_HARNESSES and AS.pane_key(pane) not in claude]
|
|
310
419
|
if not probe:
|
|
311
420
|
continue
|
|
312
421
|
try:
|
|
@@ -318,7 +427,15 @@ def agent_signals(containers, sessions_by_server):
|
|
|
318
427
|
known = live.get(int(pane['pane_pid']))
|
|
319
428
|
if known is not None:
|
|
320
429
|
pane['pane_has_child'] = known
|
|
321
|
-
|
|
430
|
+
HARNESS_CACHE.prune(walked)
|
|
431
|
+
for gone in set(GROK_TITLES) - titled:
|
|
432
|
+
del GROK_TITLES[gone]
|
|
433
|
+
return signals
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def merged(pane, signals, sidecar):
|
|
437
|
+
"""`agent_state.merge` of one pane over its container's `agent_signals` and its lane's hook sidecar."""
|
|
438
|
+
return AS.merge(pane, signals.get('claude', {}), sidecar, signals.get('kimi', {}), signals.get('grok', {}))
|
|
322
439
|
|
|
323
440
|
|
|
324
441
|
SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
|
|
@@ -516,10 +633,10 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
516
633
|
report['cut'] = len(cut)
|
|
517
634
|
# Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
|
|
518
635
|
try:
|
|
519
|
-
|
|
636
|
+
signals = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()}, now)
|
|
520
637
|
except Exception as error:
|
|
521
638
|
log('agents', error)
|
|
522
|
-
|
|
639
|
+
signals = {}
|
|
523
640
|
# Task 8: the hook sidecars `lane-signal` writes, one directory listing per container per cycle. They outrank the
|
|
524
641
|
# session file and the title in `agent_state.merge`; a container without hooks costs one failed listdir.
|
|
525
642
|
try:
|
|
@@ -604,8 +721,8 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
604
721
|
entry['note'] = 'session name outside the lane declaration grammar'
|
|
605
722
|
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
|
|
606
723
|
settled(previous, entry['id'], now,
|
|
607
|
-
|
|
608
|
-
|
|
724
|
+
merged(pane, signals.get(container, {}),
|
|
725
|
+
sidecars.get(container, {}).get(entry['id']))))
|
|
609
726
|
except Exception as error:
|
|
610
727
|
log(f'observe {container}/{socket}/{name}', error)
|
|
611
728
|
continue
|
|
@@ -625,8 +742,8 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
625
742
|
try:
|
|
626
743
|
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
|
|
627
744
|
settled(previous, lane['id'], now,
|
|
628
|
-
|
|
629
|
-
|
|
745
|
+
merged(pane, signals.get(container, {}),
|
|
746
|
+
sidecars.get(container, {}).get(lane['id']))))
|
|
630
747
|
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
631
748
|
log(f"observe {lane['id']}", error)
|
|
632
749
|
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
@@ -852,6 +969,68 @@ def update_containers(ctx, registry, level):
|
|
|
852
969
|
info['running'] = pid_alive(info.get('pid'))
|
|
853
970
|
|
|
854
971
|
|
|
972
|
+
def docs_counts(ctx):
|
|
973
|
+
"""docs/counts.json as the lane rows carry it -> {lane id: {n, at}} ({} when absent or not valid). ONE os.stat of
|
|
974
|
+
the file per cycle; it is read (docs_store.read_counts: no link followed, no FIFO opened, the shape checked) only
|
|
975
|
+
when its (mtime, inode, size) moved since the last read — docs_store replaces it on every change."""
|
|
976
|
+
try:
|
|
977
|
+
info = os.stat(docs_store.counts_path())
|
|
978
|
+
stamp = (info.st_mtime_ns, info.st_ino, info.st_size)
|
|
979
|
+
except OSError:
|
|
980
|
+
stamp = None
|
|
981
|
+
if stamp != ctx.get('docs_stamp') or 'docs_counts' not in ctx:
|
|
982
|
+
ctx['docs_counts'] = docs_store.read_counts() if stamp else {}
|
|
983
|
+
ctx['docs_stamp'] = stamp
|
|
984
|
+
return ctx['docs_counts']
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def with_docs(lanes, counts):
|
|
988
|
+
"""`lanes` with `docs: {n, at}` on every row counts.json gives documents (n > 0) and on no other row: absent at 0,
|
|
989
|
+
and dropped from a kept row whose lane has none any more. A row that changes is a NEW dict (DEF-1538: a kept row
|
|
990
|
+
may be the very object the published payload holds); the others are passed on as they are."""
|
|
991
|
+
out = {}
|
|
992
|
+
for lane_id, row in lanes.items():
|
|
993
|
+
entry = counts.get(lane_id) if isinstance(counts, dict) else None
|
|
994
|
+
want = {'n': entry['n'], 'at': entry['at']} if isinstance(entry, dict) and entry.get('n') else None
|
|
995
|
+
if not isinstance(row, dict) or row.get('docs') == want:
|
|
996
|
+
out[lane_id] = row
|
|
997
|
+
continue
|
|
998
|
+
row = {key: value for key, value in row.items() if key != 'docs'}
|
|
999
|
+
if want:
|
|
1000
|
+
row['docs'] = want
|
|
1001
|
+
out[lane_id] = row
|
|
1002
|
+
return out
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def resolve_origin():
|
|
1006
|
+
"""`https://<name>:8443` — the origin the docs page answers on (Tailscale Serve -> Caddy), by the name enrold
|
|
1007
|
+
announces (site_facts: site.conf's LANES_SERVER_NAME, else enrold.tailnet_name(), one bounded `tailscale status
|
|
1008
|
+
--json` cached for its TAILNET_TTL), or None when there is no name or it is no DNS host name: it is written into
|
|
1009
|
+
terminal OSC strings and links, so nothing but `[A-Za-z0-9.-]` ever reaches them."""
|
|
1010
|
+
import enrold # the enrolment service's own reader of the name; imported only when an origin is wanted
|
|
1011
|
+
name = enrold.server_site().get('LANES_SERVER_NAME') or enrold.tailnet_name()
|
|
1012
|
+
return f'https://{name}:{WEB_PORT}' if isinstance(name, str) and HOST_NAME.match(name) else None
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def refresh_origin(ctx, level, now=None):
|
|
1016
|
+
"""main()'s look for `web_origin`, never inside a cycle: at most every ORIGIN_TTL seconds, and on a host that is
|
|
1017
|
+
not `ok` only while none is known yet (one bounded spawn, then it waits). -> True when it looked. A failure keeps
|
|
1018
|
+
the last origin: an unanswered name is no reason to drop the links."""
|
|
1019
|
+
now = time.time() if now is None else now
|
|
1020
|
+
known = ctx.get('origin_at') is not None
|
|
1021
|
+
if known and (now - ctx['origin_at'] < ORIGIN_TTL or level != 'ok'):
|
|
1022
|
+
return False
|
|
1023
|
+
ctx['origin_at'] = now
|
|
1024
|
+
try:
|
|
1025
|
+
origin = resolve_origin()
|
|
1026
|
+
except Exception as error:
|
|
1027
|
+
log('web origin', error)
|
|
1028
|
+
return True
|
|
1029
|
+
if origin or not known:
|
|
1030
|
+
ctx['web_origin'] = origin
|
|
1031
|
+
return True
|
|
1032
|
+
|
|
1033
|
+
|
|
855
1034
|
def build_payload(ctx, level, reconcile=True):
|
|
856
1035
|
"""One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
|
|
857
1036
|
fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
|
|
@@ -915,8 +1094,15 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
915
1094
|
# Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
|
|
916
1095
|
ctx['zmx_listed_at'] = listed
|
|
917
1096
|
lanes.update(zmx_lanes)
|
|
918
|
-
|
|
919
|
-
|
|
1097
|
+
try: # plan W10 W7: the docs counts ride on the lane rows (one stat of docs/counts.json per cycle)
|
|
1098
|
+
lanes = with_docs(lanes, docs_counts(ctx))
|
|
1099
|
+
except Exception as error:
|
|
1100
|
+
log('docs', error)
|
|
1101
|
+
payload = {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
1102
|
+
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
1103
|
+
if ctx.get('web_origin'): # additive, absent while no origin is known (refresh_origin, main())
|
|
1104
|
+
payload['web_origin'] = ctx['web_origin']
|
|
1105
|
+
return payload
|
|
920
1106
|
|
|
921
1107
|
|
|
922
1108
|
def new_context():
|
|
@@ -927,16 +1113,22 @@ def new_context():
|
|
|
927
1113
|
written = float(seed.get('updated_at') or 0)
|
|
928
1114
|
for entry in (seed.get('lanes') or {}).values():
|
|
929
1115
|
entry.setdefault('stale_since', int(written))
|
|
1116
|
+
# The docs links' origin survives a restart (re-checked by main()'s first refresh_origin); only a valid one.
|
|
1117
|
+
origin = seed.get('web_origin')
|
|
1118
|
+
valid = (isinstance(origin, str) and origin.startswith('https://') and origin.endswith(f':{WEB_PORT}')
|
|
1119
|
+
and HOST_NAME.match(origin[len('https://'):-len(f':{WEB_PORT}')]))
|
|
930
1120
|
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
931
1121
|
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
|
|
932
|
-
'container_status': {}, 'container_status_errors': {}
|
|
1122
|
+
'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None,
|
|
1123
|
+
'host_name': read_host_name()}
|
|
933
1124
|
|
|
934
1125
|
|
|
935
1126
|
# ------------------------------------------- the in-container projection (LNS-32)
|
|
936
1127
|
# `~/.lz/status.json` in each workspace container: that container's slice of the payload, for the in-container Agent
|
|
937
1128
|
# API (`lane-mcp`, plan Track M), which cannot read the host's status.json. v1 = {v, at, lanes{<id>: {state, agent?,
|
|
938
|
-
# attached?, viewers?, activity?, size?, stale?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
|
|
939
|
-
|
|
1129
|
+
# attached?, viewers?, activity?, size?, stale?, docs?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
|
|
1130
|
+
# `docs` (plan W10 W7, additive) is the host row's {n, at} verbatim, absent like there.
|
|
1131
|
+
CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale', 'docs')
|
|
940
1132
|
CONTAINER_STATUS_DIR = tuple(L.CONTAINER_HOME.strip('/').split('/')) + ('.lz',)
|
|
941
1133
|
CONTAINER_STATUS_FILE = 'status.json'
|
|
942
1134
|
|
|
@@ -1034,14 +1226,312 @@ def _container_status_error(errors, container, error):
|
|
|
1034
1226
|
log(f'container status {container}', error)
|
|
1035
1227
|
|
|
1036
1228
|
|
|
1229
|
+
# ------------------------------------------ fleet facts and the Throttle pulse (W11)
|
|
1230
|
+
# `fleet` (DM-LANES-FLEET) is the host and its biggest workspace containers, `throttle` (DM-LANES-THROTTLE-PULSE) the
|
|
1231
|
+
# Throttle pulse's last document. Both are additive keys of status.json; the sidebar's pinned block and `lanes doctor`
|
|
1232
|
+
# read them there and nowhere else.
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def read_host_name(path=None):
|
|
1236
|
+
"""`host.name`: the first line of /etc/hostname, else the kernel's node name, published only when it is a DNS host
|
|
1237
|
+
name (it is drawn into terminals) — else None. Read once per daemon start (new_context), never per cycle."""
|
|
1238
|
+
try:
|
|
1239
|
+
name = Path(path or HOSTNAME_FILE).read_text().split('\n', 1)[0].strip()
|
|
1240
|
+
except (OSError, ValueError):
|
|
1241
|
+
name = ''
|
|
1242
|
+
name = name or os.uname().nodename
|
|
1243
|
+
return name if HOST_NAME.match(name) else None
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def fleet_due(ctx, now):
|
|
1247
|
+
"""True when FLEET_INTERVAL has passed since the cycle that took the last fleet sample (or none was taken yet)."""
|
|
1248
|
+
last = ctx.get('fleet_started')
|
|
1249
|
+
return last is None or now - last >= FLEET_INTERVAL
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def update_fleet(ctx, health, sample, due, now):
|
|
1253
|
+
"""ctx['fleet'] after this cycle -> it (None until a first sample). A new sample (compute_health's, taken when
|
|
1254
|
+
`due`) replaces the facts (`stale: false`) and is kept as the previous sample of the next one — in the context,
|
|
1255
|
+
never in a global. A due sample that was not taken (a critical host: compute_health took none; a failed read)
|
|
1256
|
+
keeps the last facts, `stale: true`, until one is."""
|
|
1257
|
+
if sample is not None:
|
|
1258
|
+
ctx['fleet'] = L.fleet_facts(sample, ctx.get('fleet_sample'), health, ctx.get('host_name'))
|
|
1259
|
+
ctx['fleet_sample'], ctx['fleet_started'] = sample, now
|
|
1260
|
+
elif due and ctx.get('fleet') and ctx['fleet'].get('stale') is not True:
|
|
1261
|
+
ctx['fleet'] = dict(ctx['fleet'], stale=True)
|
|
1262
|
+
return ctx.get('fleet')
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def site_settings():
|
|
1266
|
+
"""The server's site.conf LANES_* values, by the enrolment service's reader (read, never executed)."""
|
|
1267
|
+
import enrold
|
|
1268
|
+
return enrold.server_site()
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def daemon_container(registry, site):
|
|
1272
|
+
"""The container the Throttle daemon runs in: site.conf's LANES_THROTTLE_CONTAINER, else the container of the
|
|
1273
|
+
registry's `tools--throttle-monitor` tab (lanes_lib.build_registry: the monitor host most boards name), else
|
|
1274
|
+
None. A name that is no container name is None — never a guess in its place."""
|
|
1275
|
+
name = (site or {}).get('LANES_THROTTLE_CONTAINER')
|
|
1276
|
+
if not name:
|
|
1277
|
+
tools = (registry or {}).get('tools') if isinstance(registry, dict) else None
|
|
1278
|
+
tool = next((t for t in tools or [] if isinstance(t, dict) and t.get('id') == THROTTLE_TOOL), None)
|
|
1279
|
+
name = (tool or {}).get('container')
|
|
1280
|
+
return name if isinstance(name, str) and L.IDENT.match(name) else None
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def resolve_daemon(ctx, now):
|
|
1284
|
+
"""Which container hosts the daemon (site.conf, else the loaded registry) and its init pid: the one statusd
|
|
1285
|
+
tracks for that container each cycle, else — a container no lane runs in — the one `lanes sync` recorded in
|
|
1286
|
+
containers.json at this look. Never per cycle (the pulse adds ONE stat per cycle, nothing more): looked at again
|
|
1287
|
+
when the loaded registry changed, at most every FLEET_INTERVAL while the last snapshot could not open the
|
|
1288
|
+
container's ~/.lz/state (a container restarted under a new init pid), and every DAEMON_REFRESH s otherwise (an
|
|
1289
|
+
edited site.conf)."""
|
|
1290
|
+
last, registry = ctx.get('daemon_at'), ctx.get('registry')
|
|
1291
|
+
if last is not None and ctx.get('daemon_registry') is registry:
|
|
1292
|
+
wait = FLEET_INTERVAL if (ctx.get('pulse') or {}).get('no_dir') else DAEMON_REFRESH
|
|
1293
|
+
if now - last < wait:
|
|
1294
|
+
return
|
|
1295
|
+
ctx['daemon_at'], ctx['daemon_registry'] = now, registry
|
|
1296
|
+
try:
|
|
1297
|
+
site = site_settings()
|
|
1298
|
+
except Exception as error:
|
|
1299
|
+
log('site.conf', error)
|
|
1300
|
+
site = {}
|
|
1301
|
+
name = daemon_container(ctx.get('registry'), site)
|
|
1302
|
+
recorded = None
|
|
1303
|
+
if name and name not in (ctx.get('containers') or {}):
|
|
1304
|
+
known = L.read_json(L.STATE / 'containers.json', {})
|
|
1305
|
+
entry = known.get(name) if isinstance(known, dict) else None
|
|
1306
|
+
recorded = entry.get('pid') if isinstance(entry, dict) else None
|
|
1307
|
+
ctx['daemon'] = {'name': name, 'pid': recorded} if name else None
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def daemon_info(ctx):
|
|
1311
|
+
"""(container name, init pid or None) of the daemon's container, or None when no container is named."""
|
|
1312
|
+
daemon = ctx.get('daemon')
|
|
1313
|
+
if not daemon:
|
|
1314
|
+
return None
|
|
1315
|
+
tracked = (ctx.get('containers') or {}).get(daemon['name'])
|
|
1316
|
+
pid = tracked.get('pid') if isinstance(tracked, dict) else daemon.get('pid')
|
|
1317
|
+
return daemon['name'], pid if isinstance(pid, int) and pid > 0 else None
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _number(value):
|
|
1321
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _count(value):
|
|
1325
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _flag(value):
|
|
1329
|
+
return value if isinstance(value, bool) else None
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def pulse_projection(data):
|
|
1333
|
+
"""The pulse's bytes as status.json carries them, or None when they are no v1 pulse document (spec W11 §2.1).
|
|
1334
|
+
|
|
1335
|
+
The daemon's container writes that file, and the sidebar draws what is in it into terminals: nothing is copied as
|
|
1336
|
+
such. Only the spec's keys are, each of its own type — counts non-negative integers; fractions, dollars and
|
|
1337
|
+
timestamps finite numbers; flags booleans; `pressure_phase` one short lowercase word; `error` printable characters
|
|
1338
|
+
only, at most PULSE_ERROR_MAX — anything else null, unknown keys and pools dropped. No escape sequence rides
|
|
1339
|
+
through, and a pool the pulse did not write is not invented."""
|
|
1340
|
+
if data is None:
|
|
1341
|
+
return None
|
|
1342
|
+
try:
|
|
1343
|
+
document = json.loads(data.decode('utf-8'))
|
|
1344
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
1345
|
+
return None
|
|
1346
|
+
if not isinstance(document, dict) or _count(document.get('v')) != 1 or _number(document.get('at')) is None:
|
|
1347
|
+
return None
|
|
1348
|
+
pools, overflow = {}, {}
|
|
1349
|
+
for pool in PULSE_POOLS:
|
|
1350
|
+
row = (document.get('pools') or {}).get(pool) if isinstance(document.get('pools'), dict) else None
|
|
1351
|
+
if isinstance(row, dict):
|
|
1352
|
+
phase = row.get('pressure_phase')
|
|
1353
|
+
pools[pool] = dict({key: _count(row.get(key)) for key in PULSE_COUNTS},
|
|
1354
|
+
overflow_ready=_flag(row.get('overflow_ready')),
|
|
1355
|
+
pressure_phase=phase if isinstance(phase, str) and PULSE_PHASE.match(phase) else None,
|
|
1356
|
+
spill_fraction=_number(row.get('spill_fraction')),
|
|
1357
|
+
overflow_in_flight=_count(row.get('overflow_in_flight')),
|
|
1358
|
+
upstream_429_total=_count(row.get('upstream_429_total')),
|
|
1359
|
+
worst_7d=_number(row.get('worst_7d')), worst_5h=_number(row.get('worst_5h')),
|
|
1360
|
+
soonest_reset=_number(row.get('soonest_reset')))
|
|
1361
|
+
money = (document.get('overflow') or {}).get(pool) if isinstance(document.get('overflow'), dict) else None
|
|
1362
|
+
if isinstance(money, dict):
|
|
1363
|
+
overflow[pool] = {key: _number(money.get(key)) for key in PULSE_OVERFLOW}
|
|
1364
|
+
error = document.get('error')
|
|
1365
|
+
error = ''.join(c for c in error if c.isprintable())[:PULSE_ERROR_MAX] if isinstance(error, str) else ''
|
|
1366
|
+
return {'v': 1, 'at': int(document['at']), 'up': _flag(document.get('up')), 'tokened': _flag(document.get('tokened')),
|
|
1367
|
+
'pools': pools, 'overflow': overflow, 'error': error or None}
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def throttle_snapshot(ctx, now):
|
|
1371
|
+
"""The `throttle` block of status.json, or None when no daemon container is named (no Throttle here: no key).
|
|
1372
|
+
|
|
1373
|
+
ONE stat of the pulse file per cycle — through /proc/<daemon container init>/root, every directory down to
|
|
1374
|
+
~/.lz/state walked with O_NOFOLLOW (lanes_lib.open_real_dir: the container owns it, and a link planted there would
|
|
1375
|
+
resolve against the HOST's root) and the file itself lstat'ed — and a read only when its (init pid, mtime, inode,
|
|
1376
|
+
size) moved: `read_regular_at`, a regular file of at most PULSE_MAX bytes, never a link, never a FIFO. What it
|
|
1377
|
+
holds reaches status.json only through `pulse_projection`. The last values are kept when the file goes away, is
|
|
1378
|
+
refused or does not parse, `stale: true` then — and whenever their `at` is more than PULSE_STALE_S old by this
|
|
1379
|
+
clock; with none ever read the block is `{"stale": true}`. ctx['pulse']['mtime'] (the file's own age, None while
|
|
1380
|
+
it cannot be seen) is what `rearm_pulse` judges."""
|
|
1381
|
+
found = daemon_info(ctx)
|
|
1382
|
+
if found is None:
|
|
1383
|
+
ctx.pop('pulse', None)
|
|
1384
|
+
return None
|
|
1385
|
+
name, pid = found
|
|
1386
|
+
pulse = ctx.get('pulse')
|
|
1387
|
+
if not isinstance(pulse, dict) or pulse.get('name') != name:
|
|
1388
|
+
pulse = ctx['pulse'] = {'name': name}
|
|
1389
|
+
stamp = mtime = fd = None
|
|
1390
|
+
why = None if pid else 'container not running'
|
|
1391
|
+
pulse['no_dir'] = not pid
|
|
1392
|
+
if pid:
|
|
1393
|
+
try:
|
|
1394
|
+
fd = L.open_real_dir(Path(L.proc_root()) / str(pid) / 'root', PULSE_DIR)
|
|
1395
|
+
except FileNotFoundError: # the init is gone (a restart: resolve_daemon looks again soon), or no ~/.lz/state
|
|
1396
|
+
pulse['no_dir'] = not pid_alive(pid)
|
|
1397
|
+
why = 'container not running' if pulse['no_dir'] else 'no ~/.lz/state'
|
|
1398
|
+
except (OSError, L.LanesError) as error:
|
|
1399
|
+
why = f'~/.lz/state refused ({type(error).__name__})'
|
|
1400
|
+
if fd is not None:
|
|
1401
|
+
try:
|
|
1402
|
+
info = os.stat(PULSE_FILE, dir_fd=fd, follow_symlinks=False)
|
|
1403
|
+
stamp, mtime = (pid, info.st_mtime_ns, info.st_ino, info.st_size), info.st_mtime
|
|
1404
|
+
if stamp != pulse.get('stamp'):
|
|
1405
|
+
document = pulse_projection(L.read_regular_at(fd, PULSE_FILE, PULSE_MAX))
|
|
1406
|
+
pulse['valid'] = document is not None
|
|
1407
|
+
if document is not None:
|
|
1408
|
+
pulse['doc'] = document
|
|
1409
|
+
if not pulse.get('valid'):
|
|
1410
|
+
why = 'refused (no regular file of at most 64 KiB holding a v1 pulse document)'
|
|
1411
|
+
except FileNotFoundError:
|
|
1412
|
+
why = 'absent'
|
|
1413
|
+
except OSError as error:
|
|
1414
|
+
why = f'unreadable ({type(error).__name__})'
|
|
1415
|
+
finally:
|
|
1416
|
+
os.close(fd)
|
|
1417
|
+
pulse['stamp'], pulse['mtime'] = stamp, mtime
|
|
1418
|
+
if why != pulse.get('why', 'unseen'):
|
|
1419
|
+
note('throttle pulse', f'{name}: {why or "ok"}')
|
|
1420
|
+
pulse['why'] = why
|
|
1421
|
+
document = pulse.get('doc')
|
|
1422
|
+
stale = why is not None or document is None or now - document['at'] > PULSE_STALE_S
|
|
1423
|
+
return dict(document or {}, stale=stale)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def pulse_rearm_argv(container):
|
|
1427
|
+
"""The one spawn of the fleet feature: the pulse loop started detached in its own tmux server in the container,
|
|
1428
|
+
as the container user (CONTAINER_UID, like every other exec Lanes makes into a container: the loop must write
|
|
1429
|
+
/home/coder/.lz/state, and a later re-arm must find its tmux server) with HOME=CONTAINER_HOME whatever the image
|
|
1430
|
+
sets (the pulse's `~/.lz/state` and `~/.lz/throttle-token` defaults expand against it) — byte for byte the argv
|
|
1431
|
+
`lanes install-container` starts it with (bin/lanes `pulse_argv`). argv only, no shell."""
|
|
1432
|
+
return ['docker', 'exec', '-d', '-u', str(L.CONTAINER_UID), '-e', f'HOME={L.CONTAINER_HOME}', container, 'tmux',
|
|
1433
|
+
'-L', 'lanes-pulse', 'new', '-d', '-s', 'pulse', f'{L.CONTAINER_HOME}/.lz/bin/{PULSE_PROGRAM}', '--loop']
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def pulse_installed(pid):
|
|
1437
|
+
"""True when ~/.lz/bin/throttle-pulse is a regular file in the container (`lanes install-container` shipped it),
|
|
1438
|
+
looked at through /proc/<pid>/root without following a link."""
|
|
1439
|
+
try:
|
|
1440
|
+
fd = L.open_real_dir(Path(L.proc_root()) / str(pid) / 'root', PULSE_BIN)
|
|
1441
|
+
except (OSError, L.LanesError):
|
|
1442
|
+
return False
|
|
1443
|
+
try:
|
|
1444
|
+
return stat.S_ISREG(os.stat(PULSE_PROGRAM, dir_fd=fd, follow_symlinks=False).st_mode)
|
|
1445
|
+
except OSError:
|
|
1446
|
+
return False
|
|
1447
|
+
finally:
|
|
1448
|
+
os.close(fd)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def rearm_pulse(ctx, level, now=None):
|
|
1452
|
+
"""main()'s restart of a stopped Throttle pulse, between two cycles and never inside one -> True when it spawned.
|
|
1453
|
+
|
|
1454
|
+
Only while a daemon container is named and its init is alive, the host is not `critical`, the pulse file is
|
|
1455
|
+
older than PULSE_REARM_S by its mtime (or cannot be seen at all), and the last attempt is at least
|
|
1456
|
+
PULSE_REARM_EVERY seconds ago. An attempt is counted whatever comes of it, so a failing one is not retried every
|
|
1457
|
+
cycle; it starts nothing when ~/.lz/bin/throttle-pulse is not there. Then ONE bounded `docker exec -d`
|
|
1458
|
+
(pulse_rearm_argv, PULSE_REARM_TIMEOUT s), its outcome journalled."""
|
|
1459
|
+
now = time.time() if now is None else now
|
|
1460
|
+
found = daemon_info(ctx)
|
|
1461
|
+
if level == 'critical' or found is None:
|
|
1462
|
+
return False
|
|
1463
|
+
name, pid = found
|
|
1464
|
+
mtime = (ctx.get('pulse') or {}).get('mtime')
|
|
1465
|
+
if mtime is not None and now - mtime <= PULSE_REARM_S:
|
|
1466
|
+
return False
|
|
1467
|
+
last = ctx.get('pulse_rearm_at')
|
|
1468
|
+
if last is not None and now - last < PULSE_REARM_EVERY:
|
|
1469
|
+
return False
|
|
1470
|
+
if not pid_alive(pid):
|
|
1471
|
+
return False
|
|
1472
|
+
ctx['pulse_rearm_at'] = now
|
|
1473
|
+
if not pulse_installed(pid):
|
|
1474
|
+
note('throttle pulse', f'{name}: quiet, and ~/.lz/bin/{PULSE_PROGRAM} is not installed there '
|
|
1475
|
+
f'(lanes install-container {name}): not started')
|
|
1476
|
+
return False
|
|
1477
|
+
argv = pulse_rearm_argv(name)
|
|
1478
|
+
rc, _out, err = L.run_bounded(argv, PULSE_REARM_TIMEOUT)
|
|
1479
|
+
age = 'not seen' if mtime is None else f'{int(now - mtime)} s old'
|
|
1480
|
+
if rc is None:
|
|
1481
|
+
outcome = f'no answer within {PULSE_REARM_TIMEOUT} s'
|
|
1482
|
+
else:
|
|
1483
|
+
last_line = err.strip().splitlines()[-1][:160] if rc and err.strip() else ''
|
|
1484
|
+
outcome = f'exit {rc}' + (f': {last_line}' if last_line else '')
|
|
1485
|
+
note('throttle pulse', f'{name}: pulse file {age}, re-armed ({outcome}): {" ".join(argv)}')
|
|
1486
|
+
return True
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def compared(payload):
|
|
1490
|
+
"""`payload` as the rewrite decision sees it: without the sample time of `fleet` (new every FLEET_INTERVAL) and
|
|
1491
|
+
of `throttle` (the pulse's 60-s heartbeat) — the quantized facts and the stale marks are what may move a rewrite
|
|
1492
|
+
(like `health`, which is not in the payload at all); a block whose only news is its `at` waits for HEARTBEAT."""
|
|
1493
|
+
if not isinstance(payload, dict):
|
|
1494
|
+
return payload
|
|
1495
|
+
out = dict(payload)
|
|
1496
|
+
for key in ('fleet', 'throttle'):
|
|
1497
|
+
if isinstance(out.get(key), dict):
|
|
1498
|
+
out[key] = {name: value for name, value in out[key].items() if name != 'at'}
|
|
1499
|
+
return out
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def with_fleet(ctx, payload, health, sample, due, now):
|
|
1503
|
+
"""`payload` plus `fleet` and `throttle`, each failing alone: a fleet that could not be computed is the last one,
|
|
1504
|
+
stale; a pulse that could not be looked at is the last block published, stale."""
|
|
1505
|
+
out = dict(payload)
|
|
1506
|
+
try:
|
|
1507
|
+
fleet = update_fleet(ctx, health, sample, due, now)
|
|
1508
|
+
except Exception as error:
|
|
1509
|
+
log('fleet', error)
|
|
1510
|
+
fleet = dict(ctx['fleet'], stale=True) if ctx.get('fleet') else None
|
|
1511
|
+
if fleet:
|
|
1512
|
+
out['fleet'] = fleet
|
|
1513
|
+
try:
|
|
1514
|
+
resolve_daemon(ctx, now)
|
|
1515
|
+
throttle = throttle_snapshot(ctx, now)
|
|
1516
|
+
except Exception as error:
|
|
1517
|
+
log('throttle pulse', error)
|
|
1518
|
+
last = (ctx.get('payload') or {}).get('throttle')
|
|
1519
|
+
throttle = dict(last, stale=True) if isinstance(last, dict) else None
|
|
1520
|
+
if throttle is not None:
|
|
1521
|
+
out['throttle'] = throttle
|
|
1522
|
+
return out
|
|
1523
|
+
|
|
1524
|
+
|
|
1037
1525
|
def cycle(ctx, reconcile=True):
|
|
1038
1526
|
"""Observe once and ALWAYS publish -> (health level, payload or None)."""
|
|
1039
1527
|
started = time.time()
|
|
1040
|
-
|
|
1041
|
-
|
|
1528
|
+
due = fleet_due(ctx, started)
|
|
1529
|
+
try: # every FLEET_INTERVAL the fleet counters too (W11; compute_health takes none on a critical host)
|
|
1530
|
+
health = L.compute_health(sample=due)
|
|
1042
1531
|
except Exception as error:
|
|
1043
1532
|
log('health', error)
|
|
1044
1533
|
health = {}
|
|
1534
|
+
sample = health.pop('sample', None)
|
|
1045
1535
|
level = health.get('level', 'ok')
|
|
1046
1536
|
try:
|
|
1047
1537
|
payload = build_payload(ctx, level, reconcile)
|
|
@@ -1049,10 +1539,12 @@ def cycle(ctx, reconcile=True):
|
|
|
1049
1539
|
log('cycle', error)
|
|
1050
1540
|
payload = None
|
|
1051
1541
|
if payload is not None:
|
|
1542
|
+
payload = with_fleet(ctx, payload, health, sample, due, started)
|
|
1052
1543
|
ctx['payload'] = payload
|
|
1053
1544
|
# Rewrite only on change (plus a heartbeat) so sidebars do not repaint for nothing. `health` changes every
|
|
1054
|
-
# cycle and is therefore not part of the comparison.
|
|
1055
|
-
if
|
|
1545
|
+
# cycle and is therefore not part of the comparison; nor is the fleet's sample time (`compared`).
|
|
1546
|
+
if (ctx['published'] is None or compared(payload) != compared(ctx['published'])
|
|
1547
|
+
or started - ctx['status_at'] > HEARTBEAT):
|
|
1056
1548
|
try:
|
|
1057
1549
|
written = time.time()
|
|
1058
1550
|
L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written,
|
|
@@ -1147,6 +1639,12 @@ def main():
|
|
|
1147
1639
|
while True:
|
|
1148
1640
|
started = time.time()
|
|
1149
1641
|
level, _payload = cycle(ctx)
|
|
1642
|
+
# The docs links' origin (plan W10 W7): a bounded look between two cycles, never inside one.
|
|
1643
|
+
refresh_origin(ctx, level)
|
|
1644
|
+
try: # W11: a pulse file gone quiet -> one bounded re-arm, at most every PULSE_REARM_EVERY s, never critical
|
|
1645
|
+
rearm_pulse(ctx, level)
|
|
1646
|
+
except Exception as error:
|
|
1647
|
+
log('throttle pulse', error)
|
|
1150
1648
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
1151
1649
|
# Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
|
|
1152
1650
|
if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
|