simbiot-harness/scripts/cron-migrate/daily-submit.py

511 lines
No EOL
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""d-job runner перенесённых кронов Гермеса → Simbiot (t-261002222610).
Дизайн по итогам живого e2e 2026-10-03 (первый прогон):
воркер min-тика тратил бюджет на разведку файлов и закрывал задачу
бюджет-заглушкой («Начала с промпта»), которая ушла в канал портала.
Новая схема:
* runner сам выбирает тему дня (детерминированная ротация по
registry.themes, миграция last_theme/used из состояния Гермеса);
* runner инжектит ПОЛНЫЙ промпт джобы + тему прямо в details — воркер
ничего не читает с диска, только генерирует;
* класс M (est 30м) → берёт hour-уровень: бюджет шагов на порядок
больше, чем у min (S ≤ 5 минут — мало для сценария);
* quality gate: ответ ≥ min_chars И содержит required-маркер («ДЛЯ
РУЧНОЙ ЗАПИСИ» — формат промпта). Не прошёл → ретрай той же темы
(_r1, в details пометка «прошлая попытка — отписка»); вторая
неудача → в канал уходит ЧЕСТНОЕ «сценарий не готов» (не успех);
* доставка: sha-контроль (тот же ответ дважды не уходит), канал —
portal_channel.push (TG-чат инстанса Риты не подключён, канал
агента — портал); портал недоступен → outbox-файл (досыл flush'ем);
* тайминги прод-дня: таймер 05:55 подаёт → hour-тик 06:00 забирает
и генерит → деливери в течение ~15 мин; reconcile-вызов 07:05
идемпотентно дожимает недодоставленное; суточный вызов с
yesterday-веткой разруливает зависшее.
Коды возврата: 0 — сделано/доставлено или «уже сделано/не готов, дожмёт
reconcile» (не ошибка таймера), 2 — неудача после ретраев (failed-сервис
в journalctl — видимое внимание на утро).
"""
import argparse
import datetime
import hashlib
import json
import os
import sys
import time
REPO = os.environ.get("SIMBIOT_REPO", "/root/simbiot-harness")
REG_PATH = os.path.join(REPO, "scripts/cron-migrate/registry.json")
def load_registry(job_name):
with open(REG_PATH) as f:
reg = json.load(f)
for j in reg.get("jobs", []):
if j.get("name") == job_name:
return j
raise SystemExit("job not in registry: " + job_name)
def today_utc():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
def date_n(days):
return (datetime.datetime.now(datetime.timezone.utc) +
datetime.timedelta(days=days)).strftime("%Y-%m-%d")
def state_dir():
d = os.path.join(REPO, ".cron-state")
os.makedirs(d, exist_ok=True)
return d
def rot_path(job):
return os.path.join(state_dir(), job["name"] + "-rotation.json")
def deliv_path(job):
return os.path.join(state_dir(), job["name"] + "-delivered.json")
def pending_path(job):
return os.path.join(state_dir(), job["name"] + "-pending.json")
def read_pending_pick(job):
"""Тема дня на момент подачи (пишу в handle_day) — reconcile по ней
фиксирует ротацию, даже если доставка случилась в его вызове."""
try:
with open(pending_path(job)) as f:
p = json.load(f)
if p.get("date") == today_utc():
return {"num": p["num"], "title": p.get("title", ""),
"last_used": p.get("last_used", ""),
"note": p.get("note", "")}
except Exception:
pass
return None
def _atomic_write(path, obj):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def migrate_hermes_state(job):
"""Первый запуск: тяну ротацию тем из Гермеса (последняя тема не должна
повториться — переносим last_theme/used как есть)."""
sp = rot_path(job)
if os.path.exists(sp):
return sp
src = job.get("migrate_state_from", "")
if src and os.path.exists(src):
try:
with open(src) as f:
data = json.load(f)
_atomic_write(sp, {"migrated_from": src, **data})
print("rotation state migrated from hermes:", sp)
except Exception as e:
print("rotation migrate skipped:", e)
return sp
def _days_ago(dstr):
try:
return (datetime.datetime.now(datetime.timezone.utc).date() -
datetime.date.fromisoformat(dstr)).days
except Exception:
return 999
def pick_theme(job):
"""Тема сегодняшнего сценария: из тем, использованных ≥repeat_days дней
назад (или никогда), — та, что использована раньше всех; при равенстве —
минимальный номер. Не ПИШЕТ состояние."""
sp = migrate_hermes_state(job)
try:
with open(sp) as f:
st = json.load(f)
except Exception:
st = {}
themes = job.get("themes") or []
by_theme = {}
note_by_theme = {}
for u in (st.get("used") or []):
t = int(u.get("theme", 0))
by_theme[t] = u.get("date", "")
if u.get("note"):
note_by_theme[t] = u["note"]
cutoff = job.get("theme_repeat_days", 7)
cands = [i for i in range(1, len(themes) + 1)
if _days_ago(by_theme.get(i, "")) >= cutoff]
pool = cands or list(range(1, len(themes) + 1))
# Если ни одна тема не вышла из окна повтора — берём наименее свежую
# (макс. days_ago), НЕ свежую: min по days_ago повторял вчерашнюю тему
# (e2e 2026-10-03: тема 3 вышла 10-02 и снова 10-03).
n = min(pool, key=lambda i: (-_days_ago(by_theme.get(i, "")), i))
return {"num": n, "title": themes[n - 1],
"last_used": by_theme.get(n, "никогда"),
"note": note_by_theme.get(n, ""), "state_path": sp}
def record_theme_today(job):
"""Фиксация темы дня ПОСЛЕ качественного результата. Источник истины —
pending-файл (тема на момент подачи); запись (date→theme) идемпотентна:
повторные вызовы дня не плодят дубли и не добавляют чужих тем."""
sp = rot_path(job)
try:
with open(sp) as f:
st = json.load(f)
except Exception:
st = {}
used = st.get("used") or []
p = read_pending_pick(job)
today_e = [u for u in used if u.get("date") == today_utc()]
if today_e:
st["last_theme"] = today_e[-1]["theme"]
elif p is not None:
used.append({"date": today_utc(), "theme": p["num"],
"note": (p.get("note") or "")[:120]})
st["last_theme"] = p["num"]
else:
return # нет ни записи, ни pending — нечего фиксировать
st["date"] = today_utc()
st["used"] = used[-30:]
_atomic_write(sp, st)
def sha(txt):
return hashlib.sha256((txt or "").encode()).hexdigest()[:16]
def es_mod():
if REPO not in sys.path:
sys.path.insert(0, REPO)
from harness import es
return es
def completed_answer(es, task_id):
import sqlite3 as _sq
db = _sq.connect(es.DB_PATH)
row = db.execute(
"SELECT data FROM events WHERE aggregate_id=? AND event_type='TaskCompleted' "
"ORDER BY id DESC LIMIT 1", (task_id,)).fetchone()
db.close()
if not row:
return None
return json.loads(row[0]).get("answer") or ""
def completed_answers(es, task_id):
"""Все ответы TaskCompleted из ledger по порядку (старые→новые).
Проекция (read model) хранит последний answer: дайджест-закрытие тика
могло перетереть целевой ответ — поэтому quality-решения принимает
ledger-first (r2 e2e 2026-10-03: ответ сценария перезаписан digest'ом).
"""
import sqlite3 as _sq
db = _sq.connect(es.DB_PATH)
rows = db.execute(
"SELECT data FROM events WHERE aggregate_id=? AND event_type='TaskCompleted' "
"ORDER BY id", (task_id,)).fetchall()
db.close()
return [(json.loads(d).get("answer") or "") for _ts, d in rows
if (json.loads(d).get("answer") or "").strip()]
def best_completed_answer(es, job, task_id):
"""Лучший по quality-gate ответ из ledger; если ни один не качественный —
первый (чтобы честно провалу не изображать успех)."""
ans = completed_answers(es, task_id)
if not ans:
return None
ok = [a for a in ans if quality_ok(job, a)]
return (ok or ans)[0]
def task_status(es, task_id):
model = es.run_projections()
t = (model.get("tasks") or {}).get(task_id)
if not t:
return None
return {"status": t.get("status")}
def quality_ok(job, answer):
q = job.get("quality_gate") or {}
if len((answer or "").strip()) < q.get("min_chars", 700):
return False
return all(m in (answer or "") for m in (q.get("required") or []))
def deliver_text(job, answer):
return (job.get("deliver_header") or "✅ Дейли-сценарий (mentor.cil.by):") \
+ "\n\n" + (answer or "").strip()[:4000]
def problem_text(job, task_id):
return (job.get("problem_header") or
"⚠ Дейли-сценарий не готов: две попытки воркера вернули отписку "
"(бюджет/пустой ответ). Разбери задачу %s вручную." % task_id)
def was_delivered(job, task_id, answer):
"""sha-контроль: этот ответ уже уходил в канал (перманентная история
— delivery-файл не теряет старые записи, reconcile не пушист повтор)."""
try:
with open(deliv_path(job)) as f:
d = json.load(f)
return any(e.get("task_id") == task_id and e.get("sha") == sha(answer)
for e in (d.get("entries") or []))
except Exception:
return False
def mark_delivered(job, task_id, answer):
p = deliv_path(job)
try:
with open(p) as f:
d = json.load(f)
except Exception:
d = {}
entries = d.get("entries") or []
entries.append({"date": today_utc(), "task_id": task_id,
"sha": sha(answer), "ts": time.time()})
d["entries"] = entries[-40:]
_atomic_write(p, d)
def load_env_file():
"""oneshot не наследует ENV scheduler'а: SIMBIOT_AGENT_NAME нужен
portal_channel._cfg. Только SET (не перетираю окружение)."""
p = os.path.join(REPO, ".env")
if not os.path.exists(p):
return
with open(p) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
if k.strip() and k.strip() not in os.environ:
os.environ[k.strip()] = v.strip()
def push_portal(text):
try:
load_env_file()
from harness import portal_channel
return bool(portal_channel.push("portal:Rita", text, None))
except Exception as e:
print("portal push failed:", e)
return False
def deliver(job, task_id, answer):
if not (answer or "").strip():
return False
if was_delivered(job, task_id, answer):
print("already delivered (same sha):", task_id)
return True
ok = push_portal(deliver_text(job, answer))
if not ok: # портал недоступен → outbox-файл (досыл flush'ем харнеса)
mid = "cron-%d" % int(time.time() * 1000)
od = os.path.join(REPO, "portal/inbox/outbox")
os.makedirs(od, exist_ok=True)
with open(os.path.join(od, mid + ".json"), "w") as f:
json.dump({"id": mid, "text": deliver_text(job, answer)[:8000],
"ts": int(time.time() * 1000)}, f, ensure_ascii=False)
print("portal down → outbox file", mid)
mark_delivered(job, task_id, answer)
print("delivered:", task_id)
return True
def submit(es, job, details, task_id):
es.append_event("task", task_id, "TaskSubmitted", {
"id": task_id, "title": job["title"], "details": details,
"size": job.get("size", "M"), "est_minutes": job.get("est", 30),
"source": "d-job:" + job["name"],
})
print("submitted:", task_id)
def prompt_text(job):
p = os.path.join(REPO, "scripts/cron-migrate/jobs", job["name"] + ".prompt.md")
with open(p) as f:
return f.read().strip()
RETRY_NOTE = ("⚠ ПРЕДЫДУЩАЯ ПОПЫТКА ЭТОЙ ЖЕ ТЕМЫ ВЕРНУЛА ОТПИСКУ О БЮДЖЕТЕ "
"ШАГОВ — она засчитана как НЕГОТОВЫЙ результат. Не веди "
"разведку файлов/логов/истории: весь бюджет вложи в написание "
"сценария по тексту выше и сразу завершайся результатом.")
def build_details(job, pick, retry=False):
head = [prompt_text(job)]
head.append("ДАТА ВЫПУСКА: %s." % today_utc())
head.append("ТЕКУЩАЯ ТЕМА ДНЯ: №%d — %s." % (pick["num"], pick["title"]))
head.append("В прошлый раз тема %s выходила %s (%s). Сегодня придумай НОВЫЙ "
"угол, прежние формулировки не повторяй."
% (pick["num"], pick["last_used"], pick["note"] or "без заметки"))
if retry:
head.append(RETRY_NOTE)
head.append("\nФАЙЛЫ НЕ ЧИТАЙ — нечего: весь необходимый контент выше. "
"Готовый сценарий — ЦЕЛИКОМ в ответе задачи (answer), до 40 строк, "
"маркер «ДЛЯ РУЧНОЙ ЗАПИСИ» обязателен.")
return "\n".join(head)
def attempt(es, job, task_id, pick, retry, deadline_ts):
"""Одна попытка (по уже определённой теме). Возвращает ('done'|...,
answer). Если задача уже существует (ready/running от прошлого вызова
таймера) — НЕ пересабмичиваю (двойной TaskSubmitted перетёр бы
проекцию), просто жду/читаю статус."""
st = task_status(es, task_id)
if st and st["status"] == "done":
return "done", best_completed_answer(es, job, task_id) or ""
if st and st["status"] == "failed":
return "failed", ""
if not st or st["status"] not in ("ready", "running"):
submit(es, job, build_details(job, pick, retry), task_id)
else:
print("already in progress:", task_id, st["status"])
while time.time() < deadline_ts:
time.sleep(25)
st = task_status(es, task_id)
if st and st["status"] == "done":
return "done", best_completed_answer(es, job, task_id) or ""
if st and st["status"] == "failed":
return "failed", ""
return "wait", ""
def handle_day(es, job, base, deadline_ts):
"""День: тема одна, до двух попыток, quality-gate, доставка/честный провал."""
pick = pick_theme(job)
_atomic_write(pending_path(job),
{"date": today_utc(), "num": pick["num"],
"title": pick["title"], "last_used": pick["last_used"],
"note": pick["note"]})
verdict, answer = attempt(es, job, base, pick, False, deadline_ts)
if verdict != "done" or not quality_ok(job, answer):
if verdict == "done":
print("first attempt quality FAIL (%d chars) → retry _r1"
% len(answer or ""))
verdict, answer = attempt(es, job, base + "_r1", pick, True, deadline_ts)
if verdict == "done" and quality_ok(job, answer):
deliver(job, base, answer)
record_theme_today(job)
return 0
if verdict == "done":
push_portal(problem_text(job, base))
record_theme_today(job) # день состоялся (тема занята), качество — нет
print("PROBLEM: two stub answers for", base)
return 2
if verdict == "failed":
push_portal(problem_text(job, base))
print("PROBLEM: worker failed for", base)
return 2
print("submitted/in progress — reconcile дожмёт:", base)
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--job", required=True)
ap.add_argument("--timeout", type=int, default=1200, metavar="SEC")
ap.add_argument("--reconcile", action="store_true",
help="короткий вызов: только дожим готового")
args = ap.parse_args()
job = load_registry(args.job)
base = "_cron_%s_%s" % (args.job, today_utc().replace("-", ""))
es = es_mod()
if args.reconcile:
st = task_status(es, base)
if st and st["status"] == "done":
a = best_completed_answer(es, job, base) or ""
if quality_ok(job, a):
if was_delivered(job, base, a):
record_theme_today(job)
print("reconcile: already delivered, ok")
return 0
deliver(job, base, a)
record_theme_today(job)
return 0
# done-заглушка: один ретрай _r1 (тема из pending-файла)
pick = read_pending_pick(job) or pick_theme(job)
verdict, a2 = attempt(es, job, base + "_r1", pick, True, time.time() + 900)
if verdict == "done" and quality_ok(job, a2):
deliver(job, base, a2)
record_theme_today(job)
return 0
if verdict == "done":
push_portal(problem_text(job, base))
record_theme_today(job)
print("reconcile PROBLEM: stub again", base)
return 2
print("reconcile: _r1 ещё в работе/ждёт — не трогаю")
return 0
if st and st["status"] in ("ready", "running"):
print("reconcile: задача ещё в работе — ок")
return 0
# задачи нет/failed — ничего не подаю (подача только у 05:55-таймера)
print("reconcile: задача дня отсутствует или failed — не подаю")
return 0
job = load_registry(args.job)
base = "_cron_%s_%s" % (args.job, today_utc().replace("-", ""))
es = es_mod()
# ── вчера: разбор зависшего/недоставленного (владельцу-тику сутки — он умер) ──
y_base = "_cron_%s_%s" % (args.job, date_n(-1).replace("-", ""))
yst = task_status(es, y_base)
if yst and yst["status"] == "running":
from harness import commands as _cmds
try:
_cmds.requeue_task(y_base, "reconcile d-job: тик-владелец умер >24ч")
print("yesterday requeued:", y_base)
except Exception as e:
print("yesterday requeue skipped:", e)
elif yst and yst["status"] == "done":
ya = completed_answer(es, y_base)
if ya and quality_ok(job, ya):
try:
y_mark = None
with open(deliv_path(job)) as f:
y_mark = json.load(f)
if y_mark.get("date") != date_n(-1):
deliver(job, y_base, ya) # вчера не доставлено — досылаю
except Exception:
pass
# ── сегодня: идемпотентные короткие выходы ──
st = task_status(es, base)
if st and st["status"] == "done":
a = best_completed_answer(es, job, base) or ""
if quality_ok(job, a):
# тема дня уже записана (запись сделана в handle_day) — не перетираю
if was_delivered(job, base, a):
record_theme_today(job)
return 0
deliver(job, base, a)
record_theme_today(job)
return 0
# done-заглушка (легаси-прогон старого runner'а) — ретрай ниже
return handle_day(es, job, base, time.time() + max(60, args.timeout))
if __name__ == "__main__":
sys.exit(main())