@vibelord/lanes 1.0.2 → 1.0.4
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/README.md +3 -0
- package/package.json +1 -1
- package/payload/lanes/README.md +58 -21
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +21 -8
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/lane +5 -5
- package/payload/lanes/container/lane-signal +4 -4
- package/payload/lanes/docs/ARCHITECTURE.md +16 -8
- package/payload/lanes/docs/DESIGN.md +3 -3
- package/payload/lanes/docs/INSTALL.md +38 -2
- package/payload/lanes/docs/RUNBOOK.md +57 -27
- package/payload/lanes/docs/SHARING.md +239 -4
- package/payload/lanes/docs/USERGUIDE.md +1239 -0
- package/payload/lanes/holders.lock +10 -0
- package/payload/lanes/install/enable-studio-host.sh +14 -0
- package/payload/lanes/install/host-install.sh +5 -2
- package/payload/lanes/install/services.py +101 -4
- package/payload/lanes/install.sh +1 -1
- package/payload/lanes/libexec/agent_state.py +1 -1
- package/payload/lanes/libexec/cursor_sync.py +2 -2
- package/payload/lanes/libexec/economy_poll.py +3 -3
- package/payload/lanes/libexec/lanes_lib.py +1 -1
- package/payload/lanes/libexec/notify.py +3 -3
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +1 -1
- package/payload/lanes/libexec/sidebar.py +7 -7
- package/payload/lanes/libexec/statusd.py +25 -10
- package/payload/lanes/libexec/studio_host.py +1608 -0
- package/payload/lanes/systemd/lanes-economy.service +1 -1
- package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
- package/payload/lanes/systemd/lanes-studio-host.service +37 -0
- package/payload/lanes/web/README.md +2 -2
- package/payload/lanes/zellij/config.kdl +1 -1
- package/payload/manifest.json +4 -3
|
@@ -6,3 +6,13 @@ zmx 0.8.1 dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 https
|
|
|
6
6
|
# glow 3.0.0 (markdown preview, `lanes preview`): tarball digest matches GitHub's asset digest and the release's checksums.txt;
|
|
7
7
|
# the binary sits in glow_3.0.0_Linux_x86_64/. Installed only when no glow is on PATH; without either, the preview uses less -R.
|
|
8
8
|
glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 https://github.com/charmbracelet/glow/releases/download/v3.0.0/glow_3.0.0_Linux_x86_64.tar.gz
|
|
9
|
+
#
|
|
10
|
+
# OPT-IN pins carry three more fields and are skipped by host-install.sh; the step that needs one installs it:
|
|
11
|
+
# name version sha256(tarball) url opt-in member-inside-the-tarball sha256(member)
|
|
12
|
+
# throttle-rc is the co-located Studio Host (docs/SHARING.md "Co-located Studio Host"; `lanes studio-host install`
|
|
13
|
+
# downloads, checks BOTH digests and selects it). It comes from a `node/<sha12>` GitHub prerelease
|
|
14
|
+
# (docs/runbooks/node-release-asset.md): version = node-<sha12>, the asset sidecars give both digests. No
|
|
15
|
+
# node/<sha12> release containing the co-location CLI exists yet, so no line is active: until one is tagged, install
|
|
16
|
+
# a locally built binary with `lanes studio-host install --from PATH [--sha256 HEX]` (also sha256-verified).
|
|
17
|
+
# Template (fill from the release's two .sha256 sidecars, then uncomment):
|
|
18
|
+
# throttle-rc node-<sha12> <tarball-sha256> https://github.com/bwllaming/bw-tools/releases/download/node/<sha12>/throttle-rc-x86_64-unknown-linux-gnu.tar.gz opt-in throttle-rc-x86_64-unknown-linux-gnu/throttle-rc <binary-sha256>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Opt-in: run the Throttle Studio Host co-located on this lanes server (docs/SHARING.md "Co-located Studio Host").
|
|
3
|
+
# As the workspace account. Writes the Host config in lanes-server mode if absent, publishes this machine's PUBLIC
|
|
4
|
+
# key for the deploy, adopts a deploy receipt or enrols with a registration key file, and enables
|
|
5
|
+
# lanes-studio-host.service — or leaves it enabled and WAITING for enrolment (exit 0; exit 3 when a receipt or key
|
|
6
|
+
# was refused, the units still enabled so lanes-studio-host-enrol.timer retries).
|
|
7
|
+
# Never prints a key. --dry-run changes nothing. --help lists the options.
|
|
8
|
+
set -eu
|
|
9
|
+
repo=$(cd "$(dirname "$0")/.." && pwd)
|
|
10
|
+
case "$(uname -s)" in Linux) ;; *) echo 'The co-located Studio Host runs on a Linux lanes server.' >&2; exit 1 ;; esac
|
|
11
|
+
[ "$(id -u)" != 0 ] || { echo 'Run as the workspace account, not root.' >&2; exit 1; }
|
|
12
|
+
command -v python3 >/dev/null 2>&1 || { echo 'Missing prerequisite: python3 (see docs/INSTALL.md)' >&2; exit 1; }
|
|
13
|
+
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
14
|
+
exec python3 "$repo/libexec/studio_host.py" enable "$@"
|
|
@@ -22,7 +22,7 @@ for command in python3 curl tar sha256sum infocmp tic install systemctl docker t
|
|
|
22
22
|
command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
|
|
23
23
|
done
|
|
24
24
|
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else "Python 3.9+ is required")'
|
|
25
|
-
export XDG_RUNTIME_DIR
|
|
25
|
+
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
26
26
|
if [ "$start" = 1 ]; then
|
|
27
27
|
systemctl --user show-environment >/dev/null
|
|
28
28
|
python3 -c 'import subprocess; subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], check=True, timeout=15)'
|
|
@@ -37,8 +37,11 @@ mkdir -p "$share/bin" "$share/dl" "$state/flags"
|
|
|
37
37
|
# The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
|
|
38
38
|
# installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
|
|
39
39
|
pane_path=$share/bin:$repo/bin:/usr/local/bin:/usr/bin:/bin
|
|
40
|
-
grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
|
|
40
|
+
grep -v '^#' "$repo/holders.lock" | while read -r name version sha url flag _rest; do
|
|
41
41
|
[ -n "$name" ] || continue
|
|
42
|
+
# An `opt-in` pin (the co-located Studio Host's throttle-rc) is installed by the opt-in step that needs it
|
|
43
|
+
# (install/enable-studio-host.sh, `lanes studio-host install`), never by every server install.
|
|
44
|
+
[ "$flag" != opt-in ] || continue
|
|
42
45
|
target=$share/bin/$name-$version
|
|
43
46
|
if [ -x "$target" ]; then echo "ok $name $version"; continue; fi
|
|
44
47
|
# The markdown preview's renderer is optional: a glow the pane can find serves `lanes preview` as well.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Register this checkout's user services without starting or restarting them."""
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import subprocess
|
|
5
8
|
from pathlib import Path
|
|
6
9
|
import tempfile
|
|
7
10
|
|
|
@@ -35,8 +38,95 @@ def notify_settings(environ):
|
|
|
35
38
|
return {name: environ[name] for name in NOTIFY_SETTINGS if environ.get(name)}
|
|
36
39
|
|
|
37
40
|
|
|
38
|
-
def
|
|
41
|
+
def throttle_rc_path(share):
|
|
42
|
+
"""The co-located Studio Host's binary: a stable link the opt-in installer points at a sha256-verified file
|
|
43
|
+
(`lanes studio-host install`, libexec/studio_host.py). Rendered whether or not it exists yet, like @CADDY@."""
|
|
44
|
+
return Path(share) / 'bin' / 'throttle-rc'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Units that run throttle-rc (or the installer that resolves its paths): they must see the SAME XDG roots the
|
|
48
|
+
# installer used, or the config and credential it wrote are not where the supervised Host looks (review round 1).
|
|
49
|
+
# Review round 3: BOTH roots are pinned ALWAYS — the installer's own, else the defaults as systemd specifiers — never
|
|
50
|
+
# left to the user manager's environment block, which can carry roots the installing shell did not.
|
|
51
|
+
STUDIO_HOST_UNITS = ('lanes-studio-host.service', 'lanes-studio-host-enrol.service')
|
|
52
|
+
XDG_ROOTS = ('XDG_CONFIG_HOME', 'XDG_DATA_HOME')
|
|
53
|
+
XDG_DEFAULTS = {'XDG_CONFIG_HOME': '%h/.config', 'XDG_DATA_HOME': '%h/.local/share'}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Review round 4: the roots the opt-in enable step (install/enable-studio-host.sh) rendered the studio units with,
|
|
57
|
+
# recorded beside its enrolment record. A later server install (re-run for every update, possibly from a shell that
|
|
58
|
+
# does not carry them, e.g. `sudo -u <account> sh` without -i) renders the studio units from THIS record, never from
|
|
59
|
+
# its own environment, so it cannot move the supervised Host away from the config and credential enable wrote.
|
|
60
|
+
XDG_RECORD = Path('studio-host') / 'xdg-roots.json'
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def recorded_xdg(state):
|
|
64
|
+
"""The roots enable recorded under $LANES_STATE, or None when it never ran (or the record is unreadable)."""
|
|
65
|
+
try:
|
|
66
|
+
doc = json.loads((Path(state) / XDG_RECORD).read_text())
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(doc, dict) or not isinstance(doc.get('roots'), dict):
|
|
70
|
+
return None
|
|
71
|
+
return {name: value for name, value in doc['roots'].items()
|
|
72
|
+
if name in XDG_ROOTS and isinstance(value, str) and value.startswith('/')}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def record_xdg(state, xdg):
|
|
76
|
+
"""Record the roots enable renders the studio units with (an empty dict = the defaults)."""
|
|
77
|
+
path = Path(state) / XDG_RECORD
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
fd, temporary = tempfile.mkstemp(prefix='.xdg-roots-', dir=path.parent)
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, 'w') as handle:
|
|
82
|
+
json.dump({'schema': 'lanes.studio-host-xdg-roots', 'version': 1,
|
|
83
|
+
'roots': {name: str(Path(xdg[name]).resolve()) for name in XDG_ROOTS if xdg.get(name)}},
|
|
84
|
+
handle, sort_keys=True)
|
|
85
|
+
handle.write('\n')
|
|
86
|
+
os.chmod(temporary, 0o600)
|
|
87
|
+
os.replace(temporary, path)
|
|
88
|
+
finally:
|
|
89
|
+
if os.path.exists(temporary):
|
|
90
|
+
os.unlink(temporary)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def studio_host_config(home, xdg):
|
|
94
|
+
"""The Host's config path as libexec/studio_host.py's Layout resolves it: $XDG_CONFIG_HOME, else %h/.config
|
|
95
|
+
(left as the systemd specifier so the unit stays the golden when no XDG root is set)."""
|
|
96
|
+
if xdg.get('XDG_CONFIG_HOME'):
|
|
97
|
+
return unit_value(Path(xdg['XDG_CONFIG_HOME']) / 'throttle-rc/studio-host.toml', command=True)
|
|
98
|
+
return '%h/.config/throttle-rc/studio-host.toml'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def manager_unit_dir(home):
|
|
102
|
+
"""Where the user MANAGER reads unit files (systemd.unit(5)): $XDG_CONFIG_HOME/systemd/user when the manager's own
|
|
103
|
+
environment sets it, else ~/.config/systemd/user. Asked of the manager, never of this shell (review round 2);
|
|
104
|
+
no answer keeps the default. libexec/studio_host.py's manager_units_dir is the same rule."""
|
|
105
|
+
default = Path(home) / '.config/systemd/user'
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(['systemctl', '--user', 'show-environment'], capture_output=True, text=True,
|
|
108
|
+
timeout=10, stdin=subprocess.DEVNULL)
|
|
109
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
110
|
+
return default
|
|
111
|
+
if result.returncode == 0:
|
|
112
|
+
for line in result.stdout.splitlines():
|
|
113
|
+
if line.startswith('XDG_CONFIG_HOME=') and line.split('=', 1)[1].startswith('/'):
|
|
114
|
+
return Path(line.split('=', 1)[1]) / 'systemd/user'
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register(repo, home, share, state, workspaces=None, economy=None, notify=None, xdg=None, units_dir=None):
|
|
39
119
|
repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
|
|
120
|
+
if xdg is None:
|
|
121
|
+
ambient = {name: os.environ[name] for name in XDG_ROOTS if os.environ.get(name)}
|
|
122
|
+
xdg = recorded_xdg(state)
|
|
123
|
+
if xdg is None:
|
|
124
|
+
xdg = ambient
|
|
125
|
+
elif {k: str(Path(v).resolve()) for k, v in ambient.items()} != {k: str(Path(v).resolve()) for k, v in xdg.items()}:
|
|
126
|
+
print(f'services.py: the studio units keep the XDG roots install/enable-studio-host.sh recorded in '
|
|
127
|
+
f'{state / XDG_RECORD} ({xdg or "the defaults"}), not this shell\'s ({ambient or "none"}); re-run '
|
|
128
|
+
'install/enable-studio-host.sh from the shell whose roots should hold', file=sys.stderr)
|
|
129
|
+
xdg = {name: str(Path(value).resolve()) for name, value in xdg.items() if name in XDG_ROOTS and value}
|
|
40
130
|
current = home / '.local/share/lanes/current'
|
|
41
131
|
links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
|
|
42
132
|
for link, _ in links:
|
|
@@ -50,6 +140,9 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
|
|
|
50
140
|
for name, value in sorted((economy or {}).items()) if name in ECONOMY_SETTINGS)
|
|
51
141
|
notify_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
|
|
52
142
|
for name, value in sorted((notify or {}).items()) if name in NOTIFY_SETTINGS)
|
|
143
|
+
studio_environment = environment + ''.join(
|
|
144
|
+
f'Environment="{name}={unit_value(xdg[name]) if name in xdg else XDG_DEFAULTS[name]}"\n'
|
|
145
|
+
for name in XDG_ROOTS)
|
|
53
146
|
rendered = {}
|
|
54
147
|
for source in sorted((repo / 'systemd').iterdir()):
|
|
55
148
|
if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
|
|
@@ -57,12 +150,15 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
|
|
|
57
150
|
content = source.read_text().replace('@REPO@', unit_value(repo, command=True))
|
|
58
151
|
content = content.replace('@SHARE@', unit_value(share, command=True))
|
|
59
152
|
content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
|
|
153
|
+
content = content.replace('@THROTTLE_RC@', unit_value(throttle_rc_path(share), command=True))
|
|
154
|
+
content = content.replace('@STUDIO_HOST_CONFIG@', studio_host_config(home, xdg))
|
|
60
155
|
if '[Service]\n' in content:
|
|
156
|
+
lines = studio_environment if source.name in STUDIO_HOST_UNITS else environment
|
|
61
157
|
extra = (economy_environment if source.name in ECONOMY_UNITS else
|
|
62
158
|
notify_environment if source.name in NOTIFY_UNITS else '')
|
|
63
|
-
content = content.replace('[Service]\n', '[Service]\n' +
|
|
159
|
+
content = content.replace('[Service]\n', '[Service]\n' + lines + extra, 1)
|
|
64
160
|
rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
|
|
65
|
-
units = home / '.config/systemd/user'
|
|
161
|
+
units = Path(units_dir) if units_dir else home / '.config/systemd/user'
|
|
66
162
|
units.mkdir(parents=True, exist_ok=True)
|
|
67
163
|
for name, content in rendered.items():
|
|
68
164
|
fd, temporary = tempfile.mkstemp(prefix='.lanes-', dir=units)
|
|
@@ -85,4 +181,5 @@ if __name__ == '__main__':
|
|
|
85
181
|
register(Path(__file__).resolve().parents[1], Path.home(),
|
|
86
182
|
os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
|
|
87
183
|
os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
|
|
88
|
-
os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ)
|
|
184
|
+
os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ),
|
|
185
|
+
units_dir=manager_unit_dir(Path.home()))
|
package/payload/lanes/install.sh
CHANGED
|
@@ -285,7 +285,7 @@ EOF
|
|
|
285
285
|
# parent's only include) and nothing loads later. Omarchy's theme file sits two deep: one hop.
|
|
286
286
|
# 2. KIND — a theme pair loses to ANY explicit colour key in the everyday chain (above), so the pair is installed only
|
|
287
287
|
# when that chain sets none; otherwise the explicit dark palette (last assignment wins) and `say` names the file.
|
|
288
|
-
# Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-
|
|
288
|
+
# Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-1547 holds the live check).
|
|
289
289
|
# Without the flag the include is absent and every earlier palette file is removed: inherit-first is the default.
|
|
290
290
|
install_palette() {
|
|
291
291
|
rm -f "$config"/ghostty-palette*.conf
|
|
@@ -194,7 +194,7 @@ def read_sidecar_dict(d, now, max_age, proc_root=None):
|
|
|
194
194
|
|
|
195
195
|
Freshness is the LAST event (`at`; `since` when a writer predates `at`), while `since` is when the status began:
|
|
196
196
|
a `working` stretch re-signalled by every tool call keeps its start. lane-run's `{phase, exit, at, pid}` records
|
|
197
|
-
live one directory up and are never a sidecar (no `v`; the two writers never share a file name, DEF-
|
|
197
|
+
live one directory up and are never a sidecar (no `v`; the two writers never share a file name, DEF-1572). With
|
|
198
198
|
`proc_root` (the container's /proc as the host sees it) a recorded `pid` must be live and, when the writer
|
|
199
199
|
recorded `start`, still be the same process (field 22 of /proc/<pid>/stat, as `claude_sessions` checks Claude's
|
|
200
200
|
`procStart`): a crashed agent's last word is never published. `pid` 0 means the writer could not name its agent;
|
|
@@ -21,7 +21,7 @@ NEW lane its identity (LANE_ID, LANE_WS, the lanes/<ws>/<id> trace). `--identity
|
|
|
21
21
|
recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c <dir>] <command…>` the same identity
|
|
22
22
|
(`plain_tmux_new`: the command argv is kept, only the `-e` flags are added); any other working recipe is left
|
|
23
23
|
alone and its lane carries no identity. The old value is kept as `createPrevious` in both cases. Identity reaches a
|
|
24
|
-
tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-
|
|
24
|
+
tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1559, lanes/docs/ARCHITECTURE.md).
|
|
25
25
|
Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
|
|
26
26
|
reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
|
|
27
27
|
"""
|
|
@@ -83,7 +83,7 @@ def needs_canonical(create, folder, exists):
|
|
|
83
83
|
return 'no create recipe'
|
|
84
84
|
recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
|
|
85
85
|
# Every class `lanes doctor` warns about (lanes_lib.classify_recipe: null, attach-only, retired-path) is one this
|
|
86
|
-
# repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-
|
|
86
|
+
# repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-1541).
|
|
87
87
|
reason = CLASS_REASONS.get(L.classify_recipe(recipe))
|
|
88
88
|
if reason:
|
|
89
89
|
return reason
|
|
@@ -28,7 +28,7 @@ The poller (plan Task 11, opt-in `lanes-economy.service`; enabling it is an oper
|
|
|
28
28
|
production daemon's loopback control port): every `--interval` seconds it GETs `/status` and `/sessions` (3 s
|
|
29
29
|
timeout, optional bearer from `LANES_THROTTLE_TOKEN_FILE`), folds today's log through `day_totals`, and writes
|
|
30
30
|
`STATE/economy.json` (schema `v: 1`, additive) only when the payload changed apart from `at` — or when the file's
|
|
31
|
-
`at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-
|
|
31
|
+
`at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-1528); the
|
|
32
32
|
cursor lives in `STATE/economy.cursor.json`. `LANES_THROTTLE_CONTROL=none` disables every daemon request (the log fold still runs);
|
|
33
33
|
`--status-file`/`--sessions-file` feed fixture documents instead. A failed `/status` keeps the previous `fleet` with
|
|
34
34
|
`stale: true`; a failed `/sessions` keeps each lane's previous live figures with `stale: true`. GET requests only:
|
|
@@ -38,7 +38,7 @@ Where it runs: `lanes-economy.service` is a HOST user unit, while the daemon (it
|
|
|
38
38
|
workspace container. `LANES_THROTTLE_CONTAINER=<name>` reads the log through that container's home volume
|
|
39
39
|
(`lanes_lib.host_path` of `/home/coder/.local/share/throttle`, the home recorded in `STATE/containers.json` by
|
|
40
40
|
`lanes sync`); an explicit `LANES_THROTTLE_LOG_DIR` wins. The control URL must be reachable from the HOST's network
|
|
41
|
-
namespace — a daemon bound to the container's 127.0.0.1 is not (DEF-
|
|
41
|
+
namespace — a daemon bound to the container's 127.0.0.1 is not (DEF-1525). Why a source is down (`connection
|
|
42
42
|
refused`, `timed out`, `HTTP 401`, `log dir missing`, ...) goes to stderr — the journal — each time it changes, and
|
|
43
43
|
on every `--once`; it is never written into economy.json.
|
|
44
44
|
"""
|
|
@@ -572,7 +572,7 @@ def poll_once(state_dir, env, opener=None, now=None, write=True, status_file=Non
|
|
|
572
572
|
diagnostics=None):
|
|
573
573
|
"""One poll: the daemon (or fixture files), today's log fold and the lane maps → the economy.json v1 payload.
|
|
574
574
|
With `write`, economy.json is replaced only when the payload changed apart from `at` or its `at` is HEARTBEAT
|
|
575
|
-
seconds old (the liveness heartbeat, DEF-
|
|
575
|
+
seconds old (the liveness heartbeat, DEF-1528), and the cursor only when it changed. Never raises for a down daemon or a missing log; raises LanesError for a malformed control URL. A
|
|
576
576
|
`diagnostics` dict receives why each of status, sessions and log is unavailable (None when it answered)."""
|
|
577
577
|
state_dir = Path(state_dir)
|
|
578
578
|
now = int(time.time() if now is None else now)
|
|
@@ -799,7 +799,7 @@ def classify_recipe(create):
|
|
|
799
799
|
|
|
800
800
|
def recipe_identity(create):
|
|
801
801
|
"""Does the recipe give the lane it creates its identity — an `-e LANE_ID=…` on a tmux new-session (the
|
|
802
|
-
canonical recipe's flags, DEF-
|
|
802
|
+
canonical recipe's flags, DEF-1559)? An env prefix (`LANE_ID=x tmux …`) does not count: it reaches the tmux
|
|
803
803
|
client, never the session."""
|
|
804
804
|
lists = _recipe_word_lists(create) if isinstance(create, str) else None
|
|
805
805
|
return any(verb in _RECIPE_NEW and any(letter == 'e' and value.startswith('LANE_ID=')
|
|
@@ -16,9 +16,9 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
|
|
|
16
16
|
lane) for a DESK device only: one with a `<device>.*.pid` of any age whose `STATE/devices/<device>.json` is not
|
|
17
17
|
`mobile: true`. Phone presence is decided by the device's mobile layout, not by how it attached: a Safari phone
|
|
18
18
|
has no `.pid`, and an SSH-only phone that ran `lanes attach phone --mobile` is still mobile, so opening a lane
|
|
19
|
-
from the phone's picker never mutes pushes (DEF-
|
|
19
|
+
from the phone's picker never mutes pushes (DEF-1533). A phone's own attach within the window still counts.
|
|
20
20
|
Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
|
|
21
|
-
looking now" (DEF-
|
|
21
|
+
looking now" (DEF-1532). A suppressed transition is consumed, never sent later.
|
|
22
22
|
* Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
|
|
23
23
|
(`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
|
|
24
24
|
when `action_url` (a template over `{lane}`) is configured.
|
|
@@ -140,7 +140,7 @@ def device_active(clients_dir, window, now, devices_dir=None):
|
|
|
140
140
|
(the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
|
|
141
141
|
the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
|
|
142
142
|
(`lanes attach phone --mobile`, RUNBOOK) and its pid is never removed — so a lane opened from the phone's picker
|
|
143
|
-
never mutes the phone's own pushes, however it attached (DEF-
|
|
143
|
+
never mutes the phone's own pushes, however it attached (DEF-1533). `devices_dir` defaults to the `devices`
|
|
144
144
|
directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
|
|
145
145
|
never present."""
|
|
146
146
|
if window <= 0:
|
|
@@ -81,7 +81,7 @@ def rename(lane_id, new_name):
|
|
|
81
81
|
"""Display name of one registered session — this file is its only name authority (`apply` takes the lane's
|
|
82
82
|
name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
|
|
83
83
|
resolve files and `lane open <id>` keep working. The `user-sessions/` and `resolve.dynamic/` files an
|
|
84
|
-
UNREGISTERED session is renamed through are never read for a registered one (DEF-
|
|
84
|
+
UNREGISTERED session is renamed through are never read for a registered one (DEF-1556)."""
|
|
85
85
|
name = L._title(new_name)
|
|
86
86
|
with L.registry_lock():
|
|
87
87
|
data = load()
|
|
@@ -179,7 +179,7 @@ def promote_to_registered(workspace_id, entry, registry):
|
|
|
179
179
|
raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
|
|
180
180
|
# A binding any board already holds — a repository declaration or an adopted registered session — is
|
|
181
181
|
# registered. Appending it again would make the repository declaration win the binding on the next sync,
|
|
182
|
-
# re-keying an adopted lane's id and orphaning its view targets (DEF-
|
|
182
|
+
# re-keying an adopted lane's id and orphaning its view targets (DEF-1555). Nothing is written.
|
|
183
183
|
if entry.get('registered_session') or session_key(entry) in {
|
|
184
184
|
session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
|
|
185
185
|
if lane.get('holder') == 'tmux'}:
|
|
@@ -93,7 +93,7 @@ def osc_text(text):
|
|
|
93
93
|
# ----------------------------------------------------------------- economy (plan Task 12, spec §4.2/§4.6(c))
|
|
94
94
|
ECONOMY_SCHEMA = 1 # economy.json `v` this reader understands (Task 11, DM-LANES-ECONOMY; additive)
|
|
95
95
|
STARVING_AGE = 600 # seconds without a request while the pane is producing output
|
|
96
|
-
# economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-
|
|
96
|
+
# economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1528): the poller
|
|
97
97
|
# rewrites the file at least every economy_poll.HEARTBEAT (60 s) even when nothing changed, so three missed heartbeats
|
|
98
98
|
# mean the poller stopped (crashed, disabled, or a host too loaded to run it) — the same own-clock rule as HEALTH_STALE.
|
|
99
99
|
ECONOMY_STALE = 180
|
|
@@ -564,7 +564,7 @@ class Sidebar:
|
|
|
564
564
|
|
|
565
565
|
def economy_aged(self, now):
|
|
566
566
|
"""True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
|
|
567
|
-
the poller stopped, so every figure in it is frozen (DEF-
|
|
567
|
+
the poller stopped, so every figure in it is frozen (DEF-1528). False without a document or without a numeric
|
|
568
568
|
`at` — nothing is known, so nothing is claimed (health_banner's rule for a missing health file)."""
|
|
569
569
|
at = number(self.economy.get('at')) if self.economy else None
|
|
570
570
|
return at is not None and now - at > ECONOMY_STALE
|
|
@@ -682,7 +682,7 @@ class Sidebar:
|
|
|
682
682
|
marks the fleet `stale` (a frozen picture is not live), ink-dim with the stale mark in front."""
|
|
683
683
|
fleet = self.economy.get('fleet') or {}
|
|
684
684
|
# Frozen two ways: the poller kept its last fleet strip (`/status` down), or the poller itself stopped and the
|
|
685
|
-
# whole document is older than ECONOMY_STALE by our clock (DEF-
|
|
685
|
+
# whole document is older than ECONOMY_STALE by our clock (DEF-1528). Both are painted alike.
|
|
686
686
|
stale = fleet.get('stale') is True or self.economy_aged(now)
|
|
687
687
|
spend, budget = number(fleet.get('overflow_spend_today_usd')), number(fleet.get('overflow_budget_usd'))
|
|
688
688
|
fraction = number(fleet.get('spent_fraction'))
|
|
@@ -766,7 +766,7 @@ class Sidebar:
|
|
|
766
766
|
"""(glyph, role) for column 34, by priority (spec §4.2): not-observed beats everything, then what is
|
|
767
767
|
actionable — stale `?` > starving ⚠ > downgraded ↓ > paid `$` > a blank. `econ` is the lane's economy.json
|
|
768
768
|
object from `self.economy` or None (no poller, or a lane the poller has not joined: the tail is blank).
|
|
769
|
-
A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-
|
|
769
|
+
A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1528): the lane record
|
|
770
770
|
carries `stale: true` (the poller kept it while `/sessions` was down), or — with `now` — the whole document
|
|
771
771
|
is older than ECONOMY_STALE. A frozen ⚠ is last-known, not a live alarm; the `?` stays statusd's mark."""
|
|
772
772
|
if observed.get('stale'):
|
|
@@ -906,7 +906,7 @@ class Sidebar:
|
|
|
906
906
|
"""The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
|
|
907
907
|
on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
|
|
908
908
|
that moves between sections never moves the operator's eye. Then the index is clamped. render() and
|
|
909
|
-
render_mobile() bound the shifted `top` by the list (DEF-
|
|
909
|
+
render_mobile() bound the shifted `top` by the list (DEF-1514): a list that fits the body is never scrolled."""
|
|
910
910
|
if cursor_id:
|
|
911
911
|
index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
|
|
912
912
|
if index is not None and index != self.cursor:
|
|
@@ -1107,7 +1107,7 @@ class Sidebar:
|
|
|
1107
1107
|
return
|
|
1108
1108
|
try:
|
|
1109
1109
|
# Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
|
|
1110
|
-
# but its name authority is registered-sessions.json, not user-sessions/ (DEF-
|
|
1110
|
+
# but its name authority is registered-sessions.json, not user-sessions/ (DEF-1556).
|
|
1111
1111
|
if entry.get('registered_session'):
|
|
1112
1112
|
R.rename(entry['id'], new_name)
|
|
1113
1113
|
self.message = f'Renamed → {new_name}'
|
|
@@ -1142,7 +1142,7 @@ class Sidebar:
|
|
|
1142
1142
|
return
|
|
1143
1143
|
# Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
|
|
1144
1144
|
# lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
|
|
1145
|
-
# second declaration of the same binding to the repository's terminals.json (DEF-
|
|
1145
|
+
# second declaration of the same binding to the repository's terminals.json (DEF-1555).
|
|
1146
1146
|
if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
|
|
1147
1147
|
self.message = 'Already a registered lane; nothing to promote'
|
|
1148
1148
|
self.dirty = True
|
|
@@ -276,7 +276,7 @@ def agent_signals(containers, sessions_by_server):
|
|
|
276
276
|
(`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
|
|
277
277
|
child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
|
|
278
278
|
a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
|
|
279
|
-
a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-
|
|
279
|
+
a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1557). A
|
|
280
280
|
container whose files cannot be read costs only its own agents, never a lane its `state`."""
|
|
281
281
|
claude = {}
|
|
282
282
|
for container in sorted({container for container, _socket in sessions_by_server}):
|
|
@@ -312,7 +312,7 @@ def agent_signals(containers, sessions_by_server):
|
|
|
312
312
|
SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
|
|
313
313
|
# The hook sidecars' own directory under the container home. NOT `.lz/state` itself: lane-run writes each zmx lane's
|
|
314
314
|
# `{phase, ...}` record there as `<zmx name>.json` and exports that name as LANE_ID, so a shared name would let a hook
|
|
315
|
-
# overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-
|
|
315
|
+
# overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1572).
|
|
316
316
|
SIDECAR_DIR = ('.lz', 'state', 'hooks')
|
|
317
317
|
|
|
318
318
|
|
|
@@ -337,7 +337,7 @@ def agent_sidecars(containers, wanted, now=None):
|
|
|
337
337
|
~/.lz/state/hooks per cycle, and a read only for the `.json` names in it — lane-run's phase records in the parent
|
|
338
338
|
directory are never listed or read here. A container whose directory cannot be listed simply has no sidecars —
|
|
339
339
|
absent is "no hook installed", never an error a lane pays for. A zmx lane's hook sidecar is named by its zmx
|
|
340
|
-
session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-
|
|
340
|
+
session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1573)."""
|
|
341
341
|
out = {}
|
|
342
342
|
for container in sorted(wanted):
|
|
343
343
|
info = containers.get(container) or {}
|
|
@@ -757,7 +757,7 @@ def observe_zmx_container(container):
|
|
|
757
757
|
name = fields.get('name', '')
|
|
758
758
|
if not L.IDENT.match(name):
|
|
759
759
|
continue
|
|
760
|
-
# The meta and state sidecars are written INSIDE the container (DEF-
|
|
760
|
+
# The meta and state sidecars are written INSIDE the container (DEF-1542): each one is read as an object or
|
|
761
761
|
# nothing, each field as one printable line or nothing, and the workspace must be a lanes identifier — it
|
|
762
762
|
# names a host file below. A malformed sidecar costs its own row's labels, never the container's other rows.
|
|
763
763
|
# `lane new` writes exactly these shapes (`ws` checked against IDENT or empty, `zmx_version` a string).
|
|
@@ -782,12 +782,22 @@ def observe_zmx_container(container):
|
|
|
782
782
|
return lanes, groups
|
|
783
783
|
|
|
784
784
|
|
|
785
|
-
def observe_zmx(containers, previous=None):
|
|
786
|
-
"""zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one.
|
|
785
|
+
def observe_zmx(containers, previous=None, listed=None):
|
|
786
|
+
"""zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one.
|
|
787
|
+
|
|
788
|
+
`listed`, when given, receives `{container: unix time the zmx list was ISSUED}` for every container whose listing
|
|
789
|
+
ANSWERED this cycle (status.json `zmx_listed_at`). A container that timed out or failed is absent from it: its
|
|
790
|
+
groups are the previous ones republished, which say nothing about a lane created since. The co-located Studio
|
|
791
|
+
Host prunes a lane it started only on such a listing (design §8.10 C1, review round 6 — it read absence from any
|
|
792
|
+
fresh status.json and lost stop authority over lanes a timed-out listing had never seen)."""
|
|
787
793
|
lanes, groups = {}, {}
|
|
788
794
|
for container in containers:
|
|
795
|
+
issued = time.time()
|
|
789
796
|
try:
|
|
790
|
-
|
|
797
|
+
answered = observe_zmx_container(container)
|
|
798
|
+
found = answered or kept_zmx(previous or {}, 'timeout', container)
|
|
799
|
+
if answered is not None and listed is not None:
|
|
800
|
+
listed[container] = issued
|
|
791
801
|
except Exception as error:
|
|
792
802
|
log(f'zmx {container}', error)
|
|
793
803
|
found = kept_zmx(previous or {}, 'error', container)
|
|
@@ -860,7 +870,7 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
860
870
|
except Exception as error:
|
|
861
871
|
log('observe', error)
|
|
862
872
|
lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
|
|
863
|
-
# DEF-
|
|
873
|
+
# DEF-1538: COPIES of what was published, never the previous payload itself (`previous` is the very object
|
|
864
874
|
# `ctx['published']` holds, so a kept list extended in place would grow the published payload), and only
|
|
865
875
|
# observe's own rows (holder 'tmux', the rows `observe` carries over too).
|
|
866
876
|
servers = dict(previous.get('servers') or {})
|
|
@@ -876,11 +886,15 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
876
886
|
reconcile_client_sizes(registry, containers, live)
|
|
877
887
|
except Exception as error:
|
|
878
888
|
log('reconcile', error)
|
|
889
|
+
listed = {}
|
|
879
890
|
try:
|
|
880
|
-
zmx_lanes, zmx_groups = observe_zmx(containers, previous)
|
|
891
|
+
zmx_lanes, zmx_groups = observe_zmx(containers, previous, listed)
|
|
881
892
|
except Exception as error:
|
|
882
893
|
log('zmx', error)
|
|
883
894
|
zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
|
|
895
|
+
listed = {}
|
|
896
|
+
# Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
|
|
897
|
+
ctx['zmx_listed_at'] = listed
|
|
884
898
|
lanes.update(zmx_lanes)
|
|
885
899
|
return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
886
900
|
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
@@ -919,7 +933,8 @@ def cycle(ctx, reconcile=True):
|
|
|
919
933
|
if payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
|
|
920
934
|
try:
|
|
921
935
|
written = time.time()
|
|
922
|
-
L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written
|
|
936
|
+
L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written,
|
|
937
|
+
zmx_listed_at=ctx.get('zmx_listed_at') or {})) + '\n')
|
|
923
938
|
ctx['published'], ctx['status_at'] = payload, written
|
|
924
939
|
except Exception as error:
|
|
925
940
|
log('status.json', error)
|