@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
|
@@ -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
|
|
878
|
-
"""cgroup-v2
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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+)',
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1266
|
-
|
|
1267
|
-
|
|
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} {
|
|
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
|