@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
|
@@ -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}')
|