@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.
@@ -39,6 +39,22 @@ def current_link(home=None):
39
39
  return (Path(home) if home is not None else Path.home()) / CURRENT_LINK
40
40
 
41
41
 
42
+ # The server's site facts (~/.config/lanes/site.conf): LANES_* KEY=VALUE lines, read and never executed. bin/lanes
43
+ # site_profile and update.daemon_container read them here; the same line is what `lanes client-config` hands out.
44
+ SITE_CONF = Path('.config/lanes/site.conf')
45
+ SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$')
46
+
47
+
48
+ def site_conf(home=None):
49
+ """`<home>/.config/lanes/site.conf`'s LANES_* facts (default: this account's home) -> {key: value}; the last line
50
+ naming a key wins; {} when there is no file or it cannot be read."""
51
+ try:
52
+ text = ((Path(home) if home is not None else Path.home()) / SITE_CONF).read_text()
53
+ except (OSError, UnicodeDecodeError, KeyError, RuntimeError):
54
+ return {}
55
+ return dict(match.groups() for match in map(SITE_LINE.match, text.splitlines()) if match)
56
+
57
+
42
58
  def staged_tree(path):
43
59
  """Whether `path` is a tree the npm wrapper staged, not a checkout: it sits in the default pkg root
44
60
  (`…/pkg/<version>`), or it carries the wrapper's stamp (PACKAGE_STAMP, a regular file lanes-install.mjs stage()
@@ -855,6 +871,7 @@ def tmux_query(info, socket_name, args, timeout=5):
855
871
  # -------------------------------------------------------------------- health
856
872
  # Host pressure from PLAIN FILES only — no subprocess: this is read exactly when spawning is what hurts.
857
873
 
874
+ GIB = 2 ** 30
858
875
  HEALTH_LINE = ('at', 'level', 'mem_some10', 'mem_full10', 'io_some10', 'cpu_some10', 'swap_pct', 'load1', 'status_at')
859
876
 
860
877
 
@@ -874,12 +891,17 @@ def _psi10(text, kind):
874
891
  return 0
875
892
 
876
893
 
877
- def _container_memory(proc, cgroup_root, pid):
878
- """cgroup-v2 memory of the container whose init is `pid`; the cgroup is found the way the kernel names it."""
894
+ def _container_cgroup(proc, cgroup_root, pid):
895
+ """The cgroup-v2 directory of the container whose init is `pid`, found the way the kernel names it (or None)."""
879
896
  match = re.search(r'^0::(/\S*)$', _text(proc / str(pid) / 'cgroup'), re.M) if pid else None
880
897
  if not match or '..' in match.group(1).split('/'):
881
898
  return None
882
- base = cgroup_root / match.group(1).lstrip('/')
899
+ return cgroup_root / match.group(1).lstrip('/')
900
+
901
+
902
+ def _container_memory(base):
903
+ """cgroup-v2 memory of one container (`base` = its cgroup directory) -> (the health row, memory.current in bytes),
904
+ or None when the cgroup has no readable memory.current (a container that is down)."""
883
905
  current = _text(base / 'memory.current').strip()
884
906
  if not current.isdigit():
885
907
  return None
@@ -889,13 +911,34 @@ def _container_memory(proc, cgroup_root, pid):
889
911
  found = re.search(r'^' + key + r' (\d+)$', _text(base / name), re.M)
890
912
  return int(found.group(1)) if found else 0
891
913
 
892
- gib = 2 ** 30
893
- return {'cur_gib': round(int(current) / gib, 1), 'max_gib': round(int(limit) / gib, 1) if limit.isdigit() else None,
894
- 'shmem_gib': round(counter('memory.stat', 'shmem') / gib, 1), # shmem = RAM-backed /tmp and /dev/shm
895
- # anon + shmem is what cannot be given back; `file` minus shmem is page cache, which always grows to the
896
- # limit on a busy container ("207.9 of 208" is normal and says nothing about pressure)
897
- 'anon_gib': round(counter('memory.stat', 'anon') / gib, 1),
898
- 'oom_kill': counter('memory.events', 'oom_kill')}
914
+ return ({'cur_gib': round(int(current) / GIB, 1), 'max_gib': round(int(limit) / GIB, 1) if limit.isdigit() else None,
915
+ 'shmem_gib': round(counter('memory.stat', 'shmem') / GIB, 1), # shmem = RAM-backed /tmp and /dev/shm
916
+ # anon + shmem is what cannot be given back; `file` minus shmem is page cache, which always grows to the
917
+ # limit on a busy container ("207.9 of 208" is normal and says nothing about pressure)
918
+ 'anon_gib': round(counter('memory.stat', 'anon') / GIB, 1),
919
+ 'oom_kill': counter('memory.events', 'oom_kill')}, int(current))
920
+
921
+
922
+ def _cpu_usage_usec(base):
923
+ """The container's cumulative cpu time (cgroup-v2 `cpu.stat` usage_usec), or None when the file has none."""
924
+ found = re.search(r'^usage_usec (\d+)$', _text(base / 'cpu.stat'), re.M)
925
+ return int(found.group(1)) if found else None
926
+
927
+
928
+ def _proc_stat_cpu(proc):
929
+ """[busy, total] jiffies of the aggregate `cpu` line of /proc/stat (user nice system idle iowait irq softirq steal;
930
+ guest time is already inside user and nice), or None. Cumulative since boot: only a difference is a utilization."""
931
+ match = re.search(r'^cpu[ \t]+([0-9 \t]+)$', _text(proc / 'stat'), re.M)
932
+ fields = [int(word) for word in match.group(1).split()][:8] if match else []
933
+ if len(fields) < 4:
934
+ return None
935
+ total = sum(fields)
936
+ return [total - fields[3] - (fields[4] if len(fields) > 4 else 0), total]
937
+
938
+
939
+ def _int_file(path):
940
+ text = _text(path).strip()
941
+ return int(text) if text.isdigit() else None
899
942
 
900
943
 
901
944
  def health_level(health):
@@ -909,32 +952,109 @@ def health_level(health):
909
952
  return 'ok'
910
953
 
911
954
 
912
- def compute_health(paths=None):
955
+ def compute_health(paths=None, sample=False):
913
956
  """paths (tests): 'proc' (else $LANES_PROC_ROOT, else /proc), 'cgroup' (/sys/fs/cgroup), 'containers'
914
- ($STATE/containers.json: whose init pids to look up), 'ncpu'. A file that is missing reads as 0."""
957
+ ($STATE/containers.json: whose init pids to look up), 'ncpu', 'now'. A file that is missing reads as 0.
958
+
959
+ `sample` (statusd, every FLEET_INTERVAL; spec W11 §2.2): also read the fleet's raw counters into
960
+ health['sample'] — the aggregate `cpu` line of /proc/stat, MemTotal/MemAvailable (from the meminfo this reads
961
+ anyway), the inotify watch CEILING (never the watches in use: counting those walks every process's fdinfo), and
962
+ per container its memory.current in bytes and its cgroup `cpu.stat` usage_usec. Not on a `critical` host: no
963
+ `sample` key then, and nothing more is read where reading is what hurts. The counters are cumulative; `fleet_facts`
964
+ turns two samples into rates, and the previous sample is kept by the CALLER (statusd's context), never here."""
915
965
  paths = paths or {}
916
966
  proc = Path(paths.get('proc') or proc_root())
917
967
  pressure = {name: _text(proc / 'pressure' / name) for name in ('memory', 'io', 'cpu')}
918
- meminfo = {key: int(value) for key, value in re.findall(r'^(SwapTotal|SwapFree):\s+(\d+)', _text(proc / 'meminfo'), re.M)}
968
+ meminfo = {key: int(value) for key, value in re.findall(r'^(SwapTotal|SwapFree|MemTotal|MemAvailable):\s+(\d+)',
969
+ _text(proc / 'meminfo'), re.M)}
919
970
  try:
920
971
  load1 = float(_text(proc / 'loadavg').split()[0])
921
972
  except (IndexError, ValueError):
922
973
  load1 = 0.0
923
974
  swap_total = meminfo.get('SwapTotal', 0)
924
- health = {'at': time.time(), 'level': 'ok',
975
+ now = paths.get('now')
976
+ health = {'at': time.time() if now is None else now, 'level': 'ok',
925
977
  'mem_some10': _psi10(pressure['memory'], 'some'), 'mem_full10': _psi10(pressure['memory'], 'full'),
926
978
  'io_some10': _psi10(pressure['io'], 'some'), 'cpu_some10': _psi10(pressure['cpu'], 'some'),
927
979
  'swap_pct': int(round(100 * (swap_total - meminfo.get('SwapFree', 0)) / swap_total)) if swap_total else 0,
928
980
  'load1': load1, 'ncpu': int(paths.get('ncpu') or os.cpu_count() or 1), 'containers': {}}
929
981
  health['level'] = health_level(health)
982
+ raw = None
983
+ if sample and health['level'] != 'critical':
984
+ kib = {key: meminfo[key] * 1024 if key in meminfo else None for key in ('MemTotal', 'MemAvailable')}
985
+ raw = health['sample'] = {'at': health['at'], 'cpu': _proc_stat_cpu(proc), 'mem_total': kib['MemTotal'],
986
+ 'mem_avail': kib['MemAvailable'],
987
+ 'inotify_max': _int_file(proc / 'sys/fs/inotify/max_user_watches'), 'containers': {}}
930
988
  recorded = read_json(paths.get('containers') or STATE / 'containers.json', {}) or {}
989
+ cgroup_root = Path(paths.get('cgroup') or '/sys/fs/cgroup')
931
990
  for name, info in recorded.items():
932
- memory = _container_memory(proc, Path(paths.get('cgroup') or '/sys/fs/cgroup'), (info or {}).get('pid'))
991
+ pid = (info or {}).get('pid')
992
+ base = _container_cgroup(proc, cgroup_root, pid)
993
+ memory = _container_memory(base) if base else None
933
994
  if memory:
934
- health['containers'][name] = memory
995
+ health['containers'][name] = memory[0]
996
+ if raw is not None:
997
+ raw['containers'][name] = {'pid': pid, 'mem': memory[1], 'usec': _cpu_usage_usec(base)}
935
998
  return health
936
999
 
937
1000
 
1001
+ # The `fleet` block of status.json (spec W11 §2.2, DM-LANES-FLEET): OpenTelemetry semantic-convention names as the
1002
+ # keys, verbatim. Every figure is quantized — fractions to 1 %, percents to whole percents, bytes to 0.1 GiB, the load
1003
+ # to 0.1 — so a sample that moved nothing a reader can see renders the same bytes and does not rewrite status.json.
1004
+ FLEET_VERSION = 1
1005
+ FLEET_TOP = 3 # containers published, the biggest by memory first
1006
+
1007
+
1008
+ def gib_tenths(value):
1009
+ """A byte count quantized to 0.1 GiB, still in bytes (an int); None stays None."""
1010
+ return None if value is None else int(round(round(value * 10 / GIB) * GIB / 10))
1011
+
1012
+
1013
+ def _rate(after, before):
1014
+ """after - before when both are counts and the counter did not go back (a restart), else None."""
1015
+ if isinstance(after, int) and isinstance(before, int) and after >= before:
1016
+ return after - before
1017
+ return None
1018
+
1019
+
1020
+ def fleet_facts(sample, previous, health, host_name):
1021
+ """The `fleet` document from this `compute_health(sample=True)` sample and the `previous` one (None or {} on the
1022
+ first; the caller keeps it). A rate needs two samples: on the first, `system.cpu.utilization` and every
1023
+ `container.cpu.percent` are null — never a since-boot average. A container whose init pid changed since the
1024
+ previous sample (restarted) or whose counter went back has a null `container.cpu.percent` for this sample.
1025
+ `container.cpu.percent` is of the whole host: usage_usec delta ÷ interval ÷ logical cpus × 100. `containers` are
1026
+ the FLEET_TOP biggest by memory.current (ties by name) of every container whose cgroup answered."""
1027
+ previous = previous or {}
1028
+ ncpu = int(health.get('ncpu') or 1)
1029
+ seconds = sample['at'] - previous['at'] if isinstance(previous.get('at'), (int, float)) else None
1030
+ utilization = None
1031
+ if sample.get('cpu') and previous.get('cpu'):
1032
+ busy, total = (_rate(sample['cpu'][i], previous['cpu'][i]) for i in (0, 1))
1033
+ if busy is not None and total and busy <= total:
1034
+ utilization = round(busy / total, 2)
1035
+ total, available = sample.get('mem_total'), sample.get('mem_avail')
1036
+ used = total - available if total and available is not None and available <= total else None
1037
+ rows = []
1038
+ for name, now in (sample.get('containers') or {}).items():
1039
+ before = (previous.get('containers') or {}).get(name) or {}
1040
+ spent = _rate(now.get('usec'), before.get('usec')) if before.get('pid') == now.get('pid') else None
1041
+ percent = float(round(100 * spent / 1e6 / seconds / ncpu)) if spent is not None and seconds and seconds > 0 else None
1042
+ rows.append((-now['mem'], name, {'container.name': name, 'container.memory.usage.total': gib_tenths(now['mem']),
1043
+ 'container.cpu.percent': percent}))
1044
+ return {'v': FLEET_VERSION, 'at': int(sample['at']), 'host.name': host_name, 'stale': False,
1045
+ 'system.cpu.utilization': utilization, 'system.cpu.logical.count': ncpu,
1046
+ 'system.cpu.load_average.1m': round(float(health.get('load1') or 0.0), 1),
1047
+ 'system.memory.utilization': round(used / total, 2) if used is not None else None,
1048
+ 'system.memory.usage': {'used': gib_tenths(used), 'total': gib_tenths(total)},
1049
+ 'system.linux.memory.available': gib_tenths(available),
1050
+ 'system.pressure': {'cpu.some.avg10': float(health.get('cpu_some10') or 0),
1051
+ 'memory.some.avg10': float(health.get('mem_some10') or 0),
1052
+ 'memory.full.avg10': float(health.get('mem_full10') or 0),
1053
+ 'io.some.avg10': float(health.get('io_some10') or 0)},
1054
+ 'system.inotify.max_user_watches': sample.get('inotify_max'),
1055
+ 'containers': [row for _mem, _name, row in sorted(rows, key=lambda r: r[:2])[:FLEET_TOP]]}
1056
+
1057
+
938
1058
  def health_line(health, status_at):
939
1059
  """The one line of $STATE/health (sh and python read it without JSON). Key order is part of the format."""
940
1060
  values = dict(health, at=int(health['at']), load1=f"{health['load1']:.2f}", status_at=int(status_at))
@@ -1262,9 +1382,11 @@ def lane_trace(lane_id, ws):
1262
1382
 
1263
1383
  def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
1264
1384
  """The one create recipe that gives a new tmux lane its identity: LANE_ID / LANE_WS for scripts and hooks, the
1265
- lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, THROTTLE_TRACE for the rest).
1266
- `tmux new -A` attaches when the session already exists, so the env lands only on a real create — never through
1267
- `set-environment`, which would leak into unrelated windows. `cmd` is a shell command string, appended as is."""
1385
+ lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, KIMI_CODE_CUSTOM_HEADERS for Kimi
1386
+ Code, which blanks the Anthropic one, LANES_TRACE for Grok Build's `env_http_headers`, THROTTLE_TRACE for the
1387
+ rest) and GROK_HOME (Grok Build's home, never grok-cli's ~/.grok; W12). `tmux new -A` attaches when the session
1388
+ already exists, so the env lands only on a real create — never through `set-environment`, which would leak into
1389
+ unrelated windows. `cmd` is a shell command string, appended as is."""
1268
1390
  trace = lane_trace(lane_id, ws)
1269
1391
  if trace is None:
1270
1392
  raise LanesError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)')
@@ -1277,7 +1399,8 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
1277
1399
  header = shlex.quote(f'x-throttle-trace: {trace}')
1278
1400
  return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
1279
1401
  f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
1280
- f'-e THROTTLE_TRACE={trace} {cmd}')
1402
+ f'-e THROTTLE_TRACE={trace} -e KIMI_CODE_CUSTOM_HEADERS={header} -e LANES_TRACE={trace} '
1403
+ f'-e GROK_HOME={CONTAINER_HOME}/.grok-build {cmd}')
1281
1404
 
1282
1405
 
1283
1406
  # What `lane open` gets when it runs a lane's recorded `create` for a lane that does not exist (plan Task 18; the