@matt82198/aesop 0.2.0 → 0.3.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/CHANGELOG.md +39 -1
- package/README.md +60 -263
- package/bin/cli.js +7 -3
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +48 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +6 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cost Projection & Threshold Alerting — Live burn-rate observability for Aesop.
|
|
4
|
+
|
|
5
|
+
Calculates burn rate (tokens/min) from recent ledger entries, projects end-of-wave
|
|
6
|
+
spend, and fires threshold alerts at 70% and 90% of the configured cost ceiling.
|
|
7
|
+
|
|
8
|
+
API:
|
|
9
|
+
project(window_minutes, ceiling, config, horizon_minutes=60) -> dict
|
|
10
|
+
Calculate current spend, burn rate, and projected end-of-wave total.
|
|
11
|
+
Returns:
|
|
12
|
+
{
|
|
13
|
+
"current": int, # tokens spent in window
|
|
14
|
+
"burn_rate_per_min": float, # avg tokens/min in window
|
|
15
|
+
"projected": int, # projected spend at horizon
|
|
16
|
+
"ceiling": int or None, # configured ceiling (or None if unconfigured)
|
|
17
|
+
"pct_of_ceiling": float or None, # % of ceiling (or None if no ceiling)
|
|
18
|
+
"minutes_to_ceiling": float or None, # minutes until ceiling hit at current burn rate (None-safe)
|
|
19
|
+
"is_thin_window": bool, # warn if window has < 3 entries
|
|
20
|
+
"by_role": {role: {...}, ...}, # breakdown by model/role (haiku/sonnet/opus)
|
|
21
|
+
"reason": str or None # degradation reason if applicable
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
check_and_alert(window_minutes, ceiling, wave, config, horizon_minutes=60) -> dict
|
|
25
|
+
Run projection + fire threshold alerts at 70% and 90% of ceiling.
|
|
26
|
+
Writes to SECURITY-ALERTS.log and creates flag files for idempotency.
|
|
27
|
+
Single-writer-per-wave assumption: only one process calls this per wave.
|
|
28
|
+
Returns:
|
|
29
|
+
{
|
|
30
|
+
"alert_level": str or None, # "70", "90", or None if below threshold
|
|
31
|
+
"current": int,
|
|
32
|
+
"ceiling": int,
|
|
33
|
+
"pct_of_ceiling": float,
|
|
34
|
+
"minutes_to_ceiling": float or None, # minutes to ceiling breach at current burn rate
|
|
35
|
+
"fired_alert": bool # True if new alert was written (not already fired)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
CLI:
|
|
39
|
+
python tools/cost_projection.py --projection [--window 30] [--horizon 60] [--ceiling N] [--json] [--config <path>]
|
|
40
|
+
Print projection result (human or JSON).
|
|
41
|
+
python tools/cost_projection.py --check-alerts --wave <id> [--horizon 60] [--ceiling N] [--json] [--config <path>]
|
|
42
|
+
Check thresholds and fire alerts; print result. Requires --wave or AESOP_WAVE env var.
|
|
43
|
+
|
|
44
|
+
Configuration (aesop.config.json):
|
|
45
|
+
"limits": {
|
|
46
|
+
"max_wave_tokens": 50000, # Ceiling for projection/alerts
|
|
47
|
+
"max_daily_tokens": null,
|
|
48
|
+
"wave_duration_minutes": 60 # Horizon for projection (optional; default 60)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Ledger Source:
|
|
52
|
+
Reads from state/ledger/OUTCOMES-LEDGER.md (markdown table with ISO timestamps).
|
|
53
|
+
Filters entries within window_minutes of NOW (UTC).
|
|
54
|
+
No naive datetimes; all UTC epoch-based.
|
|
55
|
+
|
|
56
|
+
WINDOW CONTRACT (shared with cost_ceiling.py):
|
|
57
|
+
Both cost_projection.py and cost_ceiling.py import and use the shared
|
|
58
|
+
calculate_window_bounds() helper to ensure identical window calculations.
|
|
59
|
+
When both tools are called with the same window_minutes value, they will
|
|
60
|
+
compute identical spend figures from the ledger.
|
|
61
|
+
|
|
62
|
+
This prevents disagreement between projection and ceiling checks that use
|
|
63
|
+
different window calculations.
|
|
64
|
+
|
|
65
|
+
Alert Idempotency:
|
|
66
|
+
Flag files: state/.cost-alert-{70,90}-w{wave}
|
|
67
|
+
Once fired for a (threshold, wave) pair, no duplicate log entries until next wave.
|
|
68
|
+
Single-writer guarantee: only the monitor/orchestrator calls this at the same cadence.
|
|
69
|
+
|
|
70
|
+
Write Integrity:
|
|
71
|
+
append_alert_log uses open('a')+write+flush+os.fsync for crash-safe appends.
|
|
72
|
+
mark_alert_fired called only after successful append (checked via return value).
|
|
73
|
+
Failures print CRITICAL to stderr; flag file not created on append failure.
|
|
74
|
+
|
|
75
|
+
Stdlib-only (json, sys, os, pathlib, datetime, collections).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
import argparse
|
|
79
|
+
import json
|
|
80
|
+
import os
|
|
81
|
+
import sys
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
from datetime import datetime, timezone, timedelta
|
|
84
|
+
from collections import defaultdict
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
import fleet_ledger
|
|
88
|
+
import common
|
|
89
|
+
import cost_ceiling
|
|
90
|
+
except ImportError:
|
|
91
|
+
from tools import fleet_ledger
|
|
92
|
+
from tools import common
|
|
93
|
+
from tools import cost_ceiling
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_state_dir(config=None):
|
|
97
|
+
"""Resolve state directory from config or environment."""
|
|
98
|
+
if config and config.get("state_root"):
|
|
99
|
+
return Path(config["state_root"])
|
|
100
|
+
if os.environ.get("AESOP_STATE_ROOT"):
|
|
101
|
+
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
102
|
+
return Path.cwd() / "state"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_ceiling(config):
|
|
106
|
+
"""Extract max_wave_tokens ceiling from config, or None if unconfigured."""
|
|
107
|
+
if not config:
|
|
108
|
+
return None
|
|
109
|
+
limits = config.get("limits", {})
|
|
110
|
+
if not isinstance(limits, dict):
|
|
111
|
+
return None
|
|
112
|
+
return limits.get("max_wave_tokens")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_horizon_minutes(config):
|
|
116
|
+
"""Extract wave_duration_minutes from config, or 60 if unconfigured."""
|
|
117
|
+
if not config:
|
|
118
|
+
return 60
|
|
119
|
+
limits = config.get("limits", {})
|
|
120
|
+
if not isinstance(limits, dict):
|
|
121
|
+
return 60
|
|
122
|
+
return limits.get("wave_duration_minutes", 60)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def filter_ledger_by_window(rows, window_minutes):
|
|
126
|
+
"""Filter ledger rows to those within the last window_minutes (UTC).
|
|
127
|
+
|
|
128
|
+
Uses the shared calculate_window_bounds() helper from cost_ceiling.py
|
|
129
|
+
to ensure consistent window calculations with cost_ceiling.check().
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
rows: List of dicts from fleet_ledger.parse_ledger_rows()
|
|
133
|
+
window_minutes: Time window in minutes
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Filtered list of rows within the window
|
|
137
|
+
|
|
138
|
+
Window contract:
|
|
139
|
+
When cost_projection.project(window_minutes=W) and
|
|
140
|
+
cost_ceiling.check(window_minutes=W) both use the same W value,
|
|
141
|
+
they produce identical spend calculations.
|
|
142
|
+
"""
|
|
143
|
+
if not rows:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
# Use shared window bounds helper from cost_ceiling
|
|
147
|
+
window_start, window_end = cost_ceiling.calculate_window_bounds(window_minutes)
|
|
148
|
+
|
|
149
|
+
filtered = []
|
|
150
|
+
for row in rows:
|
|
151
|
+
try:
|
|
152
|
+
iso_ts = row['iso_ts']
|
|
153
|
+
# Parse ISO timestamp (format: YYYY-MM-DDTHH:MM:SS[Z] or with offset)
|
|
154
|
+
ts_clean = iso_ts.replace('Z', '+00:00') if 'Z' in iso_ts else iso_ts
|
|
155
|
+
row_dt = datetime.fromisoformat(ts_clean)
|
|
156
|
+
# Ensure UTC
|
|
157
|
+
if row_dt.tzinfo is None:
|
|
158
|
+
row_dt = row_dt.replace(tzinfo=timezone.utc)
|
|
159
|
+
|
|
160
|
+
if row_dt >= window_start:
|
|
161
|
+
filtered.append(row)
|
|
162
|
+
except (ValueError, AttributeError):
|
|
163
|
+
# Skip malformed timestamps
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
return filtered
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def project(window_minutes=30, ceiling=None, config=None, horizon_minutes=None):
|
|
170
|
+
"""Calculate current spend, burn rate, and end-of-wave projection.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
window_minutes: Time window for burn rate calculation (default 30)
|
|
174
|
+
ceiling: Optional cost ceiling (for percentage calculation)
|
|
175
|
+
config: aesop.config.json dict or None (will use env var / defaults)
|
|
176
|
+
horizon_minutes: Minutes to project spend to (default from config or 60)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
dict with keys: current, burn_rate_per_min, projected, ceiling,
|
|
180
|
+
pct_of_ceiling, minutes_to_ceiling, is_thin_window, by_role, reason
|
|
181
|
+
"""
|
|
182
|
+
if config is None:
|
|
183
|
+
config = {}
|
|
184
|
+
|
|
185
|
+
state_dir = get_state_dir(config)
|
|
186
|
+
if ceiling is None:
|
|
187
|
+
ceiling = get_ceiling(config)
|
|
188
|
+
if horizon_minutes is None:
|
|
189
|
+
horizon_minutes = get_horizon_minutes(config)
|
|
190
|
+
|
|
191
|
+
# Parse ledger
|
|
192
|
+
try:
|
|
193
|
+
rows = fleet_ledger.parse_ledger_rows()
|
|
194
|
+
except Exception as e:
|
|
195
|
+
return {
|
|
196
|
+
"current": 0,
|
|
197
|
+
"burn_rate_per_min": 0.0,
|
|
198
|
+
"projected": 0,
|
|
199
|
+
"ceiling": ceiling,
|
|
200
|
+
"pct_of_ceiling": None,
|
|
201
|
+
"is_thin_window": True,
|
|
202
|
+
"by_role": {},
|
|
203
|
+
"reason": f"Failed to read ledger: {str(e)[:100]}"
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Filter to window
|
|
207
|
+
windowed_rows = filter_ledger_by_window(rows, window_minutes)
|
|
208
|
+
|
|
209
|
+
# Calculate current spend and breakdown by role
|
|
210
|
+
current_total = 0
|
|
211
|
+
by_role = defaultdict(lambda: {"tokens_in": 0, "tokens_out": 0, "total": 0, "entries": 0})
|
|
212
|
+
|
|
213
|
+
for row in windowed_rows:
|
|
214
|
+
ti = row.get('tokens_in', 0)
|
|
215
|
+
to = row.get('tokens_out', 0)
|
|
216
|
+
total = ti + to
|
|
217
|
+
current_total += total
|
|
218
|
+
|
|
219
|
+
role = row.get('model', 'unknown')
|
|
220
|
+
by_role[role]['tokens_in'] += ti
|
|
221
|
+
by_role[role]['tokens_out'] += to
|
|
222
|
+
by_role[role]['total'] += total
|
|
223
|
+
by_role[role]['entries'] += 1
|
|
224
|
+
|
|
225
|
+
# Convert defaultdict to plain dict for JSON
|
|
226
|
+
by_role_dict = {k: dict(v) for k, v in by_role.items()}
|
|
227
|
+
|
|
228
|
+
# Calculate burn rate
|
|
229
|
+
if len(windowed_rows) == 0:
|
|
230
|
+
burn_rate_per_min = 0.0
|
|
231
|
+
is_thin_window = True
|
|
232
|
+
elif len(windowed_rows) < 3:
|
|
233
|
+
# Thin window: estimate from available data (but flag it)
|
|
234
|
+
# Use actual time span, not window size
|
|
235
|
+
try:
|
|
236
|
+
first_row = windowed_rows[0]
|
|
237
|
+
last_row = windowed_rows[-1]
|
|
238
|
+
first_ts = first_row['iso_ts'].replace('Z', '+00:00')
|
|
239
|
+
last_ts = last_row['iso_ts'].replace('Z', '+00:00')
|
|
240
|
+
first_dt = datetime.fromisoformat(first_ts)
|
|
241
|
+
last_dt = datetime.fromisoformat(last_ts)
|
|
242
|
+
if first_dt.tzinfo is None:
|
|
243
|
+
first_dt = first_dt.replace(tzinfo=timezone.utc)
|
|
244
|
+
if last_dt.tzinfo is None:
|
|
245
|
+
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
|
246
|
+
|
|
247
|
+
time_span_min = (last_dt - first_dt).total_seconds() / 60.0
|
|
248
|
+
if time_span_min <= 0:
|
|
249
|
+
time_span_min = 1.0 # Avoid division by zero
|
|
250
|
+
burn_rate_per_min = current_total / time_span_min
|
|
251
|
+
except (ValueError, AttributeError):
|
|
252
|
+
burn_rate_per_min = 0.0
|
|
253
|
+
is_thin_window = True
|
|
254
|
+
else:
|
|
255
|
+
# Sufficient data: use window minutes
|
|
256
|
+
burn_rate_per_min = current_total / window_minutes if window_minutes > 0 else 0.0
|
|
257
|
+
is_thin_window = False
|
|
258
|
+
|
|
259
|
+
# Project to end-of-horizon (using horizon_minutes parameter)
|
|
260
|
+
projected = int(burn_rate_per_min * horizon_minutes)
|
|
261
|
+
|
|
262
|
+
# Calculate percentage of ceiling
|
|
263
|
+
pct_of_ceiling = None
|
|
264
|
+
if ceiling is not None and ceiling > 0:
|
|
265
|
+
pct_of_ceiling = (current_total / ceiling) * 100.0
|
|
266
|
+
|
|
267
|
+
# Calculate minutes until ceiling is hit at current burn rate (None-safe)
|
|
268
|
+
minutes_to_ceiling = None
|
|
269
|
+
if ceiling is not None and ceiling > 0 and burn_rate_per_min > 0:
|
|
270
|
+
remaining_tokens = ceiling - current_total
|
|
271
|
+
if remaining_tokens > 0:
|
|
272
|
+
minutes_to_ceiling = remaining_tokens / burn_rate_per_min
|
|
273
|
+
else:
|
|
274
|
+
minutes_to_ceiling = 0.0 # Already at or past ceiling
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"current": current_total,
|
|
278
|
+
"burn_rate_per_min": round(burn_rate_per_min, 2),
|
|
279
|
+
"projected": projected,
|
|
280
|
+
"ceiling": ceiling,
|
|
281
|
+
"pct_of_ceiling": pct_of_ceiling,
|
|
282
|
+
"minutes_to_ceiling": round(minutes_to_ceiling, 2) if minutes_to_ceiling is not None else None,
|
|
283
|
+
"is_thin_window": is_thin_window,
|
|
284
|
+
"by_role": by_role_dict,
|
|
285
|
+
"reason": None
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_alert_flag_path(state_dir, threshold, wave):
|
|
290
|
+
"""Get the flag file path for a (threshold, wave) pair."""
|
|
291
|
+
return Path(state_dir) / f".cost-alert-{threshold}-w{wave}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_alert_already_fired(state_dir, threshold, wave):
|
|
295
|
+
"""Check if alert has already been fired for this (threshold, wave) pair."""
|
|
296
|
+
flag = get_alert_flag_path(state_dir, threshold, wave)
|
|
297
|
+
return flag.exists()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def mark_alert_fired(state_dir, threshold, wave):
|
|
301
|
+
"""Mark an alert as fired by creating a flag file."""
|
|
302
|
+
flag = get_alert_flag_path(state_dir, threshold, wave)
|
|
303
|
+
state_dir_path = Path(state_dir)
|
|
304
|
+
state_dir_path.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
try:
|
|
306
|
+
flag.write_text(
|
|
307
|
+
datetime.now(timezone.utc).isoformat(),
|
|
308
|
+
encoding='utf-8'
|
|
309
|
+
)
|
|
310
|
+
except IOError as e:
|
|
311
|
+
print(f"[cost_projection] Failed to write flag file: {e}", file=sys.stderr)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def append_alert_log(state_dir, alert_line):
|
|
315
|
+
"""Append one line to SECURITY-ALERTS.log (idempotent per flag).
|
|
316
|
+
|
|
317
|
+
Uses open('a')+write+flush+os.fsync for crash-safe appends (Windows-portable).
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
bool: True if append succeeded, False on IOError
|
|
321
|
+
"""
|
|
322
|
+
alert_file = Path(state_dir) / "SECURITY-ALERTS.log"
|
|
323
|
+
state_dir_path = Path(state_dir)
|
|
324
|
+
state_dir_path.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
with open(alert_file, 'a', encoding='utf-8') as f:
|
|
328
|
+
f.write(alert_line + '\n')
|
|
329
|
+
f.flush()
|
|
330
|
+
os.fsync(f.fileno())
|
|
331
|
+
return True
|
|
332
|
+
except IOError as e:
|
|
333
|
+
print(f"[cost_projection] CRITICAL: Failed to write alert: {e}", file=sys.stderr)
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def check_and_alert(window_minutes=30, ceiling=None, wave=None, config=None, horizon_minutes=None):
|
|
338
|
+
"""Run projection + fire threshold alerts at 70% and 90% of ceiling.
|
|
339
|
+
|
|
340
|
+
Fires BOTH 70% and 90% alerts independently if a spike breaches 90%.
|
|
341
|
+
Only calls mark_alert_fired AFTER append succeeds.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
window_minutes: Time window for burn rate (default 30)
|
|
345
|
+
ceiling: Cost ceiling (or None to use config)
|
|
346
|
+
wave: Wave number for alert flag files (required or from AESOP_WAVE env)
|
|
347
|
+
config: aesop.config.json dict
|
|
348
|
+
horizon_minutes: Minutes to project spend to (default from config or 60)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
dict with alert info:
|
|
352
|
+
{
|
|
353
|
+
"alert_level": "70"|"90"|None,
|
|
354
|
+
"current": int,
|
|
355
|
+
"ceiling": int,
|
|
356
|
+
"pct_of_ceiling": float,
|
|
357
|
+
"minutes_to_ceiling": float or None,
|
|
358
|
+
"fired_alert": bool
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
ValueError: If wave is not provided and AESOP_WAVE env var is not set
|
|
363
|
+
"""
|
|
364
|
+
# Resolve wave from argument or environment
|
|
365
|
+
if wave is None:
|
|
366
|
+
wave = os.environ.get("AESOP_WAVE")
|
|
367
|
+
if wave is None:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"wave parameter required: pass --wave <id> or set AESOP_WAVE env var"
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
wave = int(wave)
|
|
373
|
+
except ValueError:
|
|
374
|
+
raise ValueError(f"AESOP_WAVE must be an integer, got: {wave}")
|
|
375
|
+
|
|
376
|
+
if config is None:
|
|
377
|
+
config = {}
|
|
378
|
+
|
|
379
|
+
state_dir = get_state_dir(config)
|
|
380
|
+
if ceiling is None:
|
|
381
|
+
ceiling = get_ceiling(config)
|
|
382
|
+
if horizon_minutes is None:
|
|
383
|
+
horizon_minutes = get_horizon_minutes(config)
|
|
384
|
+
|
|
385
|
+
proj = project(window_minutes, ceiling, config, horizon_minutes)
|
|
386
|
+
current = proj["current"]
|
|
387
|
+
pct = proj["pct_of_ceiling"]
|
|
388
|
+
minutes_to_ceiling = proj.get("minutes_to_ceiling")
|
|
389
|
+
|
|
390
|
+
alert_level = None
|
|
391
|
+
fired = False
|
|
392
|
+
|
|
393
|
+
if pct is not None and ceiling is not None:
|
|
394
|
+
# Check both thresholds independently (spike past 90% fires BOTH)
|
|
395
|
+
# 90% alert (highest priority, always checked)
|
|
396
|
+
if pct >= 90.0:
|
|
397
|
+
alert_level = "90"
|
|
398
|
+
if not check_alert_already_fired(state_dir, "90", wave):
|
|
399
|
+
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
|
400
|
+
alert_line = (
|
|
401
|
+
f"[{timestamp}] CRITICAL: Cost projection at 90% of ceiling "
|
|
402
|
+
f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
|
|
403
|
+
)
|
|
404
|
+
if append_alert_log(state_dir, alert_line):
|
|
405
|
+
mark_alert_fired(state_dir, "90", wave)
|
|
406
|
+
fired = True
|
|
407
|
+
# Also check 70% when at 90% (fire BOTH independently)
|
|
408
|
+
if not check_alert_already_fired(state_dir, "70", wave):
|
|
409
|
+
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
|
410
|
+
alert_line_70 = (
|
|
411
|
+
f"[{timestamp}] HIGH: Cost projection at 70% of ceiling "
|
|
412
|
+
f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
|
|
413
|
+
)
|
|
414
|
+
if append_alert_log(state_dir, alert_line_70):
|
|
415
|
+
mark_alert_fired(state_dir, "70", wave)
|
|
416
|
+
fired = True
|
|
417
|
+
# 70% alert (if not at 90%)
|
|
418
|
+
elif pct >= 70.0:
|
|
419
|
+
alert_level = "70"
|
|
420
|
+
if not check_alert_already_fired(state_dir, "70", wave):
|
|
421
|
+
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
|
422
|
+
alert_line = (
|
|
423
|
+
f"[{timestamp}] HIGH: Cost projection at 70% of ceiling "
|
|
424
|
+
f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
|
|
425
|
+
)
|
|
426
|
+
if append_alert_log(state_dir, alert_line):
|
|
427
|
+
mark_alert_fired(state_dir, "70", wave)
|
|
428
|
+
fired = True
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"alert_level": alert_level,
|
|
432
|
+
"current": current,
|
|
433
|
+
"ceiling": ceiling,
|
|
434
|
+
"pct_of_ceiling": pct,
|
|
435
|
+
"minutes_to_ceiling": minutes_to_ceiling,
|
|
436
|
+
"fired_alert": fired
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def load_config_file(config_path=None):
|
|
441
|
+
"""Load aesop.config.json from specified path or current directory."""
|
|
442
|
+
if config_path:
|
|
443
|
+
path = Path(config_path)
|
|
444
|
+
else:
|
|
445
|
+
path = Path.cwd() / "aesop.config.json"
|
|
446
|
+
|
|
447
|
+
if not path.exists():
|
|
448
|
+
return {}
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
452
|
+
return json.load(f)
|
|
453
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
454
|
+
print(f"[cost_projection] Failed to load config: {e}", file=sys.stderr)
|
|
455
|
+
return {}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def main(argv=None):
|
|
459
|
+
"""CLI entry point."""
|
|
460
|
+
parser = argparse.ArgumentParser(
|
|
461
|
+
description="Cost projection and threshold alerting for Aesop."
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"--projection",
|
|
465
|
+
action="store_true",
|
|
466
|
+
help="Calculate and print projection (default mode)"
|
|
467
|
+
)
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"--check-alerts",
|
|
470
|
+
action="store_true",
|
|
471
|
+
help="Check thresholds and fire alerts"
|
|
472
|
+
)
|
|
473
|
+
parser.add_argument(
|
|
474
|
+
"--window",
|
|
475
|
+
type=int,
|
|
476
|
+
default=30,
|
|
477
|
+
help="Time window in minutes for burn rate (default 30)"
|
|
478
|
+
)
|
|
479
|
+
parser.add_argument(
|
|
480
|
+
"--horizon",
|
|
481
|
+
type=int,
|
|
482
|
+
default=None,
|
|
483
|
+
help="Minutes to project spend to (default from config or 60)"
|
|
484
|
+
)
|
|
485
|
+
parser.add_argument(
|
|
486
|
+
"--ceiling",
|
|
487
|
+
type=int,
|
|
488
|
+
default=None,
|
|
489
|
+
help="Cost ceiling in tokens (overrides config)"
|
|
490
|
+
)
|
|
491
|
+
parser.add_argument(
|
|
492
|
+
"--wave",
|
|
493
|
+
type=int,
|
|
494
|
+
default=None,
|
|
495
|
+
help="Wave number (for alert idempotency; required for --check-alerts or from AESOP_WAVE env)"
|
|
496
|
+
)
|
|
497
|
+
parser.add_argument(
|
|
498
|
+
"--json",
|
|
499
|
+
action="store_true",
|
|
500
|
+
help="Output as JSON"
|
|
501
|
+
)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"--config",
|
|
504
|
+
type=str,
|
|
505
|
+
default=None,
|
|
506
|
+
help="Path to aesop.config.json (default: ./aesop.config.json)"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
args = parser.parse_args(argv)
|
|
510
|
+
|
|
511
|
+
config = load_config_file(args.config)
|
|
512
|
+
|
|
513
|
+
if args.check_alerts:
|
|
514
|
+
try:
|
|
515
|
+
result = check_and_alert(
|
|
516
|
+
window_minutes=args.window,
|
|
517
|
+
ceiling=args.ceiling,
|
|
518
|
+
wave=args.wave,
|
|
519
|
+
config=config,
|
|
520
|
+
horizon_minutes=args.horizon
|
|
521
|
+
)
|
|
522
|
+
if args.json:
|
|
523
|
+
print(json.dumps(result, indent=2))
|
|
524
|
+
else:
|
|
525
|
+
pct_str = f"{result['pct_of_ceiling']:.1f}%" if result['pct_of_ceiling'] is not None else "N/A"
|
|
526
|
+
mtc_str = f"{result['minutes_to_ceiling']:.1f}min" if result['minutes_to_ceiling'] is not None else "N/A"
|
|
527
|
+
print(f"[cost_projection] alert_level={result['alert_level']}, "
|
|
528
|
+
f"current={result['current']}/{result['ceiling']}, "
|
|
529
|
+
f"pct={pct_str}, "
|
|
530
|
+
f"mtc={mtc_str}, "
|
|
531
|
+
f"fired={result['fired_alert']}")
|
|
532
|
+
return 0
|
|
533
|
+
except ValueError as e:
|
|
534
|
+
print(f"[cost_projection] ERROR: {e}", file=sys.stderr)
|
|
535
|
+
return 1
|
|
536
|
+
else:
|
|
537
|
+
# Default: --projection
|
|
538
|
+
result = project(
|
|
539
|
+
window_minutes=args.window,
|
|
540
|
+
ceiling=args.ceiling,
|
|
541
|
+
config=config,
|
|
542
|
+
horizon_minutes=args.horizon
|
|
543
|
+
)
|
|
544
|
+
if args.json:
|
|
545
|
+
print(json.dumps(result, indent=2))
|
|
546
|
+
else:
|
|
547
|
+
mtc_str = f"{result['minutes_to_ceiling']:.1f}min" if result['minutes_to_ceiling'] is not None else "N/A"
|
|
548
|
+
print(f"[cost_projection] current={result['current']}, "
|
|
549
|
+
f"burn_rate={result['burn_rate_per_min']}/min, "
|
|
550
|
+
f"projected={result['projected']}, "
|
|
551
|
+
f"ceiling={result['ceiling']}, "
|
|
552
|
+
f"pct={result['pct_of_ceiling']}, "
|
|
553
|
+
f"mtc={mtc_str}, "
|
|
554
|
+
f"thin_window={result['is_thin_window']}")
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
if __name__ == "__main__":
|
|
559
|
+
sys.exit(main())
|