@vibelord/lanes 1.0.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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lanes status daemon: observes lanes, never touches them.
|
|
3
|
+
|
|
4
|
+
Every few seconds: find LIVE tmux servers via `ps` (dead sockets are never probed), run one read-only
|
|
5
|
+
`list-panes -a` per live server that the registry references, and publish ~/.local/state/lanes/status.json
|
|
6
|
+
atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
|
|
7
|
+
future Throttle web harness all read those files.
|
|
8
|
+
|
|
9
|
+
It never INVENTS a state. "No answer" (a tmux, ps, docker or zmx call that timed out or failed on an overloaded
|
|
10
|
+
host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
|
|
11
|
+
and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
|
|
12
|
+
or positively does not exist.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
|
+
import lanes_lib as L # noqa: E402
|
|
22
|
+
import session_actions as A # noqa: E402
|
|
23
|
+
import legacy_sessions as Legacy # noqa: E402
|
|
24
|
+
|
|
25
|
+
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
26
|
+
SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
|
|
27
|
+
HEARTBEAT = 15 # status.json is rewritten at least this often, so its age proves that statusd is alive
|
|
28
|
+
PROBE_TIMEOUT = 5
|
|
29
|
+
CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
|
|
30
|
+
CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
|
|
31
|
+
ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
|
|
32
|
+
STALE_MAX = 300 # a lane nobody could observe for this long is published as 'unknown', no longer as last seen
|
|
33
|
+
FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_current_command',
|
|
34
|
+
'pane_current_path', 'window_width', 'window_height', 'session_attached', 'pane_tty',
|
|
35
|
+
'session_id', 'session_created',
|
|
36
|
+
# Alignment contract A4a: the group a session is attached onto (a Throttle Studio browser view is a
|
|
37
|
+
# grouped session), and which pane of which window represents the session's state.
|
|
38
|
+
'session_group', 'pane_active', 'window_active']
|
|
39
|
+
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
40
|
+
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log(where, error):
|
|
44
|
+
print(time.strftime('%FT%T'), where, type(error).__name__, error, flush=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------- the viewer rule (A4a)
|
|
48
|
+
# A Throttle Studio browser attaches with `new-session -t <lane> -s view-<studio>-<ch>`: an independent
|
|
49
|
+
# current window on the lane's windows. Such a session is a CLIENT of the lane, not a lane of its own, and
|
|
50
|
+
# both surfaces must say so — the Host mirrors these three functions in `studio_host::tmux`. Pure, so the
|
|
51
|
+
# rule is unit-testable without a server.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def viewer_of(name, group, leaders):
|
|
55
|
+
"""The lane session that live session `name` VIEWS, or None when it is a session in its own right.
|
|
56
|
+
|
|
57
|
+
A viewer is a tmux session GROUPED onto a group whose LEADER (`leaders`, see `group_leaders`) is a
|
|
58
|
+
DIFFERENT session — declared or not, so a browser on an undeclared Studio-started `st_<board>_<slug>`
|
|
59
|
+
lane is that lane's viewer too. Viewers are counted in the viewed lane's `attached`/`viewers`, never
|
|
60
|
+
listed as unregistered ghosts (the old `web-<ch>` spelling was, which is the defect this rule closes).
|
|
61
|
+
The value returned is the LEADER's session name, which is the group's name while that leader lives and
|
|
62
|
+
the oldest survivor's name once it does not.
|
|
63
|
+
|
|
64
|
+
The `view-` NAME alone is deliberately NOT enough, and these shapes must stay VISIBLE because nothing
|
|
65
|
+
alive may be hidden: an operator's own ungrouped `tmux new -s view-notes`, and the oldest survivor of a
|
|
66
|
+
group whose lane was killed under it, whose panes are still running. Both fall through to the 👻 list."""
|
|
67
|
+
leader = leaders.get(group) if group and group != name else None
|
|
68
|
+
return leader if leader is not None and leader != name else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def group_leaders(sessions):
|
|
72
|
+
"""group name -> the live session that LEADS it, for the viewer rule. Mirrors the Host's
|
|
73
|
+
`inventory::group_leader` exactly, so the two surfaces cannot partition one listing differently.
|
|
74
|
+
|
|
75
|
+
tmux reports a leader's `#{session_group}` as its own name (and a session with no grouped member reports
|
|
76
|
+
no group at all), so the live leader is the first answer. When the named leader is GONE — a lane killed
|
|
77
|
+
under an attached browser, or killed and RECREATED (the recreated session reports NO group while the old
|
|
78
|
+
members still name it) — the group's OLDEST surviving member stands in, ties broken by name so both
|
|
79
|
+
surfaces choose the same one. Without the stand-in a Studio attach onto such a survivor produced a second
|
|
80
|
+
member of one leaderless group that NEITHER member could be seen to view, so the browser's own
|
|
81
|
+
`view-<studio>-<ch>` was drawn here as an openable 👻 while the Host counted it as a viewer (DEF-901;
|
|
82
|
+
the Host half landed in review round 5, this half in round 6, DEF-913)."""
|
|
83
|
+
def created(name):
|
|
84
|
+
try:
|
|
85
|
+
return int(sessions[name].get('session_created') or 0)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
leaders = {}
|
|
90
|
+
for group in {row.get('session_group') for row in sessions.values() if row.get('session_group')}:
|
|
91
|
+
row = sessions.get(group)
|
|
92
|
+
if row is not None and row.get('session_group') == group:
|
|
93
|
+
leaders[group] = group
|
|
94
|
+
continue
|
|
95
|
+
members = [name for name, member in sessions.items() if member.get('session_group') == group]
|
|
96
|
+
if members:
|
|
97
|
+
leaders[group] = min(members, key=lambda name: (created(name), name))
|
|
98
|
+
return leaders
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def split_viewers(sessions, registered=()):
|
|
102
|
+
"""(lanes, viewers): the live sessions that are lanes in their own right, and per viewed lane session the
|
|
103
|
+
list of viewer session rows. `sessions` maps session_name -> its chosen `list-panes` row.
|
|
104
|
+
|
|
105
|
+
A DECLARED session (`registered`) is always a lane of its own, whatever it is grouped onto: an operator
|
|
106
|
+
can bind two declared lanes with tmux's own `new-session -t <other-lane> -s <name>`, and filing the second
|
|
107
|
+
one as a viewer published the registry's own row `absent` while its session was alive and attached — where
|
|
108
|
+
the Host's declared branch (`inventory::derive_status`) reads that session's panes and says `active`.
|
|
109
|
+
Only UNDECLARED grouped members are viewers (DEF-914)."""
|
|
110
|
+
leaders = group_leaders(sessions)
|
|
111
|
+
lanes, viewers = {}, {}
|
|
112
|
+
for name, row in sessions.items():
|
|
113
|
+
viewed = None if name in registered else viewer_of(name, row.get('session_group', ''), leaders)
|
|
114
|
+
if viewed is None:
|
|
115
|
+
lanes[name] = row
|
|
116
|
+
else:
|
|
117
|
+
viewers.setdefault(viewed, []).append(row)
|
|
118
|
+
return lanes, viewers
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def session_row(rows):
|
|
122
|
+
"""The one `list-panes -a` row that represents a session: the ACTIVE pane of its ACTIVE window, else the
|
|
123
|
+
first listed row. The Host reads the same pane (`inventory::dead_active_pane`), so `dead` cannot mean one
|
|
124
|
+
thing in the sidebar and another in the Studio for a multi-window lane."""
|
|
125
|
+
for row in rows:
|
|
126
|
+
if row.get('window_active') == '1' and row.get('pane_active') == '1':
|
|
127
|
+
return row
|
|
128
|
+
return rows[0]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def newest_activity(rows):
|
|
132
|
+
"""The newest `window_activity` across a session's panes — the same reading as the Host's
|
|
133
|
+
`inventory::newest_window_activity`. An agent working in window 1 keeps the lane `active` in BOTH
|
|
134
|
+
surfaces while window 0 idles."""
|
|
135
|
+
newest = 0
|
|
136
|
+
for row in rows:
|
|
137
|
+
try:
|
|
138
|
+
newest = max(newest, int(row['window_activity']))
|
|
139
|
+
except (KeyError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
return newest
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tmux_token(board):
|
|
145
|
+
"""A board id as the Throttle Studio Host spells it inside a session name (`tmux::tmux_token`): anything
|
|
146
|
+
outside [A-Za-z0-9_-] becomes `_`. Every board id in the operator's registry is already [a-z-], so this
|
|
147
|
+
only matters for an id carrying a `.` or a `:` — where matching the raw id alone would never fire."""
|
|
148
|
+
return ''.join(c if (c.isascii() and (c.isalnum() or c in '_-')) else '_' for c in board)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def board_named_by(name, boards):
|
|
152
|
+
"""The board an UNDECLARED session NAMES, or None. A Studio-started lane is `st_<board>_<slug>` and a
|
|
153
|
+
Studio browser view is `view-<board>-<ch>` (contract A2/A5), both in the Host's own spelling
|
|
154
|
+
(`tmux_token`). Used only as the PREFERENCE handed to `session_actions.workspace_for`, which still
|
|
155
|
+
decides; a session that names no board is attributed by its working directory as before."""
|
|
156
|
+
for board in sorted(boards):
|
|
157
|
+
for spelling in {board, tmux_token(board)}:
|
|
158
|
+
if name.startswith(f'st_{spelling}_') or name.startswith(f'{VIEW_PREFIX}{spelling}-'):
|
|
159
|
+
return board
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def live_tmux_sockets():
|
|
164
|
+
"""Socket names (-L) of running tmux servers, from the process table only.
|
|
165
|
+
None = ps did not answer: the caller must then NOT conclude that any server is gone."""
|
|
166
|
+
rc, out, _err = L.run_bounded(['ps', '-eo', 'comm=,args='], 10)
|
|
167
|
+
if rc != 0:
|
|
168
|
+
return None
|
|
169
|
+
names = set()
|
|
170
|
+
for line in out.splitlines():
|
|
171
|
+
if not line.startswith('tmux: server'):
|
|
172
|
+
continue
|
|
173
|
+
parts = line.split()
|
|
174
|
+
for index, part in enumerate(parts):
|
|
175
|
+
if part == '-L' and index + 1 < len(parts):
|
|
176
|
+
names.add(parts[index + 1])
|
|
177
|
+
return names
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def keep(previous, lane_id, outcome):
|
|
181
|
+
"""What to publish for a lane that could not be observed: its last published entry, marked stale — or
|
|
182
|
+
'unknown' when there is none, or once it has gone unobserved for STALE_MAX. Never 'absent' / 'missing'.
|
|
183
|
+
'stale_since' is set once and then carried along, so a kept entry is identical from cycle to cycle (no
|
|
184
|
+
rewrite of status.json, no sidebar repaint) until the one change to 'unknown'."""
|
|
185
|
+
old = (previous.get('lanes') or {}).get(lane_id) or {}
|
|
186
|
+
now = time.time()
|
|
187
|
+
since = old.get('stale_since')
|
|
188
|
+
since = since if isinstance(since, (int, float)) else int(now)
|
|
189
|
+
if old.get('state') in (None, 'unknown') or now - since > STALE_MAX:
|
|
190
|
+
return {'state': 'unknown', 'observed': outcome}
|
|
191
|
+
# 'active' is the one state that is a claim about NOW (output in the last 20 s): never true of a cached entry,
|
|
192
|
+
# and the one that readers which know nothing about 'stale' count and draw green. 'idle' is the seventh word
|
|
193
|
+
# a pre-DEF-823 statusd published for a zmx lane: a status.json written by that build seeds `new_context()`
|
|
194
|
+
# across the upgrade, so it is normalised here rather than drawn as an unknown glyph by the sidebar.
|
|
195
|
+
state = 'quiet' if old['state'] in ('active', 'idle') else old['state']
|
|
196
|
+
return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def lane_entry(pane, now, activity=None, viewing=()):
|
|
200
|
+
"""One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
|
|
201
|
+
(see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
|
|
202
|
+
default to "this pane alone", which is what a caller with a single row has."""
|
|
203
|
+
if activity is None:
|
|
204
|
+
try:
|
|
205
|
+
activity = int(pane['window_activity'])
|
|
206
|
+
except ValueError:
|
|
207
|
+
activity = 0
|
|
208
|
+
if pane['pane_dead'] == '1':
|
|
209
|
+
state = 'dead'
|
|
210
|
+
# INCLUSIVE, on the integer second: `#{window_activity}` is whole seconds, this daemon's clock is a float,
|
|
211
|
+
# and the Host compares integer seconds with `age <= ACTIVE_WINDOW_SECS` (`inventory::from_window_activity`).
|
|
212
|
+
# A strict float `<` made a lane exactly ACTIVE_WINDOW seconds idle `quiet` here and `active` in the Studio
|
|
213
|
+
# (DEF-885, re-lost to the #347 merge and re-landed as DEF-932).
|
|
214
|
+
elif int(now) - activity <= ACTIVE_WINDOW:
|
|
215
|
+
state = 'active'
|
|
216
|
+
else:
|
|
217
|
+
state = 'quiet'
|
|
218
|
+
entry = {'state': state, 'activity': activity, 'command': pane['pane_current_command'],
|
|
219
|
+
'cwd': pane['pane_current_path'], 'pid': int(pane['pane_pid'] or 0),
|
|
220
|
+
'size': f"{pane['window_width']}x{pane['window_height']}",
|
|
221
|
+
# Clients on the session itself plus the clients of every session that views it.
|
|
222
|
+
'attached': int(pane['session_attached'] or 0)
|
|
223
|
+
+ sum(int(row.get('session_attached') or 0) for row in viewing),
|
|
224
|
+
'tty': pane['pane_tty']}
|
|
225
|
+
if viewing: # skipped when 0, so a lane nobody views publishes byte-identically to before A4a
|
|
226
|
+
entry['viewers'] = len(viewing)
|
|
227
|
+
return entry
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def probe_server(info, socket, live):
|
|
231
|
+
if not info.get('pid'):
|
|
232
|
+
# Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
|
|
233
|
+
# carries an error (docker did not answer) = nobody knows.
|
|
234
|
+
return ('noserver' if info and not info.get('error') else 'error'), ''
|
|
235
|
+
if live is not None and socket not in live:
|
|
236
|
+
return 'noserver', '' # ps answered and lists no such server: a stale socket file is never probed
|
|
237
|
+
if (live is not None and info.get('running', True) and os.path.exists(f"{L.proc_root()}/{info['pid']}")
|
|
238
|
+
and not os.path.exists(L.tmux_socket(info, socket))):
|
|
239
|
+
# (container init alive: a container that went DOWN takes its sockets along and is 'missing', not this)
|
|
240
|
+
# The server process runs but its socket file is gone: somebody emptied the container's /tmp (2026-09-20,
|
|
241
|
+
# 04:24: every lane alive, every viewer saying "not running"). Nothing can be asked, nothing died.
|
|
242
|
+
return 'nosocket', ''
|
|
243
|
+
return L.tmux_probe(info, socket, ['list-panes', '-a', '-F', FORMAT], timeout=PROBE_TIMEOUT)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def discovered_sockets(containers, live):
|
|
247
|
+
"""Every LIVE `IDENT`-named tmux socket file in these workspace containers, as (container, socket).
|
|
248
|
+
|
|
249
|
+
The one discovery rule, read by `observe` (which lists their sessions as unregistered rows) and by
|
|
250
|
+
`reconcile_client_sizes` (which owes those sessions the same size policy — a lanes view can show any row
|
|
251
|
+
the sidebar draws, and the sockets a Studio-started lane may land on are by construction ones no
|
|
252
|
+
declaration names). Never starts a server to ask whether one exists: a socket file that is not in `live`
|
|
253
|
+
(statusd's process-table reading) is not probed.
|
|
254
|
+
"""
|
|
255
|
+
found = []
|
|
256
|
+
for container, info in containers.items():
|
|
257
|
+
if not info.get('pid'):
|
|
258
|
+
continue
|
|
259
|
+
directory = Path(L.tmux_socket(info, 'discovery')).parent
|
|
260
|
+
try:
|
|
261
|
+
for path in sorted(directory.iterdir()):
|
|
262
|
+
if L.IDENT.fullmatch(path.name) and path.is_socket() and (live is None or path.name in live):
|
|
263
|
+
found.append((container, path.name))
|
|
264
|
+
except OSError:
|
|
265
|
+
pass
|
|
266
|
+
return found
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def observe(registry, containers, live, previous=None):
|
|
270
|
+
"""previous = the last published payload: what a lane keeps when its server gave no answer this time."""
|
|
271
|
+
previous = previous or {}
|
|
272
|
+
now = time.time()
|
|
273
|
+
lanes, servers, unregistered = {}, {}, {}
|
|
274
|
+
wanted, owners, counts = {}, {}, {}
|
|
275
|
+
for workspace in registry['workspaces']:
|
|
276
|
+
for lane in workspace['lanes']:
|
|
277
|
+
if lane['holder'] == 'tmux':
|
|
278
|
+
key = (lane['container'], lane['tmux']['socket'])
|
|
279
|
+
wanted.setdefault(key, []).append(lane)
|
|
280
|
+
owners.setdefault(key, set()).add(workspace['id'])
|
|
281
|
+
counts.setdefault(key, {})
|
|
282
|
+
counts[key][workspace['id']] = counts[key].get(workspace['id'], 0) + 1
|
|
283
|
+
prior = {}
|
|
284
|
+
for workspace_id, entries in (previous.get('unregistered') or {}).items():
|
|
285
|
+
for entry in entries:
|
|
286
|
+
if entry.get('holder') != 'tmux':
|
|
287
|
+
continue
|
|
288
|
+
key = A.session_key(entry)
|
|
289
|
+
# One session is ONE entry, listed in every owner's sidebar, so the board it was ATTRIBUTED to is
|
|
290
|
+
# the one it carries — not whichever listing this loop reached last.
|
|
291
|
+
#
|
|
292
|
+
# `shared` marks the verdict "no board could claim this one", and it is NOT a preference: the
|
|
293
|
+
# board such an entry carries was minted by the tally rule to own its id, and handing THAT back to
|
|
294
|
+
# `workspace_for` as the preference made it claimable on the next cycle (`preferred in by_id`
|
|
295
|
+
# returns at once) — so the multi-owner listing lasted exactly one INTERVAL and every other
|
|
296
|
+
# owner's sidebar lost the row again, the very drop DEF-915 closed (DEF-976). The verdict is
|
|
297
|
+
# recomputed from scratch each cycle instead; the id stays the one `old` carries.
|
|
298
|
+
prior[key] = (None if entry.get('shared') else (entry.get('workspace') or workspace_id), entry)
|
|
299
|
+
wanted.setdefault(key[:2], [])
|
|
300
|
+
# Discovery covers every live socket in these workspace containers, including shared/default
|
|
301
|
+
# servers and servers with no registered lane. Never start a server to ask whether it exists.
|
|
302
|
+
for key in discovered_sockets(containers, live):
|
|
303
|
+
wanted.setdefault(key, [])
|
|
304
|
+
for (container, socket), members in wanted.items():
|
|
305
|
+
info = containers.get(container) or {}
|
|
306
|
+
rows_by_session = {}
|
|
307
|
+
# One server (or one odd row) must not cost the other servers their cycle: whatever goes wrong in here
|
|
308
|
+
# ends as "no answer from THIS server".
|
|
309
|
+
try:
|
|
310
|
+
outcome, text = probe_server(info, socket, live)
|
|
311
|
+
for row in text.splitlines() if outcome == 'ok' else []:
|
|
312
|
+
values = dict(zip(FIELDS, row.split('\t')))
|
|
313
|
+
rows_by_session.setdefault(values['session_name'], []).append(values)
|
|
314
|
+
except Exception as error:
|
|
315
|
+
log(f'observe {container}/{socket}', error)
|
|
316
|
+
outcome, rows_by_session = 'error', {}
|
|
317
|
+
# A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
|
|
318
|
+
# readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
|
|
319
|
+
sessions = {name: session_row(rows) for name, rows in rows_by_session.items()}
|
|
320
|
+
activities = {name: newest_activity(rows) for name, rows in rows_by_session.items()}
|
|
321
|
+
# ... and an UNDECLARED session grouped onto another live session is that session's viewer, not a lane.
|
|
322
|
+
registered = {lane['tmux']['session'] for lane in members}
|
|
323
|
+
own_sessions, viewers = split_viewers(sessions, registered)
|
|
324
|
+
answered = outcome in ('ok', 'noserver')
|
|
325
|
+
last = (previous.get('servers') or {}).get(f'{container}/{socket}') or {}
|
|
326
|
+
servers[f'{container}/{socket}'] = {'live': outcome == 'ok' if answered else last.get('live'),
|
|
327
|
+
'sessions': len(own_sessions) if answered else last.get('sessions', 0),
|
|
328
|
+
'outcome': outcome}
|
|
329
|
+
if not answered:
|
|
330
|
+
# The sessions nobody registered stay visible as well while their server does not answer.
|
|
331
|
+
for workspace_id, entries in (previous.get('unregistered') or {}).items():
|
|
332
|
+
for entry in entries:
|
|
333
|
+
if entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket:
|
|
334
|
+
unregistered.setdefault(workspace_id, []).append(entry)
|
|
335
|
+
lanes[entry['id']] = keep(previous, entry['id'], outcome)
|
|
336
|
+
if own_sessions:
|
|
337
|
+
# Nothing that is alive may be invisible: list sessions no registry mentions — except a VIEWER of a
|
|
338
|
+
# session on this socket (a grouped attach, Throttle Studio's `view-…` among them), which is that
|
|
339
|
+
# session's client, not a ghost.
|
|
340
|
+
for name in sorted(set(own_sessions) - registered):
|
|
341
|
+
try:
|
|
342
|
+
pane = own_sessions[name]
|
|
343
|
+
old_ws, old = prior.get((container, socket, name), (None, None))
|
|
344
|
+
board_ids = {w['id'] for w in registry['workspaces']}
|
|
345
|
+
workspace_id = A.workspace_for(registry, container, socket, pane['pane_current_path'],
|
|
346
|
+
owners.get((container, socket), set()),
|
|
347
|
+
old_ws or board_named_by(name, board_ids))
|
|
348
|
+
listed = [workspace_id] if workspace_id else []
|
|
349
|
+
if not workspace_id:
|
|
350
|
+
# `workspace_for` proved no single board: several declare lanes on this socket and the
|
|
351
|
+
# session names none of them and sits under no board's roots. Dropping it (main's rule,
|
|
352
|
+
# taken back wholesale by the #347 merge) is the one thing this daemon may not do —
|
|
353
|
+
# "nothing alive is ever hidden" — so it is listed in EVERY owner's sidebar, with its
|
|
354
|
+
# id owned by the board declaring the most lanes on the socket, ties by name. ONE
|
|
355
|
+
# session stays ONE id, ONE resolve file and ONE status row however many boards show
|
|
356
|
+
# it (DEF-884, re-lost to the merge and re-landed as DEF-915).
|
|
357
|
+
share = sorted(owners.get((container, socket)) or ()) or sorted(
|
|
358
|
+
w['id'] for w in registry['workspaces']
|
|
359
|
+
if w.get('container') == container
|
|
360
|
+
or any(x.get('container') == container for x in w.get('lanes', [])))
|
|
361
|
+
if not share:
|
|
362
|
+
log(f'unassigned {container}/{socket}/{name}',
|
|
363
|
+
ValueError(pane['pane_current_path']))
|
|
364
|
+
continue
|
|
365
|
+
tally = counts.get((container, socket), {})
|
|
366
|
+
workspace_id = min(share, key=lambda x: (-tally.get(x, 0), x))
|
|
367
|
+
listed = share
|
|
368
|
+
entry = A.make_entry(workspace_id, container, socket, name, pane, old)
|
|
369
|
+
if len(listed) > 1:
|
|
370
|
+
# The verdict, carried on the entry so the NEXT cycle does not read the tally winner
|
|
371
|
+
# as a board that claimed it (DEF-976). Readers of status.json see one more key on a
|
|
372
|
+
# row they already had; the sidebar draws the row, never this field.
|
|
373
|
+
entry['shared'] = True
|
|
374
|
+
# A name outside the DECLARATION grammar (`(`, `)`, `/`, `,` — the `<name> (drain)` twins
|
|
375
|
+
# among them) is listed like any other ghost, not dropped: the grammar governs what may be
|
|
376
|
+
# DECLARED, never what may be SEEN, and `holders/tmux.sh` opens the raw name with
|
|
377
|
+
# `-t "=$session"` (its own gate admits exactly what tmux itself accepts, DEF-891), and
|
|
378
|
+
# `session_actions.close_session` closes it by session id (DEF-916). The `note` is carried
|
|
379
|
+
# in status.json for readers of the document; no surface draws it today, and neither the
|
|
380
|
+
# RUNBOOK nor the design doc may promise that it does.
|
|
381
|
+
if not L.TMUX_SESSION.match(name):
|
|
382
|
+
entry['note'] = 'session name outside the lane declaration grammar'
|
|
383
|
+
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []))
|
|
384
|
+
except Exception as error:
|
|
385
|
+
log(f'observe {container}/{socket}/{name}', error)
|
|
386
|
+
continue
|
|
387
|
+
for board_id in listed:
|
|
388
|
+
unregistered.setdefault(board_id, []).append(entry)
|
|
389
|
+
lanes[entry['id']] = found
|
|
390
|
+
for lane in members:
|
|
391
|
+
session = lane['tmux']['session']
|
|
392
|
+
pane = own_sessions.get(session)
|
|
393
|
+
if not answered:
|
|
394
|
+
lanes[lane['id']] = keep(previous, lane['id'], outcome)
|
|
395
|
+
elif pane is None:
|
|
396
|
+
# 'absent' = the container is up but this session (or its whole tmux server) does not
|
|
397
|
+
# exist right now, e.g. a completed orchestrator turn; 'missing' = the container is down.
|
|
398
|
+
lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
|
|
399
|
+
else:
|
|
400
|
+
try:
|
|
401
|
+
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []))
|
|
402
|
+
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
403
|
+
log(f"observe {lane['id']}", error)
|
|
404
|
+
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
405
|
+
return lanes, servers, unregistered
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
VIEWER_TERM = 'xterm-lanesview' # TERM alias used by holders/tmux.sh; visible as #{client_termname}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
CLIENT_FORMAT = '#{client_name}|#{session_name}|#{session_group}|#{client_termname}|#{client_flags}'
|
|
412
|
+
|
|
413
|
+
# The Throttle Studio Host's own grouped session name (`studio_host::tmux::WebSession`):
|
|
414
|
+
# `view-<studio>-<ch>`, plus the legacy `web-<ch>` it still recognises. The Host owns those clients' size
|
|
415
|
+
# mode and un-flags them itself, so this daemon only ever ADDS the flag to them. Every other client — the
|
|
416
|
+
# operator's editors, and an operator's own grouped scratch attach — gets the size back here. The test is
|
|
417
|
+
# `lanes_lib.is_host_view`, the MIRROR of `WebSession::channel_of`; the `view-`/`web-` PREFIX this used to
|
|
418
|
+
# read is strictly WIDER than anything the Host mints, so an operator's grouped `view-notes` was flagged
|
|
419
|
+
# here and un-flagged by nobody — the DEF-852 stranding, surviving for a whole class of names (DEF-980).
|
|
420
|
+
is_host_view = L.is_host_view
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def client_size_plan(rows):
|
|
424
|
+
"""The `refresh-client` calls `reconcile_client_sizes` would make, as
|
|
425
|
+
(client_name, lane shown, is_lanes_view, was_ignoring, wanted_flag) — pure, so the rule is unit-testable
|
|
426
|
+
without a tmux server. `rows` are CLIENT_FORMAT lines already split on '|'.
|
|
427
|
+
|
|
428
|
+
Clients are bucketed by the lane they SHOW, not by their own `#{session_name}`: grouped sessions share the
|
|
429
|
+
lane's windows, so a client attached to `view-<studio>-<ch>` drives the lane's size under
|
|
430
|
+
`window-size latest` exactly like a client on the lane itself. Keying on `session_name` put such a client
|
|
431
|
+
in a bucket of its own, where no lanes view is ever present — so a browser `take` attach kept resizing the
|
|
432
|
+
operator's lanes view, and a browser `ignore` attach had the Host's `-f ignore-size` STRIPPED on the next
|
|
433
|
+
tick (MEASURED, 2026-09-20)."""
|
|
434
|
+
names = [row[0] for row in rows]
|
|
435
|
+
by_lane = {}
|
|
436
|
+
for name, session, group, term, flags in rows:
|
|
437
|
+
shows = group if group and group != session else session
|
|
438
|
+
by_lane.setdefault(shows, []).append(
|
|
439
|
+
(name, session, term == VIEWER_TERM, 'ignore-size' in flags.split(',')))
|
|
440
|
+
plan = []
|
|
441
|
+
for shows, clients in sorted(by_lane.items()):
|
|
442
|
+
viewed = any(is_viewer for _n, _s, is_viewer, _f in clients)
|
|
443
|
+
for name, session, is_viewer, ignoring in clients:
|
|
444
|
+
# Host and container pty NAMES can collide; a name that appears twice cannot be targeted safely.
|
|
445
|
+
if is_viewer or names.count(name) > 1 or ignoring == viewed:
|
|
446
|
+
continue
|
|
447
|
+
# A Studio browser client on its OWN grouped `view-<studio>-<ch>` session chose `ignore-size` for
|
|
448
|
+
# itself; only a lanes view may add the flag, and the Host un-flags that client itself
|
|
449
|
+
# (`studio_host::tmux::refresh_client_take_size_argv`) when no lanes view shows the lane. The
|
|
450
|
+
# exemption needs BOTH halves: grouped (an operator's ungrouped `view-notes` is a lane in its own
|
|
451
|
+
# right, whose editors must get the size back) AND a session name the HOST mints — the whole
|
|
452
|
+
# `channel_of` grammar, not the prefix. Keying on grouped alone exempted an operator's own
|
|
453
|
+
# `tmux new-session -t <lane> -s scratch` client — a shape `viewer_of` explicitly admits — which
|
|
454
|
+
# this daemon FLAGS when a lanes view arrives and then never un-flags, because nobody else
|
|
455
|
+
# un-flags a client that is not the Host's (DEF-852); keying on the PREFIX left the identical
|
|
456
|
+
# stranding for every grouped `view-notes`/`web-dev` the Host never minted (DEF-980).
|
|
457
|
+
if not viewed and session != shows and is_host_view(session):
|
|
458
|
+
continue
|
|
459
|
+
plan.append((name, shows, is_viewer, ignoring, 'ignore-size' if viewed else '!ignore-size'))
|
|
460
|
+
return plan
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def reconcile_client_sizes(registry, containers, live):
|
|
464
|
+
"""Size policy "the lanes view wins": while a lanes-view client shows a lane, every other client of that lane
|
|
465
|
+
(Cursor / VS Code terminals, ad-hoc attaches, a Throttle Studio browser) is put into tmux's ignore-size; once
|
|
466
|
+
no viewer shows it, the editors get the size back. Only viewer-client flags are touched — never a lane, a pane
|
|
467
|
+
or an agent. The rule itself is `client_size_plan`."""
|
|
468
|
+
if live is None or (L.STATE / 'flags/coexist').exists():
|
|
469
|
+
return 0
|
|
470
|
+
changed = 0
|
|
471
|
+
# Every socket `observe` reads, not only the ones registered lanes bind. A lanes view can show any row the
|
|
472
|
+
# sidebar draws, and the sidebar draws every DISCOVERED session (a `New +` shell lives on
|
|
473
|
+
# `lanes-shells-<board>`, which no lane declares; a Studio-started lane on a Host-owned
|
|
474
|
+
# `[inventory.board_sockets]` socket is by construction on a socket no source declares) — so the policy
|
|
475
|
+
# "the lanes view wins" covered exactly the lanes the design claimed it covered, minus those (DEF-964).
|
|
476
|
+
# A discovered socket is governed EXACTLY like a registered one, both halves: a lanes view showing one of
|
|
477
|
+
# its lanes flags the other clients, and — once no view shows the lane — any client still carrying
|
|
478
|
+
# `ignore-size` is un-flagged, whoever set the flag. That second half is not free, and DEF-964's "a
|
|
479
|
+
# discovered socket with no viewer costs one `list-clients` and changes nothing" was false for a client
|
|
480
|
+
# an operator had put into `-f ignore-size` by hand on a socket the harness had never touched before
|
|
481
|
+
# (DEF-982). It is kept deliberately: emitting only the flagging half here would re-create DEF-852 on
|
|
482
|
+
# these sockets, where nothing else un-flags. Only client size flags are touched — never a lane, a pane
|
|
483
|
+
# or an agent.
|
|
484
|
+
sockets = {(lane['container'], lane['tmux']['socket']) for w in registry['workspaces'] for lane in w['lanes']
|
|
485
|
+
if lane['holder'] == 'tmux'}
|
|
486
|
+
sockets.update(discovered_sockets(containers, live))
|
|
487
|
+
for container, socket in sorted(sockets):
|
|
488
|
+
info = containers.get(container) or {}
|
|
489
|
+
if socket not in live or not info.get('pid'):
|
|
490
|
+
continue
|
|
491
|
+
output = L.tmux_query(info, socket, ['list-clients', '-F', CLIENT_FORMAT])
|
|
492
|
+
rows = [row.split('|') for row in (output or '').splitlines() if row.count('|') == 4]
|
|
493
|
+
for name, _shows, _is_viewer, _ignoring, want in client_size_plan(rows):
|
|
494
|
+
L.tmux_query(info, socket, ['refresh-client', '-t', name, '-f', want])
|
|
495
|
+
changed += 1
|
|
496
|
+
return changed
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def kept_zmx(previous, outcome, container=None):
|
|
500
|
+
"""The zmx lanes published last time (of one container, or all of them), marked stale."""
|
|
501
|
+
lanes, groups = {}, {}
|
|
502
|
+
for workspace, entries in (previous.get('zmx') or {}).items():
|
|
503
|
+
for entry in entries:
|
|
504
|
+
if container is None or entry.get('container') == container:
|
|
505
|
+
groups.setdefault(workspace, []).append(entry)
|
|
506
|
+
lanes[entry['id']] = keep(previous, entry['id'], outcome)
|
|
507
|
+
return lanes, groups
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def zmx_state(agent):
|
|
511
|
+
"""`(state, evidence)` for a zmx-held lane, from its lane-run agent sidecar.
|
|
512
|
+
|
|
513
|
+
`lane-run` writes `phase: agent` BEFORE it execs the agent command and `phase: shell` the instant that
|
|
514
|
+
command ends (MEASURED against `lanes/container/lane-run` with a scratch LANE_ROOT), so a lane that was
|
|
515
|
+
started through `lane new` ALWAYS has a sidecar while its session lives. A present sidecar is therefore
|
|
516
|
+
`quiet`, whatever its phase — never `active`: no window-activity evidence exists for a zmx lane, and
|
|
517
|
+
`phase: shell` means the agent ended while the lane's login shell runs on, which is the same shape a tmux
|
|
518
|
+
lane publishes `quiet` for.
|
|
519
|
+
|
|
520
|
+
An ABSENT or unreadable sidecar is `unknown`: nothing about the program is known. It used to be `dead`
|
|
521
|
+
with the sentence "the agent command ended", which `lane-run` cannot produce — the shapes that reach it
|
|
522
|
+
are a session made by raw `zmx attach` (never rooted in lane-run) or a deleted/unparseable state file
|
|
523
|
+
(DEF-893). A non-`agent` phase used to publish `idle`, a seventh word this daemon alone minted (DEF-823).
|
|
524
|
+
Pure, so both halves are unit-testable without a zmx daemon."""
|
|
525
|
+
if agent:
|
|
526
|
+
return 'quiet', f'lane-run agent phase {str(agent.get("phase") or "unset")!r}'
|
|
527
|
+
return 'unknown', 'no lane-run agent sidecar: this session was not started through `lane new`'
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def observe_zmx_container(container):
|
|
531
|
+
"""-> (lanes, groups) of one container, or None when `zmx list` did not answer in time. Raises when it failed."""
|
|
532
|
+
lanes, groups = {}, {}
|
|
533
|
+
link = L.Path.home() / '.lz' / container
|
|
534
|
+
binaries = sorted((link / 'bin').glob('zmx-*')) if (link / 's').is_dir() else []
|
|
535
|
+
if not binaries:
|
|
536
|
+
return lanes, groups
|
|
537
|
+
env = {'PATH': '/usr/bin:/bin', 'HOME': str(L.Path.home()), 'ZMX_DIR': str(link / 's'), 'LANG': 'C.UTF-8'}
|
|
538
|
+
rc, out, err = L.run_bounded([str(binaries[-1]), 'list'], 15, env=env)
|
|
539
|
+
if rc is None:
|
|
540
|
+
return None
|
|
541
|
+
if rc != 0:
|
|
542
|
+
# Only rc 0 is an answer. A fork refused under memory pressure (127) or a child taken by the OOM killer
|
|
543
|
+
# (-9) has empty output as well — parsed, that reads "this container has no zmx lanes" and they all vanish.
|
|
544
|
+
raise L.LanesError(f'zmx list: rc {rc} {err.strip()[:200]}')
|
|
545
|
+
for line in out.splitlines():
|
|
546
|
+
fields = {k.strip(' →*>'): v for k, v in (part.split('=', 1) for part in line.split('\t') if '=' in part)}
|
|
547
|
+
name = fields.get('name', '')
|
|
548
|
+
if not L.IDENT.match(name):
|
|
549
|
+
continue
|
|
550
|
+
meta = L.read_json(link / 'meta' / f'{name}.json', {}) or {}
|
|
551
|
+
agent = L.read_json(link / 'state' / f'{name}.json', {}) or {}
|
|
552
|
+
workspace = meta.get('ws') or fields.get('ws') or '-'
|
|
553
|
+
lane_id = f'{workspace}--z-{name}'
|
|
554
|
+
version = str(meta.get('zmx_version') or binaries[-1].name.split('-', 1)[1])
|
|
555
|
+
groups.setdefault(workspace, []).append({
|
|
556
|
+
'id': lane_id, 'name': meta.get('title') or name, 'holder': 'zmx', 'container': container,
|
|
557
|
+
'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(meta.get('color')),
|
|
558
|
+
'icon': L.LANE_ICONS.get(meta.get('icon'), '🧵')})
|
|
559
|
+
L.atomic_write(L.STATE / 'resolve.dynamic' / lane_id,
|
|
560
|
+
'\n'.join(['zmx', container, name, version, meta.get('title') or name]) + '\n')
|
|
561
|
+
state, evidence = zmx_state(agent)
|
|
562
|
+
lanes[lane_id] = {'state': state, 'evidence': evidence,
|
|
563
|
+
'clients': int(fields.get('clients', 0) or 0), 'pid': int(fields.get('pid', 0) or 0),
|
|
564
|
+
'agent': agent, 'cwd': meta.get('cwd', '')}
|
|
565
|
+
return lanes, groups
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def observe_zmx(containers, previous=None):
|
|
569
|
+
"""zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one."""
|
|
570
|
+
lanes, groups = {}, {}
|
|
571
|
+
for container in containers:
|
|
572
|
+
try:
|
|
573
|
+
found = observe_zmx_container(container) or kept_zmx(previous or {}, 'timeout', container)
|
|
574
|
+
except Exception as error:
|
|
575
|
+
log(f'zmx {container}', error)
|
|
576
|
+
found = kept_zmx(previous or {}, 'error', container)
|
|
577
|
+
found_lanes, found_groups = found
|
|
578
|
+
lanes.update(found_lanes)
|
|
579
|
+
for workspace, entries in found_groups.items():
|
|
580
|
+
groups.setdefault(workspace, []).extend(entries)
|
|
581
|
+
return lanes, groups
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def pid_alive(pid):
|
|
585
|
+
return bool(pid) and os.path.exists(f'{L.proc_root()}/{pid}')
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def update_containers(ctx, registry, level):
|
|
589
|
+
"""Container liveness is one stat of /proc/<init-pid> per cycle. docker is asked only for a reason — the
|
|
590
|
+
registry names other containers, a recorded pid vanished, a container is down / was never answered for, or the
|
|
591
|
+
5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
|
|
592
|
+
probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
|
|
593
|
+
already too slow to answer it."""
|
|
594
|
+
names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras']}
|
|
595
|
+
known = ctx['containers']
|
|
596
|
+
age = time.time() - ctx['containers_at']
|
|
597
|
+
unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
|
|
598
|
+
if age > CONTAINER_RETRY and (unsettled or (age > CONTAINER_REFRESH and level == 'ok')):
|
|
599
|
+
try:
|
|
600
|
+
ctx['containers'] = L.refresh_containers(names)
|
|
601
|
+
finally:
|
|
602
|
+
# Stamped AFTER the (possibly slow) docker round so that the pause between two rounds is real, and also
|
|
603
|
+
# when it raised: a failing refresh must not become the next retry storm.
|
|
604
|
+
ctx['containers_at'] = time.time()
|
|
605
|
+
for info in ctx['containers'].values():
|
|
606
|
+
info['running'] = pid_alive(info.get('pid'))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def build_payload(ctx, level, reconcile=True):
|
|
610
|
+
"""One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
|
|
611
|
+
fails alone: what it could not observe is republished as last seen (stale), the other phases still run."""
|
|
612
|
+
previous = ctx['payload']
|
|
613
|
+
try:
|
|
614
|
+
stamp = os.stat(L.STATE / 'registry.json').st_mtime_ns
|
|
615
|
+
if stamp != ctx['stamp']:
|
|
616
|
+
ctx['registry'], ctx['stamp'] = L.load_registry(), stamp
|
|
617
|
+
except Exception as error: # keep observing with the last registry that loaded
|
|
618
|
+
log('registry', error)
|
|
619
|
+
registry = ctx['registry']
|
|
620
|
+
if registry is None:
|
|
621
|
+
return None
|
|
622
|
+
try:
|
|
623
|
+
update_containers(ctx, registry, level)
|
|
624
|
+
except Exception as error:
|
|
625
|
+
log('containers', error)
|
|
626
|
+
containers = ctx['containers']
|
|
627
|
+
try:
|
|
628
|
+
live = live_tmux_sockets()
|
|
629
|
+
except Exception as error:
|
|
630
|
+
log('ps', error)
|
|
631
|
+
live = None
|
|
632
|
+
try:
|
|
633
|
+
lanes, servers, unregistered = observe(registry, containers, live, previous)
|
|
634
|
+
except Exception as error:
|
|
635
|
+
log('observe', error)
|
|
636
|
+
lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
|
|
637
|
+
servers, unregistered = previous.get('servers') or {}, previous.get('unregistered') or {}
|
|
638
|
+
# Half of all our spawns, and only cosmetics (who dictates a window size): not on a host under pressure.
|
|
639
|
+
if reconcile and level == 'ok':
|
|
640
|
+
try:
|
|
641
|
+
reconcile_client_sizes(registry, containers, live)
|
|
642
|
+
except Exception as error:
|
|
643
|
+
log('reconcile', error)
|
|
644
|
+
try:
|
|
645
|
+
zmx_lanes, zmx_groups = observe_zmx(containers, previous)
|
|
646
|
+
except Exception as error:
|
|
647
|
+
log('zmx', error)
|
|
648
|
+
zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
|
|
649
|
+
lanes.update(zmx_lanes)
|
|
650
|
+
legacy_lanes, legacy_groups = Legacy.observe()
|
|
651
|
+
lanes.update(legacy_lanes)
|
|
652
|
+
for workspace, entries in legacy_groups.items():
|
|
653
|
+
unregistered.setdefault(workspace, []).extend(entries)
|
|
654
|
+
return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
655
|
+
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def new_context():
|
|
659
|
+
# Seeded from the file on disk: a statusd that (re)starts on a slow host still knows every lane's last state.
|
|
660
|
+
# The age of that file counts as time without an observation (keep() ages from 'stale_since'): a statusd that
|
|
661
|
+
# was down for a day must not republish yesterday's states as the last known ones.
|
|
662
|
+
seed = L.read_json(L.STATE / 'status.json', {}) or {}
|
|
663
|
+
written = float(seed.get('updated_at') or 0)
|
|
664
|
+
for entry in (seed.get('lanes') or {}).values():
|
|
665
|
+
entry.setdefault('stale_since', int(written))
|
|
666
|
+
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
667
|
+
'payload': seed, 'published': None, 'status_at': written}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def cycle(ctx, reconcile=True):
|
|
671
|
+
"""Observe once and ALWAYS publish -> (health level, payload or None)."""
|
|
672
|
+
started = time.time()
|
|
673
|
+
try:
|
|
674
|
+
health = L.compute_health()
|
|
675
|
+
except Exception as error:
|
|
676
|
+
log('health', error)
|
|
677
|
+
health = {}
|
|
678
|
+
level = health.get('level', 'ok')
|
|
679
|
+
try:
|
|
680
|
+
payload = build_payload(ctx, level, reconcile)
|
|
681
|
+
except Exception as error:
|
|
682
|
+
log('cycle', error)
|
|
683
|
+
payload = None
|
|
684
|
+
if payload is not None:
|
|
685
|
+
ctx['payload'] = payload
|
|
686
|
+
# Rewrite only on change (plus a heartbeat) so sidebars do not repaint for nothing. `health` changes every
|
|
687
|
+
# cycle and is therefore not part of the comparison.
|
|
688
|
+
if payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
|
|
689
|
+
try:
|
|
690
|
+
written = time.time()
|
|
691
|
+
L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written)) + '\n')
|
|
692
|
+
ctx['published'], ctx['status_at'] = payload, written
|
|
693
|
+
except Exception as error:
|
|
694
|
+
log('status.json', error)
|
|
695
|
+
try: # "servers alive, sockets deleted" as a flag file: view.sh, the sidebar and doctor read it without parsing
|
|
696
|
+
lost = sorted(key for key, server in ((payload or {}).get('servers') or {}).items() if server.get('outcome') == 'nosocket')
|
|
697
|
+
flag = L.STATE / 'flags' / 'sockets-lost'
|
|
698
|
+
if lost:
|
|
699
|
+
L.atomic_write(flag, '\n'.join(lost) + '\n')
|
|
700
|
+
elif payload is not None and flag.exists():
|
|
701
|
+
flag.unlink()
|
|
702
|
+
except Exception as error:
|
|
703
|
+
log('sockets-lost flag', error)
|
|
704
|
+
if health: # no health computed = no line written: its age then tells the readers, which is the truth
|
|
705
|
+
try:
|
|
706
|
+
L.atomic_write(L.STATE / 'health', L.health_line(health, ctx['status_at']))
|
|
707
|
+
except Exception as error:
|
|
708
|
+
log('health file', error)
|
|
709
|
+
return level, payload
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def pause(level, elapsed):
|
|
713
|
+
return max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _registry_sources(registry):
|
|
717
|
+
"""All terminals.json paths referenced by the current registry (unique, extant)."""
|
|
718
|
+
seen = set()
|
|
719
|
+
for w in (registry or {}).get('workspaces', []):
|
|
720
|
+
for lane in w.get('lanes', []):
|
|
721
|
+
src = lane.get('source')
|
|
722
|
+
if src and src not in seen:
|
|
723
|
+
seen.add(src)
|
|
724
|
+
yield src
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def main():
|
|
728
|
+
ctx = new_context()
|
|
729
|
+
last_triggered_sync = 0.0
|
|
730
|
+
source_mtimes = {} # path -> mtime_ns at last check
|
|
731
|
+
while True:
|
|
732
|
+
started = time.time()
|
|
733
|
+
level, _payload = cycle(ctx)
|
|
734
|
+
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
735
|
+
# Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
|
|
736
|
+
if time.time() - last_triggered_sync > 10 and ctx['registry']:
|
|
737
|
+
for src in _registry_sources(ctx['registry']):
|
|
738
|
+
try:
|
|
739
|
+
mtime = os.stat(src).st_mtime_ns
|
|
740
|
+
except OSError:
|
|
741
|
+
continue
|
|
742
|
+
if src in source_mtimes and mtime != source_mtimes[src]:
|
|
743
|
+
try:
|
|
744
|
+
import subprocess as _sp
|
|
745
|
+
lanes_bin = str(L.REPO / 'bin' / 'lanes')
|
|
746
|
+
_sp.Popen([lanes_bin, 'sync'], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
|
|
747
|
+
last_triggered_sync = time.time()
|
|
748
|
+
except Exception as sync_err:
|
|
749
|
+
log('hot-sync', sync_err)
|
|
750
|
+
break
|
|
751
|
+
source_mtimes[src] = mtime
|
|
752
|
+
time.sleep(pause(level, time.time() - started))
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
if __name__ == '__main__':
|
|
756
|
+
if len(sys.argv) > 1 and sys.argv[1] == '--once':
|
|
757
|
+
# The same builder as the daemon, minus the one phase that is not an observation (client size flags).
|
|
758
|
+
_level, result = cycle(new_context(), reconcile=False)
|
|
759
|
+
if result is None:
|
|
760
|
+
sys.exit('statusd --once: nothing published (no registry yet? run `lanes sync`)')
|
|
761
|
+
print(json.dumps({'lanes': len(result['lanes']),
|
|
762
|
+
'unregistered': {k: [e['name'] for e in v] for k, v in result['unregistered'].items()}}, indent=1))
|
|
763
|
+
else:
|
|
764
|
+
main()
|