@vibelord/lanes 1.0.2 → 1.0.3

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/VERSION +1 -1
  3. package/payload/lanes/bin/lanes +21 -8
  4. package/payload/lanes/container/env.sh +1 -1
  5. package/payload/lanes/container/lane +5 -5
  6. package/payload/lanes/container/lane-signal +4 -4
  7. package/payload/lanes/docs/ARCHITECTURE.md +14 -7
  8. package/payload/lanes/docs/DESIGN.md +3 -3
  9. package/payload/lanes/docs/INSTALL.md +37 -2
  10. package/payload/lanes/docs/RUNBOOK.md +52 -23
  11. package/payload/lanes/docs/SHARING.md +239 -4
  12. package/payload/lanes/holders.lock +10 -0
  13. package/payload/lanes/install/enable-studio-host.sh +14 -0
  14. package/payload/lanes/install/host-install.sh +5 -2
  15. package/payload/lanes/install/services.py +101 -4
  16. package/payload/lanes/install.sh +1 -1
  17. package/payload/lanes/libexec/agent_state.py +1 -1
  18. package/payload/lanes/libexec/cursor_sync.py +2 -2
  19. package/payload/lanes/libexec/economy_poll.py +3 -3
  20. package/payload/lanes/libexec/lanes_lib.py +1 -1
  21. package/payload/lanes/libexec/notify.py +3 -3
  22. package/payload/lanes/libexec/registrations.py +1 -1
  23. package/payload/lanes/libexec/session_actions.py +1 -1
  24. package/payload/lanes/libexec/sidebar.py +7 -7
  25. package/payload/lanes/libexec/statusd.py +25 -10
  26. package/payload/lanes/libexec/studio_host.py +1608 -0
  27. package/payload/lanes/systemd/lanes-economy.service +1 -1
  28. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  29. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  30. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  31. package/payload/lanes/web/README.md +2 -2
  32. package/payload/lanes/zellij/config.kdl +1 -1
  33. package/payload/manifest.json +4 -3
@@ -93,7 +93,7 @@ def osc_text(text):
93
93
  # ----------------------------------------------------------------- economy (plan Task 12, spec §4.2/§4.6(c))
94
94
  ECONOMY_SCHEMA = 1 # economy.json `v` this reader understands (Task 11, DM-LANES-ECONOMY; additive)
95
95
  STARVING_AGE = 600 # seconds without a request while the pane is producing output
96
- # economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1447): the poller
96
+ # economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1528): the poller
97
97
  # rewrites the file at least every economy_poll.HEARTBEAT (60 s) even when nothing changed, so three missed heartbeats
98
98
  # mean the poller stopped (crashed, disabled, or a host too loaded to run it) — the same own-clock rule as HEALTH_STALE.
99
99
  ECONOMY_STALE = 180
@@ -564,7 +564,7 @@ class Sidebar:
564
564
 
565
565
  def economy_aged(self, now):
566
566
  """True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
567
- the poller stopped, so every figure in it is frozen (DEF-1447). False without a document or without a numeric
567
+ the poller stopped, so every figure in it is frozen (DEF-1528). False without a document or without a numeric
568
568
  `at` — nothing is known, so nothing is claimed (health_banner's rule for a missing health file)."""
569
569
  at = number(self.economy.get('at')) if self.economy else None
570
570
  return at is not None and now - at > ECONOMY_STALE
@@ -682,7 +682,7 @@ class Sidebar:
682
682
  marks the fleet `stale` (a frozen picture is not live), ink-dim with the stale mark in front."""
683
683
  fleet = self.economy.get('fleet') or {}
684
684
  # Frozen two ways: the poller kept its last fleet strip (`/status` down), or the poller itself stopped and the
685
- # whole document is older than ECONOMY_STALE by our clock (DEF-1447). Both are painted alike.
685
+ # whole document is older than ECONOMY_STALE by our clock (DEF-1528). Both are painted alike.
686
686
  stale = fleet.get('stale') is True or self.economy_aged(now)
687
687
  spend, budget = number(fleet.get('overflow_spend_today_usd')), number(fleet.get('overflow_budget_usd'))
688
688
  fraction = number(fleet.get('spent_fraction'))
@@ -766,7 +766,7 @@ class Sidebar:
766
766
  """(glyph, role) for column 34, by priority (spec §4.2): not-observed beats everything, then what is
767
767
  actionable — stale `?` > starving ⚠ > downgraded ↓ > paid `$` > a blank. `econ` is the lane's economy.json
768
768
  object from `self.economy` or None (no poller, or a lane the poller has not joined: the tail is blank).
769
- A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1447): the lane record
769
+ A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1528): the lane record
770
770
  carries `stale: true` (the poller kept it while `/sessions` was down), or — with `now` — the whole document
771
771
  is older than ECONOMY_STALE. A frozen ⚠ is last-known, not a live alarm; the `?` stays statusd's mark."""
772
772
  if observed.get('stale'):
@@ -906,7 +906,7 @@ class Sidebar:
906
906
  """The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
907
907
  on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
908
908
  that moves between sections never moves the operator's eye. Then the index is clamped. render() and
909
- render_mobile() bound the shifted `top` by the list (DEF-1433): a list that fits the body is never scrolled."""
909
+ render_mobile() bound the shifted `top` by the list (DEF-1514): a list that fits the body is never scrolled."""
910
910
  if cursor_id:
911
911
  index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
912
912
  if index is not None and index != self.cursor:
@@ -1107,7 +1107,7 @@ class Sidebar:
1107
1107
  return
1108
1108
  try:
1109
1109
  # Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
1110
- # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1475).
1110
+ # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1556).
1111
1111
  if entry.get('registered_session'):
1112
1112
  R.rename(entry['id'], new_name)
1113
1113
  self.message = f'Renamed → {new_name}'
@@ -1142,7 +1142,7 @@ class Sidebar:
1142
1142
  return
1143
1143
  # Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
1144
1144
  # lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
1145
- # second declaration of the same binding to the repository's terminals.json (DEF-1474).
1145
+ # second declaration of the same binding to the repository's terminals.json (DEF-1555).
1146
1146
  if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
1147
1147
  self.message = 'Already a registered lane; nothing to promote'
1148
1148
  self.dirty = True
@@ -276,7 +276,7 @@ def agent_signals(containers, sessions_by_server):
276
276
  (`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
277
277
  child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
278
278
  a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
279
- a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1476). A
279
+ a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1557). A
280
280
  container whose files cannot be read costs only its own agents, never a lane its `state`."""
281
281
  claude = {}
282
282
  for container in sorted({container for container, _socket in sessions_by_server}):
@@ -312,7 +312,7 @@ def agent_signals(containers, sessions_by_server):
312
312
  SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
313
313
  # The hook sidecars' own directory under the container home. NOT `.lz/state` itself: lane-run writes each zmx lane's
314
314
  # `{phase, ...}` record there as `<zmx name>.json` and exports that name as LANE_ID, so a shared name would let a hook
315
- # overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1436).
315
+ # overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1572).
316
316
  SIDECAR_DIR = ('.lz', 'state', 'hooks')
317
317
 
318
318
 
@@ -337,7 +337,7 @@ def agent_sidecars(containers, wanted, now=None):
337
337
  ~/.lz/state/hooks per cycle, and a read only for the `.json` names in it — lane-run's phase records in the parent
338
338
  directory are never listed or read here. A container whose directory cannot be listed simply has no sidecars —
339
339
  absent is "no hook installed", never an error a lane pays for. A zmx lane's hook sidecar is named by its zmx
340
- session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1437)."""
340
+ session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1573)."""
341
341
  out = {}
342
342
  for container in sorted(wanted):
343
343
  info = containers.get(container) or {}
@@ -757,7 +757,7 @@ def observe_zmx_container(container):
757
757
  name = fields.get('name', '')
758
758
  if not L.IDENT.match(name):
759
759
  continue
760
- # The meta and state sidecars are written INSIDE the container (DEF-1461): each one is read as an object or
760
+ # The meta and state sidecars are written INSIDE the container (DEF-1542): each one is read as an object or
761
761
  # nothing, each field as one printable line or nothing, and the workspace must be a lanes identifier — it
762
762
  # names a host file below. A malformed sidecar costs its own row's labels, never the container's other rows.
763
763
  # `lane new` writes exactly these shapes (`ws` checked against IDENT or empty, `zmx_version` a string).
@@ -782,12 +782,22 @@ def observe_zmx_container(container):
782
782
  return lanes, groups
783
783
 
784
784
 
785
- def observe_zmx(containers, previous=None):
786
- """zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one."""
785
+ def observe_zmx(containers, previous=None, listed=None):
786
+ """zmx-held lanes describe themselves (labels + meta sidecar): no registry edit is needed to see one.
787
+
788
+ `listed`, when given, receives `{container: unix time the zmx list was ISSUED}` for every container whose listing
789
+ ANSWERED this cycle (status.json `zmx_listed_at`). A container that timed out or failed is absent from it: its
790
+ groups are the previous ones republished, which say nothing about a lane created since. The co-located Studio
791
+ Host prunes a lane it started only on such a listing (design §8.10 C1, review round 6 — it read absence from any
792
+ fresh status.json and lost stop authority over lanes a timed-out listing had never seen)."""
787
793
  lanes, groups = {}, {}
788
794
  for container in containers:
795
+ issued = time.time()
789
796
  try:
790
- found = observe_zmx_container(container) or kept_zmx(previous or {}, 'timeout', container)
797
+ answered = observe_zmx_container(container)
798
+ found = answered or kept_zmx(previous or {}, 'timeout', container)
799
+ if answered is not None and listed is not None:
800
+ listed[container] = issued
791
801
  except Exception as error:
792
802
  log(f'zmx {container}', error)
793
803
  found = kept_zmx(previous or {}, 'error', container)
@@ -860,7 +870,7 @@ def build_payload(ctx, level, reconcile=True):
860
870
  except Exception as error:
861
871
  log('observe', error)
862
872
  lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
863
- # DEF-1457: COPIES of what was published, never the previous payload itself (`previous` is the very object
873
+ # DEF-1538: COPIES of what was published, never the previous payload itself (`previous` is the very object
864
874
  # `ctx['published']` holds, so a kept list extended in place would grow the published payload), and only
865
875
  # observe's own rows (holder 'tmux', the rows `observe` carries over too).
866
876
  servers = dict(previous.get('servers') or {})
@@ -876,11 +886,15 @@ def build_payload(ctx, level, reconcile=True):
876
886
  reconcile_client_sizes(registry, containers, live)
877
887
  except Exception as error:
878
888
  log('reconcile', error)
889
+ listed = {}
879
890
  try:
880
- zmx_lanes, zmx_groups = observe_zmx(containers, previous)
891
+ zmx_lanes, zmx_groups = observe_zmx(containers, previous, listed)
881
892
  except Exception as error:
882
893
  log('zmx', error)
883
894
  zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
895
+ listed = {}
896
+ # Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
897
+ ctx['zmx_listed_at'] = listed
884
898
  lanes.update(zmx_lanes)
885
899
  return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
886
900
  'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
@@ -919,7 +933,8 @@ def cycle(ctx, reconcile=True):
919
933
  if payload != ctx['published'] or started - ctx['status_at'] > HEARTBEAT:
920
934
  try:
921
935
  written = time.time()
922
- L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written)) + '\n')
936
+ L.atomic_write(L.STATE / 'status.json', json.dumps(dict(payload, health=health, updated_at=written,
937
+ zmx_listed_at=ctx.get('zmx_listed_at') or {})) + '\n')
923
938
  ctx['published'], ctx['status_at'] = payload, written
924
939
  except Exception as error:
925
940
  log('status.json', error)