@vibelord/lanes 1.0.0 → 1.0.2
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 +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""lanes status daemon: observes lanes, never touches them.
|
|
3
3
|
|
|
4
|
-
Every few seconds: find LIVE tmux servers
|
|
5
|
-
`list-panes -a` per live server that the registry references
|
|
4
|
+
Every few seconds: find LIVE tmux servers in each workspace container's own cgroup (`guard`, plain /proc reads;
|
|
5
|
+
dead sockets are never probed), run one read-only `list-panes -a` per live server that the registry references —
|
|
6
|
+
at most PROBE_WORKERS at once, the whole round cut at PROBE_TIMEOUT + PROBE_GRACE seconds, the servers the last
|
|
7
|
+
round cut started first — and publish ~/.local/state/lanes/status.json
|
|
6
8
|
atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
|
|
7
9
|
future Throttle web harness all read those files.
|
|
8
10
|
|
|
9
|
-
It never INVENTS a state. "No answer" (a tmux,
|
|
11
|
+
It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that timed out or failed on an overloaded
|
|
10
12
|
host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
|
|
11
13
|
and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
|
|
12
14
|
or positively does not exist.
|
|
13
15
|
"""
|
|
16
|
+
import concurrent.futures
|
|
14
17
|
import json
|
|
15
18
|
import os
|
|
19
|
+
import subprocess
|
|
16
20
|
import sys
|
|
17
21
|
import time
|
|
18
22
|
from pathlib import Path
|
|
@@ -20,12 +24,19 @@ from pathlib import Path
|
|
|
20
24
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
25
|
import lanes_lib as L # noqa: E402
|
|
22
26
|
import session_actions as A # noqa: E402
|
|
23
|
-
import
|
|
27
|
+
import agent_state as AS # noqa: E402
|
|
28
|
+
import guard as G # noqa: E402
|
|
24
29
|
|
|
25
30
|
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
26
31
|
SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
|
|
27
32
|
HEARTBEAT = 15 # status.json is rewritten at least this often, so its age proves that statusd is alive
|
|
28
33
|
PROBE_TIMEOUT = 5
|
|
34
|
+
# Plan Task 16: the servers of one cycle are probed concurrently, never more than PROBE_WORKERS tmux clients at once,
|
|
35
|
+
# and the cycle stops waiting PROBE_GRACE seconds after one PROBE_TIMEOUT: run_bounded's post-kill wait is 2 s, so a
|
|
36
|
+
# probe still running then is a child the kernel cannot reap (D state) or a fork that has not even started. Its socket
|
|
37
|
+
# is published as a 'timeout' (keep(): last state, stale) and the others' answers are not held back by it.
|
|
38
|
+
PROBE_WORKERS = 8
|
|
39
|
+
PROBE_GRACE = 2
|
|
29
40
|
CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
|
|
30
41
|
CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
|
|
31
42
|
ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
|
|
@@ -35,7 +46,12 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
|
|
|
35
46
|
'session_id', 'session_created',
|
|
36
47
|
# Alignment contract A4a: the group a session is attached onto (a Throttle Studio browser view is a
|
|
37
48
|
# grouped session), and which pane of which window represents the session's state.
|
|
38
|
-
'session_group', 'pane_active', 'window_active'
|
|
49
|
+
'session_group', 'pane_active', 'window_active',
|
|
50
|
+
# Task 5 (`agent{}`): the window bell, the ids a Claude session file names its pane by
|
|
51
|
+
# (`<session>:@<win>.%<pane>`), and the pane title Codex writes its status into. APPENDED, so every
|
|
52
|
+
# column above keeps its index; the title is the one free-text column and comes LAST, so a tab inside it
|
|
53
|
+
# stays in the title (the row is split at most len(FIELDS) - 1 times) instead of shifting the ids.
|
|
54
|
+
'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
|
|
39
55
|
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
40
56
|
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
41
57
|
|
|
@@ -160,21 +176,40 @@ def board_named_by(name, boards):
|
|
|
160
176
|
return None
|
|
161
177
|
|
|
162
178
|
|
|
163
|
-
def
|
|
164
|
-
"""
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
def live_by_container(containers, errors=None):
|
|
180
|
+
"""{container: socket names (-L) of the tmux servers running in it | None}, read from each container's own cgroup
|
|
181
|
+
(`guard.tmux_servers_by_container`: cgroup.procs + comm + cmdline, no subprocess). It replaced a host-wide
|
|
182
|
+
`ps -eo comm=,args=` per cycle — one fork that read every process of a 64-core host (plan Task 16).
|
|
183
|
+
|
|
184
|
+
This is statusd's own reading, PER CONTAINER: a container whose recorded init pid cannot be walked is None — only
|
|
185
|
+
ITS sockets are "nobody knows" (every socket file of that container is probed, none is concluded gone) — and the
|
|
186
|
+
other containers keep their real live sets (Task 16 review: one unwalkable pid used to collapse the WHOLE fleet's
|
|
187
|
+
reading to None). `errors` receives {container: why} for each None. A container without an init pid is absent
|
|
188
|
+
(it runs nothing; its lanes are 'missing' / kept by `probe_server`)."""
|
|
189
|
+
found = G.tmux_servers_by_container(G.Env(), containers or {}, errors)
|
|
190
|
+
return {name: (None if servers is None else set(servers)) for name, servers in found.items()}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def live_tmux_sockets(containers):
|
|
194
|
+
"""All live socket names of these containers as ONE set, or None when any container that has an init pid could
|
|
195
|
+
not be walked. All-or-nothing on purpose: `lanes invariant` (bin/lanes `all_panes`) must never save or compare a
|
|
196
|
+
PARTIAL pane list. statusd itself reads `live_by_container`, where one unwalkable container costs only its own."""
|
|
197
|
+
found = live_by_container(containers)
|
|
198
|
+
if any(servers is None for servers in found.values()):
|
|
168
199
|
return None
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
200
|
+
return {socket for servers in found.values() for socket in servers}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def live_in(live, container):
|
|
204
|
+
"""The live socket names of ONE container from statusd's `live`, or None = nobody knows (probe its socket files,
|
|
205
|
+
conclude nothing gone). `live` is `live_by_container`'s {container: set | None} (a container missing from it is
|
|
206
|
+
unknown, never empty), None (no reading at all), or a flat set, which applies to every container — the shape
|
|
207
|
+
`live_tmux_sockets` returns and the unit fixtures and `tests/cursor_lane.py` pass."""
|
|
208
|
+
if live is None:
|
|
209
|
+
return None
|
|
210
|
+
if isinstance(live, dict):
|
|
211
|
+
return live.get(container)
|
|
212
|
+
return live
|
|
178
213
|
|
|
179
214
|
|
|
180
215
|
def keep(previous, lane_id, outcome):
|
|
@@ -191,15 +226,18 @@ def keep(previous, lane_id, outcome):
|
|
|
191
226
|
# 'active' is the one state that is a claim about NOW (output in the last 20 s): never true of a cached entry,
|
|
192
227
|
# and the one that readers which know nothing about 'stale' count and draw green. 'idle' is the seventh word
|
|
193
228
|
# 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.
|
|
229
|
+
# across the upgrade, so it is normalised here rather than drawn as an unknown glyph by the sidebar. (Plan
|
|
230
|
+
# Task 19 listed this line as dead weight; it stays because test_alignment's vocabulary gate pins it — removing
|
|
231
|
+
# it would mean weakening that assert.)
|
|
195
232
|
state = 'quiet' if old['state'] in ('active', 'idle') else old['state']
|
|
196
233
|
return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
|
|
197
234
|
|
|
198
235
|
|
|
199
|
-
def lane_entry(pane, now, activity=None, viewing=()):
|
|
236
|
+
def lane_entry(pane, now, activity=None, viewing=(), agent=None):
|
|
200
237
|
"""One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
|
|
201
238
|
(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.
|
|
239
|
+
default to "this pane alone", which is what a caller with a single row has. `agent` is `agent_state.merge`'s
|
|
240
|
+
verdict for the session's pane: published as the additive `agent` key, absent when no signal names an agent."""
|
|
203
241
|
if activity is None:
|
|
204
242
|
try:
|
|
205
243
|
activity = int(pane['window_activity'])
|
|
@@ -224,16 +262,110 @@ def lane_entry(pane, now, activity=None, viewing=()):
|
|
|
224
262
|
'tty': pane['pane_tty']}
|
|
225
263
|
if viewing: # skipped when 0, so a lane nobody views publishes byte-identically to before A4a
|
|
226
264
|
entry['viewers'] = len(viewing)
|
|
265
|
+
if agent: # absent = no agent: a plain shell's row is byte-identical to before Task 5
|
|
266
|
+
entry['agent'] = agent
|
|
227
267
|
return entry
|
|
228
268
|
|
|
229
269
|
|
|
270
|
+
def agent_signals(containers, sessions_by_server):
|
|
271
|
+
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
272
|
+
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
273
|
+
namespace and nothing is spawned. -> {container: claude_by_pane}.
|
|
274
|
+
|
|
275
|
+
Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
|
|
276
|
+
(`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
|
|
277
|
+
child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
|
|
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-1476). A
|
|
280
|
+
container whose files cannot be read costs only its own agents, never a lane its `state`."""
|
|
281
|
+
claude = {}
|
|
282
|
+
for container in sorted({container for container, _socket in sessions_by_server}):
|
|
283
|
+
info = containers.get(container) or {}
|
|
284
|
+
if not info.get('pid'):
|
|
285
|
+
continue
|
|
286
|
+
root = Path(L.proc_root()) / str(info['pid']) / 'root'
|
|
287
|
+
try:
|
|
288
|
+
claude[container] = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions',
|
|
289
|
+
root / 'proc')
|
|
290
|
+
except Exception as error:
|
|
291
|
+
log(f'agents {container}', error)
|
|
292
|
+
claude[container] = {}
|
|
293
|
+
probe = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
|
|
294
|
+
for pane in sessions.values()
|
|
295
|
+
if str(pane.get('pane_pid') or '').isdigit() and AS.parse_codex_title(pane.get('pane_title'))
|
|
296
|
+
and AS._shell_foreground(pane)
|
|
297
|
+
and f"{pane.get('session_name')}:{pane.get('window_id')}.{pane.get('pane_id')}" not in claude[container]]
|
|
298
|
+
if not probe:
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
live = AS.child_liveness(root / 'proc', [pane['pane_pid'] for pane in probe])
|
|
302
|
+
except Exception as error:
|
|
303
|
+
log(f'liveness {container}', error)
|
|
304
|
+
continue
|
|
305
|
+
for pane in probe:
|
|
306
|
+
known = live.get(int(pane['pane_pid']))
|
|
307
|
+
if known is not None:
|
|
308
|
+
pane['pane_has_child'] = known
|
|
309
|
+
return claude
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
|
|
313
|
+
# The hook sidecars' own directory under the container home. NOT `.lz/state` itself: lane-run writes each zmx lane's
|
|
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-1436).
|
|
316
|
+
SIDECAR_DIR = ('.lz', 'state', 'hooks')
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def sidecar_dir(info):
|
|
320
|
+
return Path(L.proc_root()) / str(info['pid']) / 'root' / L.CONTAINER_HOME.lstrip('/') / Path(*SIDECAR_DIR)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def sidecar_for(info, lane_id, now=None):
|
|
324
|
+
"""The `lane-signal` hook sidecar of one lane (Task 8): `~/.lz/state/hooks/<lane id>.json` in the container, read
|
|
325
|
+
through `/proc/<container init pid>/root` like the Claude session files, judged by `agent_state.read_sidecar_dict`
|
|
326
|
+
in the container's pid namespace (a dead or recycled agent pid, or a last event older than SIDECAR_MAX_AGE, is
|
|
327
|
+
None)."""
|
|
328
|
+
if not info.get('pid'):
|
|
329
|
+
return None
|
|
330
|
+
root = Path(L.proc_root()) / str(info['pid']) / 'root'
|
|
331
|
+
return AS.read_sidecar_dict(L.read_json(sidecar_dir(info) / f'{lane_id}.json'),
|
|
332
|
+
time.time() if now is None else now, SIDECAR_MAX_AGE, proc_root=root / 'proc')
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def agent_sidecars(containers, wanted, now=None):
|
|
336
|
+
"""{container: {lane id: sidecar}} for the containers in `wanted`: ONE listing of each container's
|
|
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
|
+
directory are never listed or read here. A container whose directory cannot be listed simply has no sidecars —
|
|
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-1437)."""
|
|
341
|
+
out = {}
|
|
342
|
+
for container in sorted(wanted):
|
|
343
|
+
info = containers.get(container) or {}
|
|
344
|
+
if not info.get('pid'):
|
|
345
|
+
continue
|
|
346
|
+
state = sidecar_dir(info)
|
|
347
|
+
try:
|
|
348
|
+
names = os.listdir(state)
|
|
349
|
+
except OSError:
|
|
350
|
+
continue
|
|
351
|
+
found = {}
|
|
352
|
+
for name in names:
|
|
353
|
+
if name.endswith('.json') and not name.startswith('.'):
|
|
354
|
+
side = sidecar_for(info, name[:-5], now)
|
|
355
|
+
if side:
|
|
356
|
+
found[name[:-5]] = side
|
|
357
|
+
out[container] = found
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
230
361
|
def probe_server(info, socket, live):
|
|
362
|
+
"""`live` = the live socket names of THIS server's container (`live_in`), or None when nobody knows them."""
|
|
231
363
|
if not info.get('pid'):
|
|
232
364
|
# Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
|
|
233
365
|
# carries an error (docker did not answer) = nobody knows.
|
|
234
366
|
return ('noserver' if info and not info.get('error') else 'error'), ''
|
|
235
367
|
if live is not None and socket not in live:
|
|
236
|
-
return 'noserver', '' #
|
|
368
|
+
return 'noserver', '' # the cgroups were read and run no such server: a stale socket file is never probed
|
|
237
369
|
if (live is not None and info.get('running', True) and os.path.exists(f"{L.proc_root()}/{info['pid']}")
|
|
238
370
|
and not os.path.exists(L.tmux_socket(info, socket))):
|
|
239
371
|
# (container init alive: a container that went DOWN takes its sockets along and is 'missing', not this)
|
|
@@ -257,17 +389,78 @@ def discovered_sockets(containers, live):
|
|
|
257
389
|
if not info.get('pid'):
|
|
258
390
|
continue
|
|
259
391
|
directory = Path(L.tmux_socket(info, 'discovery')).parent
|
|
392
|
+
known = live_in(live, container)
|
|
260
393
|
try:
|
|
261
394
|
for path in sorted(directory.iterdir()):
|
|
262
|
-
if L.IDENT.fullmatch(path.name) and path.is_socket() and (
|
|
395
|
+
if L.IDENT.fullmatch(path.name) and path.is_socket() and (known is None or path.name in known):
|
|
263
396
|
found.append((container, path.name))
|
|
264
397
|
except OSError:
|
|
265
398
|
pass
|
|
266
399
|
return found
|
|
267
400
|
|
|
268
401
|
|
|
269
|
-
def
|
|
270
|
-
"""
|
|
402
|
+
def probe_one(container, info, socket, live):
|
|
403
|
+
"""One server's answer -> (outcome, sessions, activities). Runs on the probe pool, one call per socket: one server
|
|
404
|
+
(or one odd row) must not cost the other servers their cycle, so whatever goes wrong in here ends as "no answer
|
|
405
|
+
from THIS server" and never reaches another socket's result."""
|
|
406
|
+
rows_by_session = {}
|
|
407
|
+
try:
|
|
408
|
+
outcome, text = probe_server(info, socket, live_in(live, container))
|
|
409
|
+
for row in text.splitlines() if outcome == 'ok' else []:
|
|
410
|
+
values = dict(zip(FIELDS, row.split('\t', len(FIELDS) - 1)))
|
|
411
|
+
rows_by_session.setdefault(values['session_name'], []).append(values)
|
|
412
|
+
except Exception as error:
|
|
413
|
+
log(f'observe {container}/{socket}', error)
|
|
414
|
+
outcome, rows_by_session = 'error', {}
|
|
415
|
+
# A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
|
|
416
|
+
# readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
|
|
417
|
+
return (outcome, {name: session_row(rows) for name, rows in rows_by_session.items()},
|
|
418
|
+
{name: newest_activity(rows) for name, rows in rows_by_session.items()})
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def probe_all(containers, keys, live, first=()):
|
|
422
|
+
"""{(container, socket): probe_one's answer} for every key, at most PROBE_WORKERS probes at once, plus the keys
|
|
423
|
+
that were cut. A probe still running PROBE_TIMEOUT + PROBE_GRACE seconds after the round began answers 'timeout' —
|
|
424
|
+
keep() then republishes its lanes as last seen — and is abandoned, never waited for: `run_bounded` bounds it on
|
|
425
|
+
its own (PROBE_TIMEOUT + its 2 s post-kill wait), and `pause` lets the next round start only after that.
|
|
426
|
+
A probe that never started by then is cancelled. Both read at call time, so a test can shorten PROBE_TIMEOUT.
|
|
427
|
+
|
|
428
|
+
The keys in `first` — the ones the previous round's deadline cut — are submitted AHEAD of the others. The deadline
|
|
429
|
+
counts from the start of the round and the pool's queue is FIFO, so with more sockets than PROBE_WORKERS the
|
|
430
|
+
sockets at the end of the (deterministic, registry-ordered) key list start last: on a host where every probe
|
|
431
|
+
answers in, say, 0.4 x PROBE_TIMEOUT, the same tail was cut EVERY cycle and went 'unknown' after STALE_MAX while
|
|
432
|
+
the head stayed live (Task 16 review, MEASURED with 17 sockets at 1.5 s against a 4 s deadline). A socket cut once
|
|
433
|
+
now starts at t=0 next round, so no socket that answers inside PROBE_TIMEOUT is cut two rounds running unless more
|
|
434
|
+
sockets were cut than the pool can start at once."""
|
|
435
|
+
keys = list(keys)
|
|
436
|
+
first = set(first)
|
|
437
|
+
keys = [key for key in keys if key in first] + [key for key in keys if key not in first]
|
|
438
|
+
probed, cut = {}, []
|
|
439
|
+
if not keys:
|
|
440
|
+
return probed, cut
|
|
441
|
+
pool = concurrent.futures.ThreadPoolExecutor(max_workers=PROBE_WORKERS, thread_name_prefix='statusd-probe')
|
|
442
|
+
try:
|
|
443
|
+
futures = {pool.submit(probe_one, container, containers.get(container) or {}, socket, live): (container, socket)
|
|
444
|
+
for container, socket in keys}
|
|
445
|
+
done, _pending = concurrent.futures.wait(futures, timeout=PROBE_TIMEOUT + PROBE_GRACE)
|
|
446
|
+
for future, key in futures.items():
|
|
447
|
+
if future in done:
|
|
448
|
+
probed[key] = future.result() # probe_one never raises
|
|
449
|
+
else:
|
|
450
|
+
cut.append(key)
|
|
451
|
+
probed[key] = ('timeout', {}, {})
|
|
452
|
+
finally:
|
|
453
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
454
|
+
if cut:
|
|
455
|
+
names = ' '.join(f'{container}/{socket}' for container, socket in cut[:8]) + (' …' if len(cut) > 8 else '')
|
|
456
|
+
log('observe', TimeoutError(f'{len(cut)} tmux probe(s) still running at the {PROBE_TIMEOUT + PROBE_GRACE} s '
|
|
457
|
+
f'cycle deadline (first next round): {names}'))
|
|
458
|
+
return probed, cut
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def observe(registry, containers, live, previous=None, report=None):
|
|
462
|
+
"""previous = the last published payload: what a lane keeps when its server gave no answer this time.
|
|
463
|
+
report, when given, receives {'cut': n}: how many probes were still running at the cycle deadline."""
|
|
271
464
|
previous = previous or {}
|
|
272
465
|
now = time.time()
|
|
273
466
|
lanes, servers, unregistered = {}, {}, {}
|
|
@@ -301,23 +494,30 @@ def observe(registry, containers, live, previous=None):
|
|
|
301
494
|
# servers and servers with no registered lane. Never start a server to ask whether it exists.
|
|
302
495
|
for key in discovered_sockets(containers, live):
|
|
303
496
|
wanted.setdefault(key, [])
|
|
497
|
+
# The servers the previous round's deadline cut go first this round (`probe_all`). Read from the published
|
|
498
|
+
# `servers.*.cut` mark, so a statusd that restarts from status.json keeps the order too.
|
|
499
|
+
last_servers = previous.get('servers') or {}
|
|
500
|
+
first = {key for key in wanted if (last_servers.get(f'{key[0]}/{key[1]}') or {}).get('cut')}
|
|
501
|
+
probed, cut = probe_all(containers, wanted, live, first)
|
|
502
|
+
cut = set(cut)
|
|
503
|
+
if report is not None:
|
|
504
|
+
report['cut'] = len(cut)
|
|
505
|
+
# Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
|
|
506
|
+
try:
|
|
507
|
+
claude = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()})
|
|
508
|
+
except Exception as error:
|
|
509
|
+
log('agents', error)
|
|
510
|
+
claude = {}
|
|
511
|
+
# Task 8: the hook sidecars `lane-signal` writes, one directory listing per container per cycle. They outrank the
|
|
512
|
+
# session file and the title in `agent_state.merge`; a container without hooks costs one failed listdir.
|
|
513
|
+
try:
|
|
514
|
+
sidecars = agent_sidecars(containers, {container for container, _socket in probed}, now)
|
|
515
|
+
except Exception as error:
|
|
516
|
+
log('sidecars', error)
|
|
517
|
+
sidecars = {}
|
|
304
518
|
for (container, socket), members in wanted.items():
|
|
305
519
|
info = containers.get(container) or {}
|
|
306
|
-
|
|
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()}
|
|
520
|
+
outcome, sessions, activities = probed[(container, socket)]
|
|
321
521
|
# ... and an UNDECLARED session grouped onto another live session is that session's viewer, not a lane.
|
|
322
522
|
registered = {lane['tmux']['session'] for lane in members}
|
|
323
523
|
own_sessions, viewers = split_viewers(sessions, registered)
|
|
@@ -326,11 +526,16 @@ def observe(registry, containers, live, previous=None):
|
|
|
326
526
|
servers[f'{container}/{socket}'] = {'live': outcome == 'ok' if answered else last.get('live'),
|
|
327
527
|
'sessions': len(own_sessions) if answered else last.get('sessions', 0),
|
|
328
528
|
'outcome': outcome}
|
|
529
|
+
if (container, socket) in cut:
|
|
530
|
+
# Additive key, present only on a server the cycle DEADLINE abandoned (a probe that ran into its own
|
|
531
|
+
# PROBE_TIMEOUT is not marked): the next round starts it first. Every other row is byte-identical.
|
|
532
|
+
servers[f'{container}/{socket}']['cut'] = True
|
|
329
533
|
if not answered:
|
|
330
534
|
# The sessions nobody registered stay visible as well while their server does not answer.
|
|
331
535
|
for workspace_id, entries in (previous.get('unregistered') or {}).items():
|
|
332
536
|
for entry in entries:
|
|
333
|
-
if entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket
|
|
537
|
+
if (entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket
|
|
538
|
+
and not any(A.session_key(entry) == A.session_key(lane) for lane in members)):
|
|
334
539
|
unregistered.setdefault(workspace_id, []).append(entry)
|
|
335
540
|
lanes[entry['id']] = keep(previous, entry['id'], outcome)
|
|
336
541
|
if own_sessions:
|
|
@@ -380,7 +585,9 @@ def observe(registry, containers, live, previous=None):
|
|
|
380
585
|
# RUNBOOK nor the design doc may promise that it does.
|
|
381
586
|
if not L.TMUX_SESSION.match(name):
|
|
382
587
|
entry['note'] = 'session name outside the lane declaration grammar'
|
|
383
|
-
found = lane_entry(pane, now, activities.get(name), viewers.get(name, [])
|
|
588
|
+
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
|
|
589
|
+
AS.merge(pane, claude.get(container, {}),
|
|
590
|
+
sidecars.get(container, {}).get(entry['id'])))
|
|
384
591
|
except Exception as error:
|
|
385
592
|
log(f'observe {container}/{socket}/{name}', error)
|
|
386
593
|
continue
|
|
@@ -398,7 +605,9 @@ def observe(registry, containers, live, previous=None):
|
|
|
398
605
|
lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
|
|
399
606
|
else:
|
|
400
607
|
try:
|
|
401
|
-
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, [])
|
|
608
|
+
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
|
|
609
|
+
AS.merge(pane, claude.get(container, {}),
|
|
610
|
+
sidecars.get(container, {}).get(lane['id'])))
|
|
402
611
|
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
403
612
|
log(f"observe {lane['id']}", error)
|
|
404
613
|
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
@@ -486,7 +695,8 @@ def reconcile_client_sizes(registry, containers, live):
|
|
|
486
695
|
sockets.update(discovered_sockets(containers, live))
|
|
487
696
|
for container, socket in sorted(sockets):
|
|
488
697
|
info = containers.get(container) or {}
|
|
489
|
-
|
|
698
|
+
known = live_in(live, container) # cosmetics only on a server KNOWN to be live: unknown is never touched
|
|
699
|
+
if known is None or socket not in known or not info.get('pid'):
|
|
490
700
|
continue
|
|
491
701
|
output = L.tmux_query(info, socket, ['list-clients', '-F', CLIENT_FORMAT])
|
|
492
702
|
rows = [row.split('|') for row in (output or '').splitlines() if row.count('|') == 4]
|
|
@@ -547,21 +757,28 @@ def observe_zmx_container(container):
|
|
|
547
757
|
name = fields.get('name', '')
|
|
548
758
|
if not L.IDENT.match(name):
|
|
549
759
|
continue
|
|
550
|
-
meta
|
|
551
|
-
|
|
552
|
-
|
|
760
|
+
# The meta and state sidecars are written INSIDE the container (DEF-1461): each one is read as an object or
|
|
761
|
+
# nothing, each field as one printable line or nothing, and the workspace must be a lanes identifier — it
|
|
762
|
+
# names a host file below. A malformed sidecar costs its own row's labels, never the container's other rows.
|
|
763
|
+
# `lane new` writes exactly these shapes (`ws` checked against IDENT or empty, `zmx_version` a string).
|
|
764
|
+
meta, agent = (L.read_json(link / sub / f'{name}.json', {}) for sub in ('meta', 'state'))
|
|
765
|
+
meta, agent = (d if isinstance(d, dict) else {} for d in (meta, agent))
|
|
766
|
+
text = {key: meta[key] if isinstance(meta.get(key), str) and meta[key].isprintable() else ''
|
|
767
|
+
for key in ('ws', 'title', 'zmx_version', 'color', 'icon', 'cwd')}
|
|
768
|
+
workspace = next((w for w in (text['ws'], fields.get('ws') or '') if L.IDENT.match(w)), '-')
|
|
553
769
|
lane_id = f'{workspace}--z-{name}'
|
|
554
|
-
version =
|
|
770
|
+
version = text['zmx_version'] or binaries[-1].name.split('-', 1)[1]
|
|
771
|
+
title = text['title'] or name
|
|
555
772
|
groups.setdefault(workspace, []).append({
|
|
556
|
-
'id': lane_id, 'name':
|
|
557
|
-
'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(
|
|
558
|
-
'icon': L.LANE_ICONS.get(
|
|
773
|
+
'id': lane_id, 'name': title, 'holder': 'zmx', 'container': container,
|
|
774
|
+
'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(text['color']),
|
|
775
|
+
'icon': L.LANE_ICONS.get(text['icon'], '🧵')})
|
|
559
776
|
L.atomic_write(L.STATE / 'resolve.dynamic' / lane_id,
|
|
560
|
-
'\n'.join(['zmx', container, name, version,
|
|
777
|
+
'\n'.join(['zmx', container, name, version, title]) + '\n')
|
|
561
778
|
state, evidence = zmx_state(agent)
|
|
562
779
|
lanes[lane_id] = {'state': state, 'evidence': evidence,
|
|
563
780
|
'clients': int(fields.get('clients', 0) or 0), 'pid': int(fields.get('pid', 0) or 0),
|
|
564
|
-
'agent': agent, 'cwd':
|
|
781
|
+
'agent': agent, 'cwd': text['cwd']}
|
|
565
782
|
return lanes, groups
|
|
566
783
|
|
|
567
784
|
|
|
@@ -591,7 +808,7 @@ def update_containers(ctx, registry, level):
|
|
|
591
808
|
5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
|
|
592
809
|
probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
|
|
593
810
|
already too slow to answer it."""
|
|
594
|
-
names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes']
|
|
811
|
+
names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] if lane.get('container')}
|
|
595
812
|
known = ctx['containers']
|
|
596
813
|
age = time.time() - ctx['containers_at']
|
|
597
814
|
unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
|
|
@@ -608,8 +825,11 @@ def update_containers(ctx, registry, level):
|
|
|
608
825
|
|
|
609
826
|
def build_payload(ctx, level, reconcile=True):
|
|
610
827
|
"""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.
|
|
828
|
+
fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
|
|
829
|
+
ctx['cut'] = how many tmux probes the cycle deadline abandoned (0 when nothing was probed): `pause` reads it."""
|
|
612
830
|
previous = ctx['payload']
|
|
831
|
+
report = {'cut': 0}
|
|
832
|
+
ctx['cut'] = 0
|
|
613
833
|
try:
|
|
614
834
|
stamp = os.stat(L.STATE / 'registry.json').st_mtime_ns
|
|
615
835
|
if stamp != ctx['stamp']:
|
|
@@ -624,17 +844,32 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
624
844
|
except Exception as error:
|
|
625
845
|
log('containers', error)
|
|
626
846
|
containers = ctx['containers']
|
|
847
|
+
unwalkable = {}
|
|
627
848
|
try:
|
|
628
|
-
live =
|
|
849
|
+
live = live_by_container(containers, unwalkable)
|
|
629
850
|
except Exception as error:
|
|
630
|
-
log('
|
|
851
|
+
log('live servers', error)
|
|
631
852
|
live = None
|
|
853
|
+
# Name the container whose init pid broke the cgroup read, once when it starts (not every INTERVAL): only ITS
|
|
854
|
+
# sockets are probed blind, and the operator needs to know which one that is.
|
|
855
|
+
for name in sorted(set(unwalkable) - set(ctx.get('unwalkable') or ())):
|
|
856
|
+
log(f'live servers {name}', L.LanesError(f'{unwalkable[name]} — its sockets are probed as unknown, none concluded gone'))
|
|
857
|
+
ctx['unwalkable'] = sorted(unwalkable)
|
|
632
858
|
try:
|
|
633
|
-
lanes, servers, unregistered = observe(registry, containers, live, previous)
|
|
859
|
+
lanes, servers, unregistered = observe(registry, containers, live, previous, report)
|
|
634
860
|
except Exception as error:
|
|
635
861
|
log('observe', error)
|
|
636
862
|
lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
|
|
637
|
-
|
|
863
|
+
# DEF-1457: COPIES of what was published, never the previous payload itself (`previous` is the very object
|
|
864
|
+
# `ctx['published']` holds, so a kept list extended in place would grow the published payload), and only
|
|
865
|
+
# observe's own rows (holder 'tmux', the rows `observe` carries over too).
|
|
866
|
+
servers = dict(previous.get('servers') or {})
|
|
867
|
+
unregistered = {}
|
|
868
|
+
for workspace, entries in (previous.get('unregistered') or {}).items():
|
|
869
|
+
kept = [entry for entry in entries if entry.get('holder') == 'tmux']
|
|
870
|
+
if kept:
|
|
871
|
+
unregistered[workspace] = kept
|
|
872
|
+
ctx['cut'] = report.get('cut', 0)
|
|
638
873
|
# Half of all our spawns, and only cosmetics (who dictates a window size): not on a host under pressure.
|
|
639
874
|
if reconcile and level == 'ok':
|
|
640
875
|
try:
|
|
@@ -647,10 +882,6 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
647
882
|
log('zmx', error)
|
|
648
883
|
zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
|
|
649
884
|
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
885
|
return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
655
886
|
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
656
887
|
|
|
@@ -664,7 +895,7 @@ def new_context():
|
|
|
664
895
|
for entry in (seed.get('lanes') or {}).values():
|
|
665
896
|
entry.setdefault('stale_since', int(written))
|
|
666
897
|
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
667
|
-
'payload': seed, 'published': None, 'status_at': written}
|
|
898
|
+
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': []}
|
|
668
899
|
|
|
669
900
|
|
|
670
901
|
def cycle(ctx, reconcile=True):
|
|
@@ -709,47 +940,78 @@ def cycle(ctx, reconcile=True):
|
|
|
709
940
|
return level, payload
|
|
710
941
|
|
|
711
942
|
|
|
712
|
-
def pause(level, elapsed):
|
|
713
|
-
|
|
943
|
+
def pause(level, elapsed, cut=0):
|
|
944
|
+
"""Seconds to sleep before the next cycle. After a cycle that abandoned probes at its deadline (`cut`), at least
|
|
945
|
+
PROBE_TIMEOUT + PROBE_GRACE: run_bounded ends each of those within that time, so the next round never runs
|
|
946
|
+
beside them and at most PROBE_WORKERS tmux clients of ours are ever in flight on a host that is already slow."""
|
|
947
|
+
rest = max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
|
|
948
|
+
return max(rest, PROBE_TIMEOUT + PROBE_GRACE) if cut else rest
|
|
949
|
+
|
|
714
950
|
|
|
951
|
+
def registry_source_paths(registry, containers):
|
|
952
|
+
"""(container path, HOST path) for every terminals.json the registry references. The daemon runs on the
|
|
953
|
+
host: a lane's `source` is spelled as the container sees it and only exists under the container's home
|
|
954
|
+
volume (`L.host_path`). A container with no record is skipped — nobody can stat its files anyway.
|
|
715
955
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
seen = set()
|
|
956
|
+
Deduplicated by the HOST path, never by `source` alone: the same spelling in two containers (one repository
|
|
957
|
+
cloned in two workspace boxes) is two distinct files, and each must be stat'ed on its own."""
|
|
958
|
+
seen, out = set(), []
|
|
719
959
|
for w in (registry or {}).get('workspaces', []):
|
|
720
960
|
for lane in w.get('lanes', []):
|
|
721
961
|
src = lane.get('source')
|
|
722
|
-
if
|
|
723
|
-
|
|
724
|
-
|
|
962
|
+
if not src:
|
|
963
|
+
continue
|
|
964
|
+
info = (containers or {}).get(lane.get('container') or '') or {}
|
|
965
|
+
try:
|
|
966
|
+
path = L.host_path(info, src)
|
|
967
|
+
except L.LanesError:
|
|
968
|
+
continue
|
|
969
|
+
if path in seen:
|
|
970
|
+
continue
|
|
971
|
+
seen.add(path)
|
|
972
|
+
out.append((src, path))
|
|
973
|
+
return out
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def hot_sync(ctx, source_mtimes):
|
|
977
|
+
"""One hot-reload check: start ONE `lanes sync` when a referenced terminals.json changed since the last
|
|
978
|
+
check (read through the host volume). Returns True when a sync was started. The new mtime is recorded
|
|
979
|
+
once the sync is started, so one edit costs one sync — not one every rate-limit window forever. Keyed by the
|
|
980
|
+
host path: a `source` spelling is shared by every container that clones the same repository."""
|
|
981
|
+
for _src, path in registry_source_paths(ctx['registry'], ctx.get('containers') or {}):
|
|
982
|
+
key = str(path)
|
|
983
|
+
try:
|
|
984
|
+
mtime = path.stat().st_mtime_ns
|
|
985
|
+
except OSError:
|
|
986
|
+
continue
|
|
987
|
+
if key in source_mtimes and mtime != source_mtimes[key]:
|
|
988
|
+
try:
|
|
989
|
+
proc = subprocess.Popen([str(L.REPO / 'bin' / 'lanes'), 'sync'],
|
|
990
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
991
|
+
except Exception as sync_err:
|
|
992
|
+
log('hot-sync', sync_err)
|
|
993
|
+
return False
|
|
994
|
+
ctx.setdefault('children', []).append(proc)
|
|
995
|
+
source_mtimes[key] = mtime
|
|
996
|
+
return True
|
|
997
|
+
source_mtimes[key] = mtime
|
|
998
|
+
return False
|
|
725
999
|
|
|
726
1000
|
|
|
727
1001
|
def main():
|
|
728
1002
|
ctx = new_context()
|
|
729
1003
|
last_triggered_sync = 0.0
|
|
730
|
-
source_mtimes = {} # path -> mtime_ns at last check
|
|
1004
|
+
source_mtimes = {} # host path of a terminals.json -> its mtime_ns at last check
|
|
731
1005
|
while True:
|
|
732
1006
|
started = time.time()
|
|
733
1007
|
level, _payload = cycle(ctx)
|
|
734
1008
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
735
1009
|
# Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
|
|
736
1010
|
if time.time() - last_triggered_sync > 10 and ctx['registry']:
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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))
|
|
1011
|
+
if hot_sync(ctx, source_mtimes):
|
|
1012
|
+
last_triggered_sync = time.time()
|
|
1013
|
+
ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
|
|
1014
|
+
time.sleep(pause(level, time.time() - started, ctx.get('cut', 0)))
|
|
753
1015
|
|
|
754
1016
|
|
|
755
1017
|
if __name__ == '__main__':
|