AUM v1: метрика цели измерима (t-261004000834-ed84f5, day-20731): balances.json + авто-refresh из API (OpenRouter credits, Stripe live; livemode-only), AUM=26.7 (было None), aum --record обновил aum_target для всех тиков; тесты test_aum +4 (mock API, livemode=False→None) — ВСЕ ОК; hour-тик: task list без клонов-задач + tg_inbox env-fallback (e-261003125707); дневной отчёт day-2026-10-04
This commit is contained in:
parent
f1c6aa85af
commit
f286f1b8ed
6 changed files with 327 additions and 7 deletions
16
cli.py
16
cli.py
|
|
@ -14,7 +14,12 @@ def main():
|
|||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_task = sub.add_parser("task", help="подать задачу")
|
||||
p_task.add_argument("title")
|
||||
# t-261003125513/e-261003125707: 'task list' раньше создавал задачу-клон
|
||||
# 'list' — теперь показывает доску (внутри 'list' как слово не валидируем,
|
||||
# чтобы не породить рекурсию справки)
|
||||
p_task.add_argument("title", nargs="?")
|
||||
p_task.add_argument("--list", dest="list_board", action="store_true",
|
||||
help="показать доску задач (не создавать)")
|
||||
p_task.add_argument("-d", "--details", default="")
|
||||
p_task.add_argument("-m", "--minutes", type=int, default=5,
|
||||
help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)")
|
||||
|
|
@ -160,6 +165,15 @@ def main():
|
|||
elif args.cmd == "wizard":
|
||||
_run_wizard(args)
|
||||
elif args.cmd == "task":
|
||||
# t-261003125513/e-261003125707: голое 'list'/'tasks'/'task list' —
|
||||
# показать доску, а не плодить задачи-клоны
|
||||
if getattr(args, "list_board", False) or (args.title or "").strip() in ("list", "tasks", "task list"):
|
||||
es.init_db()
|
||||
for t in commands.list_tasks():
|
||||
print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}")
|
||||
elif not args.title:
|
||||
p_task.print_help()
|
||||
else:
|
||||
tid, size = commands.submit_task(args.title, args.details, args.minutes)
|
||||
if args.await_delivery:
|
||||
commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой")
|
||||
|
|
|
|||
32
docs/aum/balances.json
Normal file
32
docs/aum/balances.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"_comment": "Замер AUM кооператива (t-261004000834-ed84f5, day-тик 2026-10-04). amount_usd — USD-эквивалент; null — требует ручного ввода Alex (в total не входит). Авто-источники помечены src; обновлять по мере изменения балансов.",
|
||||
"as_of": "2026-10-04",
|
||||
"accounts": [
|
||||
{
|
||||
"name": "OpenRouter кредиты коопа (доступно, key из simbiot-harness/.env)",
|
||||
"amount_usd": 26.7,
|
||||
"src": "openrouter.ai/api/v1/credits (замер 2026-10-03 23:5x UTC: total=163, usage=136.30)",
|
||||
"auto": true,
|
||||
"measured_at": "2026-10-04T00:15:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "US LLC Delaware (операционный)",
|
||||
"amount_usd": null,
|
||||
"src": "ручной ввод Alex (API-источник неизвестен)"
|
||||
},
|
||||
{
|
||||
"name": "Крипто-кошелёк кооператива",
|
||||
"amount_usd": null,
|
||||
"src": "ручной ввод Alex"
|
||||
},
|
||||
{
|
||||
"name": "Stripe live balance (available)",
|
||||
"amount_usd": 0.0,
|
||||
"src": "api.stripe.com/v1/balance livemode=True, STRIPE_LIVE_KEY из coop/.env (замер 2026-10-03): available=0, pending=0",
|
||||
"auto": true,
|
||||
"measured_at": "2026-10-04T00:15:22+00:00"
|
||||
}
|
||||
],
|
||||
"debts": [],
|
||||
"notes": "Первый непустой замер: total = 26.70 USD. Что НЕ вошло и почему: Stripe test-balance (livemode=False, pending $335.24) — тестовые деньги, в AUM не актив; 9router prepay — величина предоплаты не замерена (нужен источник); выручка presale — нулевая на момент замера (live available=0, pending=0). Для Alex: заполнить null-строки (LLC-счёт, крипто-кошелёк, 9router prepay) — после этого AUM станет полным. Методика: docs/aum.md."
|
||||
}
|
||||
84
docs/digests/day-2026-10-04.md
Normal file
84
docs/digests/day-2026-10-04.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Дневной отчёт Simbiot Harness — 2026-10-04
|
||||
|
||||
Дневной тик (day-20731, 00:00 UTC). Манифест дня: разбор эскалаций → верификация
|
||||
системы → план → L-задачи. Одна L-задача закрыта в тике (AUM v1), вторая в очереди
|
||||
на часовой.
|
||||
|
||||
## План дня
|
||||
|
||||
1. **AUM v1 — сделать метрику цели измеримой** (t-261004000834-ed84f5, [L]) —
|
||||
✅ закрыто дневным тиком, см. ниже. Мотивация: северная звезда (t-260929161138)
|
||||
была None с момента фиксации — управлять долгосрочной базой без числа нельзя.
|
||||
2. **Первый канал трафика на presale** (t-261004000834-4bbc6f, [L]) — launch-kit
|
||||
органического запуска (Show HN / Reddit / X-тред, EN-черновики) + верификация
|
||||
клик-трекинга на presale.cil.by; публикация — только через Alex (график
|
||||
3-через-3, docs/alex-schedule.md). По docs/SALES-PLAN-1000-US.md: воронка,
|
||||
лендинг, живой Stripe и счётчик мест готовы — трафика ~0, без канала 1000
|
||||
предзаказов недостижимы. Фильтр AUM: трафик → предзаказы $99.9/мес → revenue.
|
||||
3. Фон: M-задача t-261003162607-aa8abf (HTML-план с ИИ-маскотами, результат
|
||||
обещан Alex в TG) возвращена в очередь — заберёт часовой тик.
|
||||
|
||||
## Эскалации
|
||||
|
||||
- **e-261003170542-389f29 (warn, hour→day) — закрыт.** Hour-тик упал на
|
||||
транзиентном «Request timed out» (стрим роутера умер в event_loop) при
|
||||
выполнении aa8abf. Причина инфраструктурная, не код: тик повторяется сам на
|
||||
следующем часу; задача requeue'd с сохранением delivery-await (Alex получит
|
||||
результат в TG автоматически). Пятый падёж за сутки на таймаутах —
|
||||
контрмеры уже в проде (tick_timeout, изолированный runner с killpg,
|
||||
авто-схлопывание failed>24ч), наблюдение продолжается.
|
||||
- Открытых эскалаций на конец тика: **0**.
|
||||
|
||||
## Сделано в тике: AUM v1 (t-261004000834-ed84f5, [L]) ✅
|
||||
|
||||
**AUM впервые измерен: 26.70 USD (было None).**
|
||||
|
||||
1. `docs/aum/balances.json` заведён по шаблону: OpenRouter кредиты коопа
|
||||
**26.70$** (API: total=163, usage=136.30), Stripe live available **0.0$**
|
||||
(livemode=True, available=0/pending=0 — presale-выручки ещё нет), LLC-счёт и
|
||||
крипто-кошелёк — `null` (ждут ручного ввода Alex; в total не входят — метрика
|
||||
честная, не раздутая).
|
||||
2. Авто-обновление балансов в `harness/aum.py`: `refresh_auto_balances()` —
|
||||
счета с `auto:true` обновляются из API перед каждым снапшотом `measure()`
|
||||
(включая дневной `AumMeasured`): OpenRouter `/api/v1/credits`, Stripe
|
||||
`/v1/balance`. Только read-only GET, ключи из локальных .env (в стор/логи не
|
||||
попадают), сетевая ошибка → остаётся прошлое значение (stale честнее
|
||||
выдуманного), запись атомарная (tmp+rename), выключатель
|
||||
`SIMBIOT_AUM_AUTO_REFRESH=0`.
|
||||
3. Защита от фальшивых денег: Stripe test-balance (livemode=False, pending
|
||||
$335.24) в AUM **не** актив — проверено обоими ключами; тест livemode=False
|
||||
→ None.
|
||||
4. `cli.py aum --record` записан: `policy.aum_target` обновлён — **цель в
|
||||
контексте каждого тика min/hour/day теперь с числом**: «AUM=26.7 у.е.». Приёмка
|
||||
из постановки выполнена: `cli.py aum` → 26.7, дневной `AumMeasured` завтра
|
||||
уйдёт в стор уже не с None.
|
||||
|
||||
Осталось для полного AUM (нужно Alex): суммы LLC-счёта, крипто-кошелька,
|
||||
9router prepay — заполнение null-строк поднимет total автоматически.
|
||||
|
||||
## Верификация системы
|
||||
|
||||
- Тесты репо: **17/17 файлов зелёные** (прямой запуск; unittest-discover по
|
||||
этим файлам неприменим — часть тестов скриптового стиля с sys.exit на уровне
|
||||
модуля, это не регрессия логики).
|
||||
- Аудит стора `cli.py verify`: 828 518 событий, подписи ed25519 + тампер-цепочка
|
||||
**валидны** (ключ faf8d19d9aed71fe).
|
||||
- Доска: открытых задач 1 (aa8abf, ready, M — на часовой), failed 0.
|
||||
- Коммит: часы-наработка (task list без клонов, tg_inbox env-fallback) + AUM v1
|
||||
вынесены в git.
|
||||
|
||||
## Метрики
|
||||
|
||||
- **AUM: 26.70 у.е.** (первый непустой замер; полный — после ввода Alex).
|
||||
- burn 9router: 24ч=1.64 · 7д=2291.81 у.е. Тренд благоприятный: 7д-окно
|
||||
снизилось с 2521.59 (манифест вчера) до ~2292 — экономия после переключения
|
||||
конфигураций держится.
|
||||
- Стикerness уровней: min/hour под гейтом тихо (критичных сигналов нет) —
|
||||
расходы на тики экономятся; day-тик выполнил манифест и закрыл L-задачу.
|
||||
|
||||
## На завтра
|
||||
|
||||
- Заполнение balances.json Alex'ом (LLC, крипта, 9router prepay) → полный AUM.
|
||||
- 4bbc6f: launch-kit → публикация с аккаунтов Alex (учесть 3-через-3), первые
|
||||
UTM-метрики → цикл «канал → клики → checkout».
|
||||
- Мониторить второй заход aa8abf на часовом (результат должен уйти в TG).
|
||||
117
harness/aum.py
117
harness/aum.py
|
|
@ -69,6 +69,111 @@ def read_balances():
|
|||
return {"total": None, "accounts": [], "src": "balances.json (нет файла)"}
|
||||
|
||||
|
||||
# ────────────────── авто-источники балансов (t-261004000834-ed84f5) ──────────────────
|
||||
# AUM не должен жить только на ручном вводе: что измеряется API — измеряем сами.
|
||||
# Принцип: read-only GET, ключи только из локальных .env (в сток/логи не попадают),
|
||||
# ошибка сети/ключа → старое значение остаётся (stale-факт честнее выдуманного).
|
||||
|
||||
AUTO_REFRESH_TIMEOUT = 15 # сек на API-запрос
|
||||
|
||||
|
||||
def _env_file_value(path: str, prefix: str):
|
||||
"""Значение VAR=… из .env-файла (первое вхождение) или None."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith(prefix + "="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'").strip() or None
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _http_json(url: str, headers: dict):
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=AUTO_REFRESH_TIMEOUT) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def refresh_openrouter_credits(env_path: str = None):
|
||||
"""Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка)."""
|
||||
env_path = env_path or os.path.join(HARNESS_ROOT, ".env")
|
||||
key = _env_file_value(env_path, "OPENROUTER_KEY")
|
||||
if not key:
|
||||
return None
|
||||
try:
|
||||
d = _http_json("https://openrouter.ai/api/v1/credits",
|
||||
{"Authorization": f"Bearer {key}"})
|
||||
inf = d.get("data", {})
|
||||
total, usage = inf.get("total_credits"), inf.get("total_usage")
|
||||
if total is None:
|
||||
return None
|
||||
return round(float(total) - float(usage or 0.0), 2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_stripe_available(coop_env: str = "/root/coop/.env"):
|
||||
"""Stripe live available USD или None. Только livemode=True: тестовые
|
||||
деньги (livemode=False) в AUM не актив — иначе метрика врёт."""
|
||||
key = _env_file_value(coop_env, "STRIPE_LIVE_KEY")
|
||||
if not key:
|
||||
return None
|
||||
try:
|
||||
d = _http_json("https://api.stripe.com/v1/balance",
|
||||
{"Authorization": f"Bearer {key}"})
|
||||
if not d.get("livemode"):
|
||||
return None
|
||||
for b in d.get("available", []):
|
||||
if b.get("currency") == "usd":
|
||||
return round(b["amount"] / 100.0, 2)
|
||||
return 0.0
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_auto_balances(path: str = None, coop_env: str = "/root/coop/.env"):
|
||||
"""Обновить accounts с auto:true в balances.json значениями из API.
|
||||
|
||||
Роутинг по имени счёта (openrouter → кредиты, stripe → live available).
|
||||
Файла нет / ни одно auto-поле не обновилось → ничего не пишем.
|
||||
Возвращает список имён обновлённых счетов."""
|
||||
path = path or os.environ.get("SIMBIOT_BALANCES_JSON", BALANCES_JSON)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
updated = []
|
||||
for acc in data.get("accounts", []):
|
||||
if not acc.get("auto"):
|
||||
continue
|
||||
name = (acc.get("name") or "").lower()
|
||||
val = None
|
||||
if "openrouter" in name:
|
||||
val = refresh_openrouter_credits()
|
||||
elif "stripe" in name:
|
||||
val = refresh_stripe_available(coop_env)
|
||||
if val is None:
|
||||
continue
|
||||
acc["amount_usd"] = val
|
||||
acc["measured_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
updated.append(acc.get("name"))
|
||||
if not updated:
|
||||
return []
|
||||
data["as_of"] = datetime.now(timezone.utc).date().isoformat()
|
||||
try:
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path) # атомарно: читатели не увидят полуфайл
|
||||
except Exception:
|
||||
return []
|
||||
return updated
|
||||
|
||||
|
||||
def read_router_spend(days: int = 7):
|
||||
"""Суточный расход 9router за N дней: {date: {cost, reqs}}. Ошибка → {}."""
|
||||
try:
|
||||
|
|
@ -99,7 +204,17 @@ def read_coop_structure():
|
|||
# ────────────────────────── замер ──────────────────────────
|
||||
|
||||
def measure():
|
||||
"""Снапшот метрики: AUM (если замерен) + burn 24ч/7д + структура кооператива."""
|
||||
"""Снапшот метрики: AUM (если замерен) + burn 24ч/7д + структура кооператива.
|
||||
|
||||
Авто-балансы (accounts c auto:true) перед замером обновляются из API
|
||||
(t-261004000834-ed84f5). SIMBIOT_AUM_AUTO_REFRESH=0 выключает (тесты/
|
||||
офлайн-режим); сетевая ошибка не валит снапшот — останется прошлое
|
||||
значение (stale честнее выдуманного)."""
|
||||
if os.environ.get("SIMBIOT_AUM_AUTO_REFRESH", "1") not in ("0", "false", "no"):
|
||||
try:
|
||||
refresh_auto_balances()
|
||||
except Exception:
|
||||
pass # обновление — best effort: замер burn важнее и идёт всегда
|
||||
bal = read_balances()
|
||||
spend = read_router_spend(days=7)
|
||||
days_iter = list(spend.values())
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ def _load_env():
|
|||
TG_TOKEN = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("SIMBIOT_TG_ALLOWED="):
|
||||
ALLOWED = {x.strip() for x in line.split("=", 1)[1].split(",") if x.strip()}
|
||||
_tok = os.environ.get("SIMBIOT_TG_TOKEN", "").strip() # systemd EnvironmentFile задаёт переменные, но не пишет .env
|
||||
if not TG_TOKEN and _tok:
|
||||
TG_TOKEN = _tok
|
||||
_allow = os.environ.get("SIMBIOT_TG_ALLOWED", "").strip()
|
||||
if TG_TOKEN and not ALLOWED and _allow:
|
||||
ALLOWED = {x.strip() for x in _allow.split(",") if x.strip()}
|
||||
|
||||
|
||||
def _api(method: str, params: dict, timeout: int = 35):
|
||||
|
|
|
|||
|
|
@ -127,6 +127,75 @@ r = subprocess.run(["/root/simbiot-harness/venv/bin/python", "/root/simbiot-harn
|
|||
capture_output=True, text=True, timeout=120, env=env)
|
||||
check("cli aum-pilot ok", r.returncode == 0 and "вердикт" in r.stdout, r.stderr[:200])
|
||||
|
||||
# ── 9. авто-обновление балансов из API (t-261004000834-ed84f5) — hermetic ──
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp(prefix="simbiot-aum-")
|
||||
|
||||
# 9a: файла нет → [] без падения
|
||||
check("refresh_auto: нет файла → []",
|
||||
aum.refresh_auto_balances(path="/nonexistent-balances.json") == [])
|
||||
|
||||
# 9b: auto-счёт, но ключей нет → значение не пишется, ошибок нет
|
||||
bal_nokey = os.path.join(tmpdir, "b1.json")
|
||||
with open(bal_nokey, "w") as f:
|
||||
json.dump({"accounts": [{"name": "Stripe live balance (available)",
|
||||
"amount_usd": 0.0, "auto": True}]}, f)
|
||||
_saved_efv = aum._env_file_value
|
||||
aum._env_file_value = lambda *a, **k: None # ключей нет
|
||||
try:
|
||||
got = aum.refresh_auto_balances(path=bal_nokey, coop_env=os.path.join(tmpdir, "no.env"))
|
||||
finally:
|
||||
aum._env_file_value = _saved_efv
|
||||
check("refresh_auto: без ключа → [] и файл не тронут",
|
||||
got == [] and json.load(open(bal_nokey))["accounts"][0]["amount_usd"] == 0.0)
|
||||
|
||||
# 9c: _http_json мокается → суммы пишутся, measured_at/as_of обновляются,
|
||||
# ручные счета не трогаются
|
||||
bal_mock = os.path.join(tmpdir, "b2.json")
|
||||
with open(bal_mock, "w") as f:
|
||||
json.dump({"accounts": [
|
||||
{"name": "OpenRouter кредиты", "amount_usd": None, "auto": True},
|
||||
{"name": "Stripe live balance", "amount_usd": None, "auto": True},
|
||||
{"name": "ручной счёт Alex", "amount_usd": 5.0}],
|
||||
"as_of": "2000-01-01"}, f)
|
||||
with open(os.path.join(tmpdir, "stripe.env"), "w") as f:
|
||||
f.write("STRIPE_LIVE_KEY=sk_test_fake\n")
|
||||
|
||||
|
||||
def _fake_http(url, headers):
|
||||
if "openrouter" in url:
|
||||
return {"data": {"total_credits": 100, "total_usage": 40}}
|
||||
return {"livemode": True, "available": [{"currency": "usd", "amount": 12345}]}
|
||||
|
||||
|
||||
_saved_http = aum._http_json
|
||||
aum._http_json = _fake_http
|
||||
try:
|
||||
got2 = aum.refresh_auto_balances(path=bal_mock,
|
||||
coop_env=os.path.join(tmpdir, "stripe.env"))
|
||||
finally:
|
||||
aum._http_json = _saved_http
|
||||
d2 = json.load(open(bal_mock))
|
||||
check("refresh_auto: mock API → 60.0 и 123.45 записаны",
|
||||
len(got2) == 2 and abs(d2["accounts"][0]["amount_usd"] - 60.0) < 0.001
|
||||
and abs(d2["accounts"][1]["amount_usd"] - 123.45) < 0.001,
|
||||
f"got2={got2} d2={d2}")
|
||||
check("refresh_auto: measured_at у auto, ручной не тронут, as_of обновлён",
|
||||
bool(d2["accounts"][0].get("measured_at"))
|
||||
and d2["accounts"][2]["amount_usd"] == 5.0 and d2["as_of"] != "2000-01-01")
|
||||
|
||||
# 9d: Stripe livemode=False → None: тестовые деньги в AUM не актив
|
||||
def _fake_http_testmode(url, headers):
|
||||
return {"livemode": False, "available": [{"currency": "usd", "amount": 33524}]}
|
||||
|
||||
|
||||
aum._http_json = _fake_http_testmode
|
||||
try:
|
||||
v_test = aum.refresh_stripe_available(coop_env=os.path.join(tmpdir, "stripe.env"))
|
||||
finally:
|
||||
aum._http_json = _saved_http
|
||||
check("stripe: livemode=False → None (не актив)", v_test is None)
|
||||
|
||||
print()
|
||||
print("ИТОГ:", "ВСЕ ОК" if not fails else f"ПРОВАЛЫ: {fails}")
|
||||
sys.exit(1 if fails else 0)
|
||||
Loading…
Reference in a new issue