@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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. 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
- def agent_signals(containers, sessions_by_server):
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`) over a bare-shell foreground can be a harness that ended into its shell, and only the
289
- child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
290
- a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
291
- a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1557). A
292
- container whose files cannot be read costs only its own agents, never a lane its `state`."""
293
- claude = {}
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[container] = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions',
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[container] = {}
305
- probe = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
306
- for pane in sessions.values()
307
- if str(pane.get('pane_pid') or '').isdigit() and AS.parse_codex_title(pane.get('pane_title'))
308
- and AS._shell_foreground(pane)
309
- and f"{pane.get('session_name')}:{pane.get('window_id')}.{pane.get('pane_id')}" not in claude[container]]
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
- return claude
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
- claude = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()})
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
- claude = {}
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
- AS.merge(pane, claude.get(container, {}),
608
- sidecars.get(container, {}).get(entry['id']))))
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
- AS.merge(pane, claude.get(container, {}),
629
- sidecars.get(container, {}).get(lane['id']))))
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
- return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
919
- 'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
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
- CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale')
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
- try:
1041
- health = L.compute_health()
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 payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
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']: