@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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -0,0 +1,685 @@
1
+ """Per-lane Throttle economy: the request-outcome log fold (file reads only) and the opt-in `economy.json` poller.
2
+
3
+ This module folds the `request outcome` events the proxy logs for every request into per-trace day totals
4
+ (requests, paid requests, the four token classes, failures, models) and joins them to lanes. Tokens and paid
5
+ request counts are authoritative; dollars are never computed here (no lanes-side pricing table — the poller's
6
+ `list_usd_estimate` comes from the daemon or stays null, plan Global Constraints).
7
+
8
+ Log layout, MEASURED 2026-10-04 (read-only, ~/.local/share/throttle on this host):
9
+ * `proxy.log.<YYYY-MM-DD>` — the daily JSON appender's file, one `{"timestamp", "level", "fields": {...}}` object
10
+ per line. TODAY's file is written live all day; a day's file is complete once the next day's exists. Nine days
11
+ were retained.
12
+ * `proxy.log` — an ANSI-coloured `key=value` text copy of the same events (`<ts> INFO request outcome
13
+ request_id=… account=… trace=… failure=None …`), beginning at the current day's first event.
14
+ Both files carry every outcome, so a day is folded from exactly ONE source: its `proxy.log.<day>` when it exists,
15
+ else `proxy.log` while that file's first line is of that day. Folding both would double count.
16
+
17
+ The cursor is plain JSON (the poller persists it): `{"v": 1, "days": {day: {"src": [dev, ino], "offset": int,
18
+ "totals": {...}, "done": bool}}}`. A source is followed by file identity, not by name, so a file renamed to
19
+ `proxy.log.<day>` continues at its offset; the offset advances only through the last complete line, so a line the
20
+ writer is still flushing is read next time. A day is frozen (`done`) once a later day's log exists — its totals then
21
+ survive the retention prune of its file.
22
+
23
+ Trace keys: `cc-session/<id>` and `codex-session/<id>` (first two segments of the harness trace), `lanes/<ws>/<id>`
24
+ (the lane-stamped header, Task 7) and, for any other trace, its first segment; a request without a trace is
25
+ `UNTRACED`, a key the proxy's trace charset (`[A-Za-z0-9._-/]`) cannot produce.
26
+
27
+ The poller (plan Task 11, opt-in `lanes-economy.service`; enabling it is an operator gate because it reads the
28
+ production daemon's loopback control port): every `--interval` seconds it GETs `/status` and `/sessions` (3 s
29
+ timeout, optional bearer from `LANES_THROTTLE_TOKEN_FILE`), folds today's log through `day_totals`, and writes
30
+ `STATE/economy.json` (schema `v: 1`, additive) only when the payload changed apart from `at` — or when the file's
31
+ `at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-1447); the
32
+ cursor lives in `STATE/economy.cursor.json`. `LANES_THROTTLE_CONTROL=none` disables every daemon request (the log fold still runs);
33
+ `--status-file`/`--sessions-file` feed fixture documents instead. A failed `/status` keeps the previous `fleet` with
34
+ `stale: true`; a failed `/sessions` keeps each lane's previous live figures with `stale: true`. GET requests only:
35
+ nothing here can change the daemon.
36
+
37
+ Where it runs: `lanes-economy.service` is a HOST user unit, while the daemon (its log and its control port) lives in a
38
+ workspace container. `LANES_THROTTLE_CONTAINER=<name>` reads the log through that container's home volume
39
+ (`lanes_lib.host_path` of `/home/coder/.local/share/throttle`, the home recorded in `STATE/containers.json` by
40
+ `lanes sync`); an explicit `LANES_THROTTLE_LOG_DIR` wins. The control URL must be reachable from the HOST's network
41
+ namespace — a daemon bound to the container's 127.0.0.1 is not (DEF-1444). Why a source is down (`connection
42
+ refused`, `timed out`, `HTTP 401`, `log dir missing`, ...) goes to stderr — the journal — each time it changes, and
43
+ on every `--once`; it is never written into economy.json.
44
+ """
45
+ import argparse
46
+ import copy
47
+ import json
48
+ import os
49
+ import re
50
+ import sys
51
+ import time
52
+ import urllib.error
53
+ import urllib.request
54
+ from pathlib import Path
55
+
56
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
57
+ import lanes_lib as L # noqa: E402
58
+
59
+ ANSI = re.compile(r'\x1b\[[0-9;]*m')
60
+ TEXT_HEAD = re.compile(r'^(\d{4}-\d\d-\d\dT\S+)\s+([A-Z]+)\s+(.*)$')
61
+ TEXT_FIELD = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|\S+)')
62
+ OUTCOME = 'request outcome'
63
+ UNTRACED = '(untraced)'
64
+ LIVE = 'proxy.log'
65
+ DAY_FILE = re.compile(r'^proxy\.log\.(\d{4}-\d\d-\d\d)$')
66
+ SESSION_PREFIXES = ('cc-session', 'codex-session')
67
+ INT_FIELDS = ('status', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens')
68
+ TOKENS = (('input', 'input_tokens'), ('output', 'output_tokens'),
69
+ ('cache_read', 'cache_read_tokens'), ('cache_write', 'cache_creation_tokens'))
70
+ CHUNK = 4 << 20
71
+ CURSOR_VERSION = 1
72
+ CURSOR_DAYS = 10 # day entries kept in the cursor: the daemon retains nine days of logs
73
+ SCHEMA = 1
74
+ DEFAULT_CONTROL = 'http://127.0.0.1:19181'
75
+ DEFAULT_LOG_DIR = '~/.local/share/throttle'
76
+ CONTAINER_LOG_DIR = L.CONTAINER_HOME + '/.local/share/throttle' # the daemon's log dir inside its container
77
+ TIMEOUT = 3
78
+ HEARTBEAT = 60 # seconds: economy.json is rewritten at least this often (sidebar ECONOMY_STALE = 3x)
79
+ ECONOMY = 'economy.json'
80
+ CURSOR = 'economy.cursor.json'
81
+ FLEET_KEYS = ('overflow_spend_today_usd', 'overflow_budget_usd', 'spent_fraction', 'alert_level_pct',
82
+ 'pressure_phase', 'spill_fraction', 'effective_free', 'accounts', 'soonest_reset_5h',
83
+ 'upstream_429_delta', 'paid_in_flight', 'sub_in_flight', 'fable_sustainable_rpm')
84
+ LIVE_KEYS = ('model', 'requested_model', 'paid_share', 'usd_per_hour', 'tok_per_min', 'cache_read_ratio',
85
+ 'downgraded_5m', 'status_429', 'last_request_age_s')
86
+
87
+
88
+ def trace_key(trace):
89
+ if not trace:
90
+ return UNTRACED
91
+ segs = trace.split('/')
92
+ if segs[0] == 'lanes' and len(segs) >= 3:
93
+ return '/'.join(segs[:3])
94
+ if segs[0] in SESSION_PREFIXES and len(segs) >= 2:
95
+ return '/'.join(segs[:2])
96
+ return segs[0]
97
+
98
+
99
+ def is_paid(account, paid_accounts=None):
100
+ """The overflow lane is the paid route. The daemon names its synthesized overflow account `<pool>/api-overflow`
101
+ (src/config.rs; `throttle accounts` filters on the same suffix); a configured name is passed as paid_accounts."""
102
+ if not account:
103
+ return False
104
+ if paid_accounts is not None:
105
+ return account in paid_accounts
106
+ return account.rsplit('/', 1)[-1] == 'api-overflow'
107
+
108
+
109
+ def _int(v):
110
+ try:
111
+ return int(v)
112
+ except (TypeError, ValueError):
113
+ return 0
114
+
115
+
116
+ def parse_line(line):
117
+ """(timestamp, fields) of one request-outcome line, JSON or tracing text, ANSI tolerated; else None."""
118
+ s = ANSI.sub('', line).strip()
119
+ if not s:
120
+ return None
121
+ if s.startswith('{'):
122
+ try:
123
+ o = json.loads(s)
124
+ except ValueError:
125
+ return None
126
+ f = o.get('fields') if isinstance(o, dict) else None
127
+ if not isinstance(f, dict) or f.get('message') != OUTCOME or not isinstance(o.get('timestamp'), str):
128
+ return None
129
+ return o['timestamp'], f
130
+ m = TEXT_HEAD.match(s)
131
+ if not m:
132
+ return None
133
+ rest = m.group(3)
134
+ if not rest.startswith(OUTCOME + ' ') and rest != OUTCOME:
135
+ return None
136
+ fields = {}
137
+ for k, v in TEXT_FIELD.findall(rest[len(OUTCOME):]):
138
+ if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
139
+ v = v[1:-1]
140
+ fields[k] = _int(v) if k in INT_FIELDS else v
141
+ return m.group(1), fields
142
+
143
+
144
+ def _record():
145
+ return {'req': 0, 'paid_req': 0, 'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0, 'failures': 0,
146
+ 'last_ts': None, 'models': {}}
147
+
148
+
149
+ def fold_outcomes(lines, day, paid_accounts=None, into=None):
150
+ """Fold request-outcome lines of `day` (UTC, `YYYY-MM-DD`) into {trace_key: totals}; other lines are ignored."""
151
+ totals = {} if into is None else into
152
+ for line in lines:
153
+ parsed = parse_line(line)
154
+ if not parsed:
155
+ continue
156
+ ts, f = parsed
157
+ if not ts.startswith(day):
158
+ continue
159
+ r = totals.setdefault(trace_key(f.get('trace')), _record())
160
+ r['req'] += 1
161
+ if is_paid(f.get('account'), paid_accounts):
162
+ r['paid_req'] += 1
163
+ for key, field in TOKENS:
164
+ r[key] += _int(f.get(field))
165
+ if f.get('failure') not in (None, '', 'None'):
166
+ r['failures'] += 1
167
+ if r['last_ts'] is None or ts > r['last_ts']:
168
+ r['last_ts'] = ts
169
+ model = f.get('model')
170
+ if model:
171
+ r['models'][model] = r['models'].get(model, 0) + 1
172
+ return totals
173
+
174
+
175
+ def _head_day(path):
176
+ try:
177
+ with open(path, 'rb') as fh:
178
+ first = fh.readline(65536)
179
+ except OSError:
180
+ return None
181
+ s = ANSI.sub('', first.decode('utf-8', 'replace')).strip()
182
+ if s.startswith('{'):
183
+ try:
184
+ ts = json.loads(s).get('timestamp')
185
+ except (ValueError, AttributeError):
186
+ return None
187
+ return ts[:10] if isinstance(ts, str) else None
188
+ m = TEXT_HEAD.match(s)
189
+ return m.group(1)[:10] if m else None
190
+
191
+
192
+ def _scan(log_dir):
193
+ """({day: path} of the day files, live path or None)."""
194
+ days = {}
195
+ try:
196
+ names = os.listdir(log_dir)
197
+ except OSError:
198
+ return days, None
199
+ for name in names:
200
+ m = DAY_FILE.match(name)
201
+ if m:
202
+ days[m.group(1)] = Path(log_dir) / name
203
+ live = Path(log_dir) / LIVE
204
+ return days, (live if LIVE in names else None)
205
+
206
+
207
+ def _fold_file(path, entry, day, paid_accounts, final):
208
+ """Fold bytes of `path` past entry['offset'] up to the last newline (to EOF when `final`); advance the offset."""
209
+ try:
210
+ fh = open(path, 'rb')
211
+ except OSError:
212
+ return
213
+ with fh:
214
+ fh.seek(entry['offset'])
215
+ pending = b''
216
+ while True:
217
+ chunk = fh.read(CHUNK)
218
+ if not chunk:
219
+ break
220
+ data = pending + chunk
221
+ cut = data.rfind(b'\n') + 1
222
+ if cut:
223
+ fold_outcomes(data[:cut].decode('utf-8', 'replace').splitlines(), day, paid_accounts,
224
+ into=entry['totals'])
225
+ entry['offset'] += cut
226
+ pending = data[cut:]
227
+ if final and pending:
228
+ fold_outcomes([pending.decode('utf-8', 'replace')], day, paid_accounts, into=entry['totals'])
229
+ entry['offset'] += len(pending)
230
+
231
+
232
+ def day_totals(log_dir, day, cursor, paid_accounts=None):
233
+ """(totals for `day`, new cursor). Reads only bytes appended since `cursor`; the input cursor is not mutated."""
234
+ cur = copy.deepcopy(cursor) if isinstance(cursor, dict) and cursor.get('v') == CURSOR_VERSION else {}
235
+ cur['v'] = CURSOR_VERSION
236
+ days = cur.setdefault('days', {})
237
+ entry = days.get(day)
238
+ if entry and entry.get('done'):
239
+ return copy.deepcopy(entry['totals']), cur
240
+ entry = entry or {'src': None, 'offset': 0, 'totals': {}, 'done': False}
241
+
242
+ day_files, live = _scan(log_dir)
243
+ live_day = _head_day(live) if live else None
244
+ later = any(d > day for d in day_files) or (live_day is not None and live_day > day)
245
+ src = day_files.get(day) or (live if live_day == day else None)
246
+ if src is not None:
247
+ try:
248
+ st = os.stat(src)
249
+ except OSError:
250
+ src = None
251
+ if src is not None:
252
+ ident = [st.st_dev, st.st_ino]
253
+ if entry['src'] == ident:
254
+ if st.st_size < entry['offset']: # truncated in place: its earlier bytes are counted and gone
255
+ entry['offset'] = 0
256
+ else:
257
+ if src.name != LIVE: # a day file is the whole-day record: it replaces, never adds
258
+ entry['totals'] = {}
259
+ entry['src'], entry['offset'] = ident, 0
260
+ _fold_file(src, entry, day, paid_accounts, final=later)
261
+ if later:
262
+ entry['done'] = True
263
+ days[day] = entry
264
+ return copy.deepcopy(entry['totals']), cur
265
+
266
+
267
+ def _add(acc, rec):
268
+ for k, v in rec.items():
269
+ if isinstance(v, bool) or v is None:
270
+ continue
271
+ if isinstance(v, (int, float)):
272
+ acc[k] = acc.get(k, 0) + v
273
+ elif isinstance(v, dict):
274
+ _add(acc.setdefault(k, {}), v)
275
+ elif isinstance(v, str):
276
+ if acc.get(k) is None or v > acc[k]:
277
+ acc[k] = v
278
+
279
+
280
+ def join_to_lanes(totals, lanes_by_session, lanes_by_trace):
281
+ """{lane_id: summed totals} over every trace key whose session id (`agent.session_id`, Task 5) or lane-stamped
282
+ key (`lanes/<ws>/<id>`, Task 7) maps to a lane; keys that map to no lane are dropped."""
283
+ out = {}
284
+ for key, rec in totals.items():
285
+ prefix, _, rest = key.partition('/')
286
+ if prefix == 'lanes':
287
+ lane = lanes_by_trace.get(key)
288
+ elif prefix in SESSION_PREFIXES:
289
+ lane = lanes_by_session.get(rest)
290
+ else:
291
+ lane = None
292
+ if lane:
293
+ _add(out.setdefault(lane, {}), rec)
294
+ return out
295
+
296
+
297
+ # ------------------------------------------------------------------ the poller (plan Task 11)
298
+
299
+ def _num(value):
300
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
301
+
302
+
303
+ def _get(doc, *path):
304
+ for key in path:
305
+ if not isinstance(doc, dict):
306
+ return None
307
+ doc = doc.get(key)
308
+ return doc
309
+
310
+
311
+ def fleet_strip(status_json, prev_429_total=None, now=None):
312
+ """The fleet strip from one `GET /status` body (src/proxy.rs `status_body`): exactly FLEET_KEYS, None for any
313
+ absent field. `overflow_budget_usd` is the ENFORCED cap (`effective_daily_budget_usd`, which an emergency ceiling
314
+ can only lower). `upstream_429_delta` is the growth of `pressure.upstream_429_total` since `prev_429_total` (None
315
+ without one, or when the total fell: a restarted daemon began counting again). `soonest_reset_5h` is the
316
+ earliest account 5 h reset (epoch seconds) after `now` (every reset when `now` is None). `sub_in_flight` is
317
+ `pressure.in_flight - pressure.overflow_in_flight` — INFERRED from src/proxy.rs (an overflow request holds a pool
318
+ permit as well as its overflow permit), not measured on a live daemon."""
319
+ s = status_json if isinstance(status_json, dict) else {}
320
+ accounts = s.get('accounts') if isinstance(s.get('accounts'), list) else None
321
+ resets = [r for r in (_num(_get(a, 'reset_5h')) for a in accounts or ()) if r is not None and (now is None or r > now)]
322
+ total = _num(_get(s, 'pressure', 'upstream_429_total'))
323
+ prev = _num(prev_429_total)
324
+ in_flight, paid = _num(_get(s, 'pressure', 'in_flight')), _num(_get(s, 'pressure', 'overflow_in_flight'))
325
+ phase = _get(s, 'pressure', 'phase')
326
+ return {
327
+ 'overflow_spend_today_usd': _num(_get(s, 'overflow', 'spend_today_usd')),
328
+ 'overflow_budget_usd': _num(_get(s, 'overflow', 'effective_daily_budget_usd')),
329
+ 'spent_fraction': _num(_get(s, 'overflow', 'spent_fraction')),
330
+ 'alert_level_pct': _num(_get(s, 'overflow', 'budget_alert_level_pct')),
331
+ 'pressure_phase': phase if isinstance(phase, str) else None,
332
+ 'spill_fraction': _num(_get(s, 'pressure', 'spill_fraction')),
333
+ 'effective_free': _num(s.get('effective_free')),
334
+ 'accounts': len(accounts) if accounts is not None else None,
335
+ 'soonest_reset_5h': min(resets) if resets else None,
336
+ 'upstream_429_delta': total - prev if total is not None and prev is not None and total >= prev else None,
337
+ 'paid_in_flight': paid,
338
+ 'sub_in_flight': max(0, in_flight - paid) if in_flight is not None and paid is not None else None,
339
+ 'fable_sustainable_rpm': _num(_get(s, 'fable_governor', 'sustainable_rpm')),
340
+ }
341
+
342
+
343
+ def lane_for(session_id, trace, lanes_by_session, lanes_by_trace):
344
+ """The lane a request row or session group belongs to: its lane-stamped trace (exact), else its session id,
345
+ else the session id inside a `cc-session/…`/`codex-session/…` trace; None when nothing maps."""
346
+ key = trace_key(trace if isinstance(trace, str) else '')
347
+ prefix, _, rest = key.partition('/')
348
+ if prefix == 'lanes' and key in lanes_by_trace:
349
+ return lanes_by_trace[key]
350
+ if isinstance(session_id, str) and session_id and session_id in lanes_by_session:
351
+ return lanes_by_session[session_id]
352
+ if prefix in SESSION_PREFIXES:
353
+ return lanes_by_session.get(rest)
354
+ return None
355
+
356
+
357
+ def per_lane_live(sessions_json, status_json, lanes_by_session, lanes_by_trace):
358
+ """{lane_id: live figures} from the `GET /sessions` `SessionGroup` rows (src/proxy.rs) of every group that maps to
359
+ a lane, summed over a lane's groups (sub-agents are groups of their own); `model`/`requested_model` come from the
360
+ lane's newest group. `status_429` and `cache_read_ratio` (cache reads over all prompt tokens) come from
361
+ `status.recent_requests`, the same 200-request ring. Lanes without a group are absent."""
362
+ groups = _get(sessions_json, 'sessions')
363
+ if not isinstance(groups, list):
364
+ return {}
365
+ now_ms = _num(_get(sessions_json, 'now_ms'))
366
+ acc = {}
367
+ for g in groups:
368
+ if not isinstance(g, dict):
369
+ continue
370
+ lane = lane_for(g.get('session_id'), g.get('trace'), lanes_by_session, lanes_by_trace)
371
+ if not lane:
372
+ continue
373
+ a = acc.setdefault(lane, {'req': 0, 'paid': 0, 'usd': 0.0, 'tpm': 0.0, 'down': 0, 'newest': None,
374
+ 'model': None, 'requested_model': None})
375
+ a['req'] += _num(g.get('req_count')) or 0
376
+ a['paid'] += _num(g.get('paid_count')) or 0
377
+ a['usd'] += _num(g.get('usd_per_hour')) or 0.0
378
+ a['tpm'] += _num(g.get('tok_per_min')) or 0.0
379
+ a['down'] += _num(g.get('downgraded_5m')) or 0
380
+ last = _num(g.get('last_ts_ms'))
381
+ if last is not None and (a['newest'] is None or last > a['newest']):
382
+ a['newest'] = last
383
+ a['model'] = g.get('model') if isinstance(g.get('model'), str) else None
384
+ a['requested_model'] = g.get('requested_model') if isinstance(g.get('requested_model'), str) else None
385
+ ring = {}
386
+ rows = _get(status_json, 'recent_requests')
387
+ for row in rows if isinstance(rows, list) else ():
388
+ if not isinstance(row, dict):
389
+ continue
390
+ lane = lane_for(row.get('session_id'), row.get('trace'), lanes_by_session, lanes_by_trace)
391
+ if lane not in acc:
392
+ continue
393
+ r = ring.setdefault(lane, {'429': 0, 'read': 0, 'prompt': 0})
394
+ if _num(row.get('status')) == 429:
395
+ r['429'] += 1
396
+ read = _num(row.get('cache_read_tokens')) or 0
397
+ r['read'] += read
398
+ r['prompt'] += read + (_num(row.get('input_tokens')) or 0) + (_num(row.get('cache_creation_tokens')) or 0)
399
+ out = {}
400
+ for lane, a in acc.items():
401
+ r = ring.get(lane, {'429': 0, 'read': 0, 'prompt': 0})
402
+ out[lane] = {
403
+ 'model': a['model'], 'requested_model': a['requested_model'],
404
+ 'paid_share': round(a['paid'] / a['req'], 4) if a['req'] else None,
405
+ 'usd_per_hour': round(a['usd'], 4), 'tok_per_min': round(a['tpm'], 1),
406
+ 'cache_read_ratio': round(r['read'] / r['prompt'], 4) if r['prompt'] else None,
407
+ 'downgraded_5m': a['down'], 'status_429': r['429'],
408
+ 'last_request_age_s': (max(0, round((now_ms - a['newest']) / 1000))
409
+ if now_ms is not None and a['newest'] is not None else None),
410
+ 'ring_req': a['req'],
411
+ }
412
+ return out
413
+
414
+
415
+ def lane_maps(status_doc, registry):
416
+ """(lanes_by_session, lanes_by_trace) from status.json (`agent.session_id`, or `agent.session` when a hook sidecar
417
+ supplied the agent) and the registry's workspaces (`lanes/<ws>/<id>`, lanes_lib.lane_trace)."""
418
+ by_session, by_trace = {}, {}
419
+ lanes = _get(status_doc, 'lanes')
420
+ for lane_id, entry in (lanes.items() if isinstance(lanes, dict) else ()):
421
+ agent = _get(entry, 'agent')
422
+ sid = _get(agent, 'session_id') or _get(agent, 'session')
423
+ if isinstance(sid, str) and sid:
424
+ by_session[sid] = lane_id
425
+ workspaces = _get(registry, 'workspaces')
426
+ for ws in workspaces if isinstance(workspaces, list) else ():
427
+ for lane in _get(ws, 'lanes') or ():
428
+ trace = L.lane_trace(_get(lane, 'id'), _get(ws, 'id'))
429
+ if trace:
430
+ by_trace[trace] = lane['id']
431
+ return by_session, by_trace
432
+
433
+
434
+ def control_url(env):
435
+ """The control base URL, or None when LANES_THROTTLE_CONTROL is `none`/`off`/empty."""
436
+ value = str(env.get('LANES_THROTTLE_CONTROL', DEFAULT_CONTROL)).strip()
437
+ if value.lower() in ('', 'none', 'off'):
438
+ return None
439
+ if not value.startswith(('http://', 'https://')):
440
+ raise L.LanesError(f'LANES_THROTTLE_CONTROL must be an http(s) URL or none, not {value!r}')
441
+ return value.rstrip('/')
442
+
443
+
444
+ def read_token(env):
445
+ """The optional bearer (loopback without `remote_key` needs none). Never logged, never written anywhere."""
446
+ path = env.get('LANES_THROTTLE_TOKEN_FILE')
447
+ if not path:
448
+ return None
449
+ try:
450
+ return Path(os.path.expanduser(path)).read_text().strip() or None
451
+ except OSError as error:
452
+ print(f'economy_poll: token file unreadable ({error.strerror}); polling without a bearer', file=sys.stderr)
453
+ return None
454
+
455
+
456
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
457
+ def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
458
+ return None
459
+
460
+
461
+ def default_opener():
462
+ """urllib without environment proxies (a loopback poll must never go through http_proxy) and without redirects."""
463
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()).open
464
+
465
+
466
+ MAX_BODY = 32 << 20
467
+
468
+
469
+ def fetch(control, path, token=None, opener=None, timeout=TIMEOUT):
470
+ """GET `<control>/<path>` → (the decoded JSON object, None), or (None, why) on any failure. `why` names the failure
471
+ class (`connection refused`, `timed out`, `HTTP 401`, `not JSON`, ...) and the URL — never the bearer."""
472
+ url = f'{control}/{path}'
473
+ request = urllib.request.Request(url, headers={'Accept': 'application/json'})
474
+ if token:
475
+ request.add_header('Authorization', f'Bearer {token}')
476
+ try:
477
+ with (opener or default_opener())(request, timeout=timeout) as response:
478
+ body = response.read(MAX_BODY + 1)
479
+ if len(body) > MAX_BODY:
480
+ return None, f'body over {MAX_BODY >> 20} MiB ({url})'
481
+ doc = json.loads(body)
482
+ except Exception as error: # the daemon may be down, restarting or slow: the poll degrades, never dies
483
+ return None, f'{failure_class(error)} ({url})'
484
+ return (doc, None) if isinstance(doc, dict) else (None, f'not a JSON object ({url})')
485
+
486
+
487
+ def failure_class(error):
488
+ """A short, secret-free name for why a GET failed."""
489
+ if isinstance(error, urllib.error.HTTPError):
490
+ return f'HTTP {error.code}' # a refused redirect lands here too (3xx)
491
+ if isinstance(error, urllib.error.URLError):
492
+ reason = error.reason
493
+ return failure_class(reason) if isinstance(reason, BaseException) else f'unreachable: {reason}'
494
+ if isinstance(error, ConnectionRefusedError):
495
+ return 'connection refused'
496
+ if isinstance(error, TimeoutError):
497
+ return 'timed out'
498
+ if isinstance(error, ValueError): # JSONDecodeError and UnicodeDecodeError are ValueErrors
499
+ return 'not JSON'
500
+ if isinstance(error, OSError):
501
+ return f'unreachable: {error.strerror or type(error).__name__}'
502
+ return type(error).__name__
503
+
504
+
505
+ def fetch_json(control, path, token=None, opener=None, timeout=TIMEOUT):
506
+ """GET `<control>/<path>` → the decoded JSON object, or None on any failure (refused, timeout, HTTP error,
507
+ oversize, not an object)."""
508
+ return fetch(control, path, token, opener, timeout)[0]
509
+
510
+
511
+ def fetch_status(control, token=None, opener=None):
512
+ return fetch_json(control, 'status', token, opener)
513
+
514
+
515
+ def fetch_sessions(control, token=None, opener=None):
516
+ return fetch_json(control, 'sessions', token, opener)
517
+
518
+
519
+ def _canonical(doc):
520
+ return json.dumps(doc, sort_keys=True, separators=(',', ':'))
521
+
522
+
523
+ def _without_at(doc):
524
+ return {k: v for k, v in doc.items() if k != 'at'} if isinstance(doc, dict) else None
525
+
526
+
527
+ def log_dir(env, state_dir):
528
+ """(the directory to fold, None) or (the directory or None, why it cannot be folded).
529
+
530
+ `LANES_THROTTLE_LOG_DIR` wins; else `LANES_THROTTLE_CONTAINER=<name>` maps the daemon's in-container log directory
531
+ through that container's home volume as `lanes sync` recorded it in STATE/containers.json (the host unit's own
532
+ `~/.local/share/throttle` is not the daemon's); else DEFAULT_LOG_DIR in this user's home."""
533
+ explicit = env.get('LANES_THROTTLE_LOG_DIR')
534
+ container = env.get('LANES_THROTTLE_CONTAINER')
535
+ if explicit:
536
+ path = Path(os.path.expanduser(str(explicit)))
537
+ elif container:
538
+ recorded = L.read_json(Path(state_dir) / 'containers.json')
539
+ info = recorded.get(container) if isinstance(recorded, dict) else None
540
+ if not isinstance(info, dict):
541
+ return None, f'container {container} is not in containers.json (lanes sync records board containers)'
542
+ try:
543
+ path = L.host_path(info, CONTAINER_LOG_DIR)
544
+ except L.LanesError as error:
545
+ return None, f'container {container}: {error}'
546
+ else:
547
+ path = Path(os.path.expanduser(DEFAULT_LOG_DIR))
548
+ try:
549
+ is_dir = path.is_dir() # re-raises EACCES from an untraversable parent (a root-only docker volume)
550
+ except OSError as error:
551
+ return path, f'log dir unreadable: {path} ({error.strerror or type(error).__name__})'
552
+ if not is_dir:
553
+ return path, f'log dir missing: {path}'
554
+ if not os.access(path, os.R_OK | os.X_OK):
555
+ return path, f'log dir unreadable: {path}'
556
+ return path, None
557
+
558
+
559
+ def report(diagnostics, last, stream=None, always=False):
560
+ """Print each source's failure class (or its recovery) to `stream` when it differs from `last` — or every one when
561
+ `always`; returns the new `last`. The journal says why `source` is false; economy.json never carries it."""
562
+ stream = stream or sys.stderr
563
+ last = last or {}
564
+ for name in ('status', 'sessions', 'log'):
565
+ why = diagnostics.get(name)
566
+ if why if always else why != last.get(name):
567
+ print(f'economy_poll: {name}: {why or "ok"}', file=stream, flush=True)
568
+ return dict(diagnostics)
569
+
570
+
571
+ def poll_once(state_dir, env, opener=None, now=None, write=True, status_file=None, sessions_file=None,
572
+ diagnostics=None):
573
+ """One poll: the daemon (or fixture files), today's log fold and the lane maps → the economy.json v1 payload.
574
+ With `write`, economy.json is replaced only when the payload changed apart from `at` or its `at` is HEARTBEAT
575
+ seconds old (the liveness heartbeat, DEF-1447), and the cursor only when it changed. Never raises for a down daemon or a missing log; raises LanesError for a malformed control URL. A
576
+ `diagnostics` dict receives why each of status, sessions and log is unavailable (None when it answered)."""
577
+ state_dir = Path(state_dir)
578
+ now = int(time.time() if now is None else now)
579
+ day = time.strftime('%Y-%m-%d', time.gmtime(now))
580
+ control = control_url(env)
581
+ token = read_token(env) if control else None
582
+ previous = L.read_json(state_dir / ECONOMY)
583
+ previous = previous if isinstance(previous, dict) and previous.get('v') == SCHEMA else {}
584
+ cursor = L.read_json(state_dir / CURSOR)
585
+ cursor = cursor if isinstance(cursor, dict) and cursor.get('v') == CURSOR_VERSION else {'v': CURSOR_VERSION}
586
+
587
+ def load(path, name):
588
+ if path:
589
+ doc = L.read_json(path)
590
+ return (doc, None) if isinstance(doc, dict) else (None, f'fixture is not a JSON object: {path}')
591
+ if not control:
592
+ return None, 'not requested (LANES_THROTTLE_CONTROL=none)'
593
+ return fetch(control, name, token, opener)
594
+
595
+ status, status_why = load(status_file, 'status')
596
+ sessions, sessions_why = load(sessions_file, 'sessions')
597
+
598
+ folded_dir, log_why = log_dir(env, state_dir)
599
+ log_ok = log_why is None
600
+ if diagnostics is not None:
601
+ diagnostics.update(status=status_why, sessions=sessions_why, log=log_why)
602
+ if log_ok:
603
+ totals, new_cursor = day_totals(folded_dir, day, cursor)
604
+ else:
605
+ totals, new_cursor = {}, copy.deepcopy(cursor)
606
+ days = new_cursor.setdefault('days', {})
607
+ for old in sorted(days)[:-CURSOR_DAYS]:
608
+ del days[old]
609
+
610
+ if status is not None:
611
+ fleet = fleet_strip(status, cursor.get('upstream_429_total'), now)
612
+ total = _num(_get(status, 'pressure', 'upstream_429_total'))
613
+ if total is not None:
614
+ new_cursor['upstream_429_total'] = total
615
+ elif isinstance(previous.get('fleet'), dict) and any(
616
+ previous['fleet'].get(k) is not None for k in FLEET_KEYS): # an all-unknown strip is not stale data
617
+ fleet = dict(previous['fleet'], stale=True)
618
+ else:
619
+ fleet = {k: None for k in FLEET_KEYS}
620
+
621
+ by_session, by_trace = lane_maps(L.read_json(state_dir / 'status.json'), L.read_json(state_dir / 'registry.json'))
622
+ lanes = {}
623
+ if sessions is not None:
624
+ lanes = per_lane_live(sessions, status, by_session, by_trace)
625
+ else:
626
+ for lane, rec in (previous.get('lanes') or {}).items():
627
+ if not isinstance(rec, dict):
628
+ continue
629
+ kept = {k: v for k, v in rec.items() if k not in ('day', 'stale')}
630
+ if any(v is not None for v in kept.values()):
631
+ lanes[lane] = dict(kept, stale=True)
632
+ for lane, record in join_to_lanes(totals, by_session, by_trace).items():
633
+ lanes.setdefault(lane, {})['day'] = dict(record, list_usd_estimate=None)
634
+ for record in lanes.values():
635
+ for key in LIVE_KEYS + ('ring_req',):
636
+ record.setdefault(key, None)
637
+
638
+ payload = {'v': SCHEMA, 'at': now,
639
+ 'source': {'status_ok': status is not None, 'sessions_ok': sessions is not None, 'log_ok': log_ok},
640
+ 'fleet': fleet, 'lanes': lanes}
641
+ if write:
642
+ written = previous.get('at')
643
+ beat = not isinstance(written, (int, float)) or isinstance(written, bool) or now - written >= HEARTBEAT
644
+ if beat or _canonical(_without_at(previous)) != _canonical(_without_at(payload)):
645
+ L.atomic_write(state_dir / ECONOMY, _canonical(payload) + '\n')
646
+ if _canonical(new_cursor) != _canonical(cursor):
647
+ L.atomic_write(state_dir / CURSOR, _canonical(new_cursor) + '\n')
648
+ return payload
649
+
650
+
651
+ def main(argv=None):
652
+ parser = argparse.ArgumentParser(prog='economy_poll', description='Throttle economy projection → economy.json')
653
+ parser.add_argument('--interval', type=float, default=10.0, help='seconds between polls (the unit uses 10)')
654
+ parser.add_argument('--once', action='store_true', help='poll once and print the projection')
655
+ parser.add_argument('--dry-run', action='store_true', help='with --once: write neither economy.json nor the cursor')
656
+ parser.add_argument('--status-file', help='read this /status fixture instead of the daemon')
657
+ parser.add_argument('--sessions-file', help='read this /sessions fixture instead of the daemon')
658
+ args = parser.parse_args(argv)
659
+ state = Path(os.environ.get('LANES_STATE') or L.STATE)
660
+ env = dict(os.environ)
661
+ if args.once or args.dry_run:
662
+ diagnostics = {}
663
+ payload = poll_once(state, env, write=not args.dry_run, status_file=args.status_file,
664
+ sessions_file=args.sessions_file, diagnostics=diagnostics)
665
+ print(json.dumps(payload, indent=2, sort_keys=True))
666
+ report(diagnostics, None, always=True)
667
+ return 0
668
+ control_url(env) # a malformed URL stops the unit at start, not once per tick
669
+ last = None
670
+ while True:
671
+ diagnostics = {}
672
+ try:
673
+ poll_once(state, env, status_file=args.status_file, sessions_file=args.sessions_file,
674
+ diagnostics=diagnostics)
675
+ last = report(diagnostics, last)
676
+ except Exception as error: # one bad tick is a journal line; the next tick tries again
677
+ print(f'economy_poll: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
678
+ time.sleep(max(1.0, args.interval))
679
+
680
+
681
+ if __name__ == '__main__':
682
+ try:
683
+ sys.exit(main())
684
+ except L.LanesError as error:
685
+ sys.exit(f'economy_poll: {error}')