@vibelord/lanes 1.3.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 +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +790 -31
- 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 +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +165 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +392 -4
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- 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/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -3
|
@@ -17,11 +17,19 @@ or positively does not exist.
|
|
|
17
17
|
The docs cockpit (plan W10 W7): a lane row carries `docs: {n, at}` while docs/counts.json counts documents for it
|
|
18
18
|
(one stat of that file per cycle), and the document `web_origin`, the https origin the docs page answers on, once
|
|
19
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.
|
|
20
26
|
"""
|
|
21
27
|
import concurrent.futures
|
|
22
28
|
import json
|
|
29
|
+
import math
|
|
23
30
|
import os
|
|
24
31
|
import re
|
|
32
|
+
import stat
|
|
25
33
|
import subprocess
|
|
26
34
|
import sys
|
|
27
35
|
import time
|
|
@@ -72,12 +80,39 @@ WEB_PORT = 8443 # bin/lanes SERVE_PORT: `tailscale serve --https=8443` -
|
|
|
72
80
|
ORIGIN_TTL = 300 # enrold.TAILNET_TTL
|
|
73
81
|
HOST_NAME = re.compile(r'(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
|
74
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
|
|
75
105
|
|
|
76
106
|
|
|
77
107
|
def log(where, error):
|
|
78
108
|
print(time.strftime('%FT%T'), where, type(error).__name__, error, flush=True)
|
|
79
109
|
|
|
80
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
|
+
|
|
81
116
|
# ------------------------------------------------- the viewer rule (A4a)
|
|
82
117
|
# A Throttle Studio browser attaches with `new-session -t <lane> -s view-<studio>-<ch>`: an independent
|
|
83
118
|
# current window on the lane's windows. Such a session is a CLIENT of the lane, not a lane of its own, and
|
|
@@ -295,34 +330,92 @@ def settled(previous, lane_id, now, raw):
|
|
|
295
330
|
return AS.settle(old.get('agent') if isinstance(old, dict) else None, raw, now)
|
|
296
331
|
|
|
297
332
|
|
|
298
|
-
|
|
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):
|
|
299
348
|
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
300
349
|
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
301
|
-
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.
|
|
302
359
|
|
|
303
360
|
Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
|
|
304
|
-
(`parse_codex_title
|
|
305
|
-
child read (`child_liveness`) tells that apart from a live harness launched under `bash -c`
|
|
306
|
-
a live Claude session file already claims is not probed (the file outranks the title)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
|
310
371
|
for container in sorted({container for container, _socket in sessions_by_server}):
|
|
311
372
|
info = containers.get(container) or {}
|
|
312
373
|
if not info.get('pid'):
|
|
313
374
|
continue
|
|
314
375
|
root = Path(L.proc_root()) / str(info['pid']) / 'root'
|
|
315
376
|
try:
|
|
316
|
-
claude
|
|
317
|
-
root / 'proc')
|
|
377
|
+
claude = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions', root / 'proc')
|
|
318
378
|
except Exception as error:
|
|
319
379
|
log(f'agents {container}', error)
|
|
320
|
-
claude
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if str(pane.get('pane_pid') or '').isdigit()
|
|
324
|
-
|
|
325
|
-
|
|
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]
|
|
326
419
|
if not probe:
|
|
327
420
|
continue
|
|
328
421
|
try:
|
|
@@ -334,7 +427,15 @@ def agent_signals(containers, sessions_by_server):
|
|
|
334
427
|
known = live.get(int(pane['pane_pid']))
|
|
335
428
|
if known is not None:
|
|
336
429
|
pane['pane_has_child'] = known
|
|
337
|
-
|
|
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', {}))
|
|
338
439
|
|
|
339
440
|
|
|
340
441
|
SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
|
|
@@ -532,10 +633,10 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
532
633
|
report['cut'] = len(cut)
|
|
533
634
|
# Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
|
|
534
635
|
try:
|
|
535
|
-
|
|
636
|
+
signals = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()}, now)
|
|
536
637
|
except Exception as error:
|
|
537
638
|
log('agents', error)
|
|
538
|
-
|
|
639
|
+
signals = {}
|
|
539
640
|
# Task 8: the hook sidecars `lane-signal` writes, one directory listing per container per cycle. They outrank the
|
|
540
641
|
# session file and the title in `agent_state.merge`; a container without hooks costs one failed listdir.
|
|
541
642
|
try:
|
|
@@ -620,8 +721,8 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
620
721
|
entry['note'] = 'session name outside the lane declaration grammar'
|
|
621
722
|
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
|
|
622
723
|
settled(previous, entry['id'], now,
|
|
623
|
-
|
|
624
|
-
|
|
724
|
+
merged(pane, signals.get(container, {}),
|
|
725
|
+
sidecars.get(container, {}).get(entry['id']))))
|
|
625
726
|
except Exception as error:
|
|
626
727
|
log(f'observe {container}/{socket}/{name}', error)
|
|
627
728
|
continue
|
|
@@ -641,8 +742,8 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
641
742
|
try:
|
|
642
743
|
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
|
|
643
744
|
settled(previous, lane['id'], now,
|
|
644
|
-
|
|
645
|
-
|
|
745
|
+
merged(pane, signals.get(container, {}),
|
|
746
|
+
sidecars.get(container, {}).get(lane['id']))))
|
|
646
747
|
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
647
748
|
log(f"observe {lane['id']}", error)
|
|
648
749
|
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
@@ -1018,7 +1119,8 @@ def new_context():
|
|
|
1018
1119
|
and HOST_NAME.match(origin[len('https://'):-len(f':{WEB_PORT}')]))
|
|
1019
1120
|
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
1020
1121
|
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
|
|
1021
|
-
'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None
|
|
1122
|
+
'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None,
|
|
1123
|
+
'host_name': read_host_name()}
|
|
1022
1124
|
|
|
1023
1125
|
|
|
1024
1126
|
# ------------------------------------------- the in-container projection (LNS-32)
|
|
@@ -1124,14 +1226,312 @@ def _container_status_error(errors, container, error):
|
|
|
1124
1226
|
log(f'container status {container}', error)
|
|
1125
1227
|
|
|
1126
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
|
+
|
|
1127
1525
|
def cycle(ctx, reconcile=True):
|
|
1128
1526
|
"""Observe once and ALWAYS publish -> (health level, payload or None)."""
|
|
1129
1527
|
started = time.time()
|
|
1130
|
-
|
|
1131
|
-
|
|
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)
|
|
1132
1531
|
except Exception as error:
|
|
1133
1532
|
log('health', error)
|
|
1134
1533
|
health = {}
|
|
1534
|
+
sample = health.pop('sample', None)
|
|
1135
1535
|
level = health.get('level', 'ok')
|
|
1136
1536
|
try:
|
|
1137
1537
|
payload = build_payload(ctx, level, reconcile)
|
|
@@ -1139,10 +1539,12 @@ def cycle(ctx, reconcile=True):
|
|
|
1139
1539
|
log('cycle', error)
|
|
1140
1540
|
payload = None
|
|
1141
1541
|
if payload is not None:
|
|
1542
|
+
payload = with_fleet(ctx, payload, health, sample, due, started)
|
|
1142
1543
|
ctx['payload'] = payload
|
|
1143
1544
|
# Rewrite only on change (plus a heartbeat) so sidebars do not repaint for nothing. `health` changes every
|
|
1144
|
-
# cycle and is therefore not part of the comparison.
|
|
1145
|
-
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):
|
|
1146
1548
|
try:
|
|
1147
1549
|
written = time.time()
|
|
1148
1550
|
L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written,
|
|
@@ -1239,6 +1641,10 @@ def main():
|
|
|
1239
1641
|
level, _payload = cycle(ctx)
|
|
1240
1642
|
# The docs links' origin (plan W10 W7): a bounded look between two cycles, never inside one.
|
|
1241
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)
|
|
1242
1648
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
1243
1649
|
# Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
|
|
1244
1650
|
if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
|
|
@@ -93,7 +93,18 @@ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'v
|
|
|
93
93
|
'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent'),
|
|
94
94
|
# The docs cockpit on the upload strip (` ↑ uploads · ▤ docs 3 (U)`, plan W10 W7): the strip's own tone.
|
|
95
95
|
# Seven-bit `#` is free on that row (`^` uploads, `v` armed, `-` sep are the others).
|
|
96
|
-
'docs': ('▤', '#', 'ink-dim')
|
|
96
|
+
'docs': ('▤', '#', 'ink-dim'),
|
|
97
|
+
# The fleet block (W11, spec 2026-10-11 §3), pinned above the upload strip: column 2 of each row says what
|
|
98
|
+
# the row is about — Throttle ⚙, a provider's subscription pool ◆, the host and its containers ▣ — in the
|
|
99
|
+
# row's worst tone; then a provider's availability bar, ten cells of usable ▰ · cooling ▱ · benched ▪ ·
|
|
100
|
+
# dead ·. The seven-bit twins are unique among the block's own marks and against every other mark its rows
|
|
101
|
+
# draw (`-` sep, `?` stale, `OK` / `!` / `X` the folded line's verdict). `#` stays the docs twin alone; the
|
|
102
|
+
# unicode `·` of a dead cell is the spec's (it shares the separator's glyph, never its place: a bar has no
|
|
103
|
+
# blank inside it).
|
|
104
|
+
'fleet-throttle': ('⚙', '@', 'ink-dim'), 'fleet-provider': ('◆', '*', 'ink-dim'),
|
|
105
|
+
'fleet-host': ('▣', '[', 'ink-dim'),
|
|
106
|
+
'bar-usable': ('▰', '=', 'tone-done'), 'bar-cooling': ('▱', '~', 'ink-dim'),
|
|
107
|
+
'bar-benched': ('▪', ':', 'tone-attention'), 'bar-dead': ('·', '.', 'ink-faint')}
|
|
97
108
|
ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
|
|
98
109
|
MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
|
|
99
110
|
'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
|