cron-migrate r2 fixes (t-261002222610): scheduler guard против дайджест-перезаписи уже закрытых задач; runner ledger-first (best_completed_answer: projection могла быть перетёрта digest-закрытием); generation-only промпт (легаси-ротацию из промпта убрал — теперь только runner ведёт темы); дата выпуска в details

This commit is contained in:
Simbiot Harness 2026-10-03 01:08:02 +00:00
parent 4bf5ef9d77
commit 98511453d7
3 changed files with 56 additions and 14 deletions

View file

@ -535,7 +535,22 @@ def run_level_tick(level_cfg, seq: int, model: dict):
from .commands import looks_like_budget_stub from .commands import looks_like_budget_stub
if (text or "") and looks_like_budget_stub(text): if (text or "") and looks_like_budget_stub(text):
return _guard_budget_stub_requeue(claimed, text) return _guard_budget_stub_requeue(claimed, text)
# guard двойного закрытия (r2 e2e cron-migrate 2026-10-03): воркер мог
# ЗАКРЫТЬ задачу сам (cli complete) ещё до батч-фазы — его TaskCompleted
# содержит целевой ответ; повторный append поверх перезаписал бы answer
# дайджестом тика и испортил бы проверенный результат (перезапись
# «последний-победил» в проекции). Закрываем только ещё-running.
_model = es.run_projections()
_done = {tid for tid in (claimed or [])
if ((_model.get("tasks") or {}).get(tid) or {}).get("status") == "done"}
_first = True
for tid in (claimed or []): for tid in (claimed or []):
if tid in _done:
if _first:
_log("skip батч-догзакрытие готовых задач: "
+ ", ".join(sorted(_done)))
_first = False
continue
es.append_event("task", tid, "TaskCompleted", { es.append_event("task", tid, "TaskCompleted", {
"result_ref": trace_id, "answer": (text or "")[:2000], "result_ref": trace_id, "answer": (text or "")[:2000],
}) })

View file

@ -147,7 +147,10 @@ def pick_theme(job):
cands = [i for i in range(1, len(themes) + 1) cands = [i for i in range(1, len(themes) + 1)
if _days_ago(by_theme.get(i, "")) >= cutoff] if _days_ago(by_theme.get(i, "")) >= cutoff]
pool = cands or list(range(1, len(themes) + 1)) pool = cands or list(range(1, len(themes) + 1))
n = min(pool, key=lambda i: (_days_ago(by_theme.get(i, "")), i)) # Если ни одна тема не вышла из окна повтора — берём наименее свежую
# (макс. 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], return {"num": n, "title": themes[n - 1],
"last_used": by_theme.get(n, "никогда"), "last_used": by_theme.get(n, "никогда"),
"note": note_by_theme.get(n, ""), "state_path": sp} "note": note_by_theme.get(n, ""), "state_path": sp}
@ -202,16 +205,39 @@ def completed_answer(es, task_id):
return json.loads(row[0]).get("answer") or "" 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): def task_status(es, task_id):
model = es.run_projections() model = es.run_projections()
t = (model.get("tasks") or {}).get(task_id) t = (model.get("tasks") or {}).get(task_id)
if not t: if not t:
return None return None
res = t.get("result") or "" return {"status": t.get("status")}
if t.get("status") == "done":
a = completed_answer(es, task_id)
res = a if a is not None else res
return {"status": t.get("status"), "answer": res}
def quality_ok(job, answer): def quality_ok(job, answer):
@ -326,7 +352,8 @@ RETRY_NOTE = ("⚠ ПРЕДЫДУЩАЯ ПОПЫТКА ЭТОЙ ЖЕ ТЕМЫ В
def build_details(job, pick, retry=False): def build_details(job, pick, retry=False):
head = [prompt_text(job)] head = [prompt_text(job)]
head.append("\nТЕКУЩАЯ ТЕМА ДНЯ: №%d — %s." % (pick["num"], pick["title"])) head.append("ДАТА ВЫПУСКА: %s." % today_utc())
head.append("ТЕКУЩАЯ ТЕМА ДНЯ: №%d — %s." % (pick["num"], pick["title"]))
head.append("В прошлый раз тема %s выходила %s (%s). Сегодня придумай НОВЫЙ " head.append("В прошлый раз тема %s выходила %s (%s). Сегодня придумай НОВЫЙ "
"угол, прежние формулировки не повторяй." "угол, прежние формулировки не повторяй."
% (pick["num"], pick["last_used"], pick["note"] or "без заметки")) % (pick["num"], pick["last_used"], pick["note"] or "без заметки"))
@ -345,7 +372,7 @@ def attempt(es, job, task_id, pick, retry, deadline_ts):
проекцию), просто жду/читаю статус.""" проекцию), просто жду/читаю статус."""
st = task_status(es, task_id) st = task_status(es, task_id)
if st and st["status"] == "done": if st and st["status"] == "done":
return "done", st["answer"] return "done", best_completed_answer(es, job, task_id) or ""
if st and st["status"] == "failed": if st and st["status"] == "failed":
return "failed", "" return "failed", ""
if not st or st["status"] not in ("ready", "running"): if not st or st["status"] not in ("ready", "running"):
@ -356,7 +383,7 @@ def attempt(es, job, task_id, pick, retry, deadline_ts):
time.sleep(25) time.sleep(25)
st = task_status(es, task_id) st = task_status(es, task_id)
if st and st["status"] == "done": if st and st["status"] == "done":
return "done", st["answer"] return "done", best_completed_answer(es, job, task_id) or ""
if st and st["status"] == "failed": if st and st["status"] == "failed":
return "failed", "" return "failed", ""
return "wait", "" return "wait", ""
@ -407,7 +434,7 @@ def main():
if args.reconcile: if args.reconcile:
st = task_status(es, base) st = task_status(es, base)
if st and st["status"] == "done": if st and st["status"] == "done":
a = st["answer"] a = best_completed_answer(es, job, base) or ""
if quality_ok(job, a): if quality_ok(job, a):
if was_delivered(job, base, a): if was_delivered(job, base, a):
record_theme_today(job) record_theme_today(job)
@ -466,7 +493,7 @@ def main():
# ── сегодня: идемпотентные короткие выходы ── # ── сегодня: идемпотентные короткие выходы ──
st = task_status(es, base) st = task_status(es, base)
if st and st["status"] == "done": if st and st["status"] == "done":
a = st["answer"] a = best_completed_answer(es, job, base) or ""
if quality_ok(job, a): if quality_ok(job, a):
# тема дня уже записана (запись сделана в handle_day) — не перетираю # тема дня уже записана (запись сделана в handle_day) — не перетираю
if was_delivered(job, base, a): if was_delivered(job, base, a):

View file

@ -1,4 +1,4 @@
Ты — Рита, агент студии Shkor Labs. Задача: каждое утро (сегодня понедельник-пятница, 09:00 Минск = 06:00 UTC) сгенерировать ОДИН сценарий короткого вертикального видео (60-90 сек, Instagram Reels / TikTok / Shorts) для привлечения людей на БЕСПЛАТНЫЙ 20-минутный разбор стартапа от Алекса Шкора. Страница: https://mentor.cil.by — бесплатный разбор, слоты ежедневно 18:00-22:00 по Минску, апсейл — менторство $999/мес. Позиционирование Алекса: строит продукты с 2010, co-founder Paralect и DEIP, Shkor Labs («42 Labs»), 50+ созданных технологий, AI-агенты. Ты — Рита, агент студии Shkor Labs. Задача: сгенерировать ОДИН сценарий короткого вертикального видео (60-90 сек, Instagram Reels / TikTok / Shorts) для привлечения людей на БЕСПЛАТНЫЙ 20-минутный разбор стартапа от Алекса Шкора. Страница: https://mentor.cil.by — бесплатный разбор, слоты ежедневно 18:00-22:00 по Минску, апсейл — менторство $999/мес. Позиционирование Алекса: строит продукты с 2010, co-founder Paralect и DEIP, Shkor Labs («42 Labs»), 50+ созданных технологий, AI-агенты.
Формат сценария (строго): Формат сценария (строго):
1. ХУК (первые 3 сек) — провокационный или неожиданный тезис, без клише («вин-вин», «пушечная идея» — запрещено). Без эмодзи. 1. ХУК (первые 3 сек) — провокационный или неожиданный тезис, без клише («вин-вин», «пушечная идея» — запрещено). Без эмодзи.
@ -7,6 +7,6 @@
4. CTA — «Запишись на бесплатный 20-минутный разбор — слоты каждый день с 18 до 22, mentor.cil.by». Одной фразой. 4. CTA — «Запишись на бесплатный 20-минутный разбор — слоты каждый день с 18 до 22, mentor.cil.by». Одной фразой.
5. ТЕКСТ ПОД ВИДЕО (2-3 строки) + 5 хэштегов. 5. ТЕКСТ ПОД ВИДЕО (2-3 строки) + 5 хэштегов.
Правила: разговорный живой русский, короткие рубленые фразы, никаких «друзья, сегодня поговорим о». Каждый день — НОВАЯ тема (ротация: 1) разбор ошибок фаундеров с ИИ, 2) кейс студии (что-то реальное: Totem-маскоты, видеоконвейер, DEIP-биржа, Simbiot-агент), 3) контр-интуитивный тезис о внедрении ИИ, 4) ответ на частый вопрос, 5) разбор типового сценария «как ИИ даёт деньги в <ниша>»). Темы не должны повторяться минимум 7 дней — хранить последнюю тему в файле ~/.hermes/cron/output/video-script-state.json и читать её. Правила: разговорный живой русский, короткие рубленые фраза, никаких «друзья, сегодня поговорим о». Тему и дату выпуска берём ИСКЛЮЧИТЕЛЬНО из блока ниже (ТЕКУЩАЯ ТЕМА ДНЯ / ДАТА) — никакие файлы не читаем, состояние ведёт runner.
В конце вывести блок ДЛЯ РУЧНОЙ ЗАПИСИ: дата, тема, 3-5 пунктов что показать на экране (b-roll/текст на экране). Не более 40 строк итога. В конце вывести блок ДЛЯ РУЧНОЙ ЗАПИСИ: дата, тема, 3-5 пунктов что показать на экране (b-roll/текст на экране). Не более 40 строк итога. Готовый сценарий — целиком в answer задачи: никаких «сделал, записал в файл», никаких отчётов о процессе — только сам сценарий.