day: agent-details (t-261002175316, /api/agent + view-agent, hour-сеанс) + cron-migrate v2 (t-261002222610): d-job runner (inline-промпт+ротация тем, M→hour, quality-gate, ретрай _r1, sha-деливери) + systemd таймеры на Rita; e2e r1 прошёл, r2 в работе

This commit is contained in:
Simbiot Harness 2026-10-03 00:35:03 +00:00
parent 933c55bb35
commit 4bf5ef9d77
9 changed files with 947 additions and 7 deletions

View file

@ -92,6 +92,23 @@
.chat-send{border:1px solid var(--ink);background:var(--signal);color:#fff;font-weight:700;font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:0 20px;cursor:pointer;border-radius:6px}
.chat-send:disabled{opacity:.45;cursor:default}
.chat-note{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim);margin:8px 2px 0}
/* ── детали агента (t-261002175316-eb24cc) ── */
.back{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;
border:1px solid var(--ink);background:transparent;color:var(--dim);padding:8px 14px;cursor:pointer;border-radius:6px}
.back:hover{background:var(--ink);color:var(--paper)}
.agenthead{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:6px 0 4px}
.agenthead h2{margin:0;border:0;padding:0}
tr.agentrow{cursor:pointer}
tr.agentrow:hover td{background:#fff3ea}
.bars{display:flex;align-items:flex-end;gap:3px;height:70px;border:1px solid var(--line);background:var(--card);padding:10px 10px 4px}
.bars div{flex:1;background:var(--signal);opacity:.8;min-width:2px;border-radius:2px 2px 0 0}
.bars-x{display:flex;gap:3px;font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--dim);padding:2px 10px 0}
.bars-x span{flex:1;text-align:left;overflow:visible;white-space:nowrap}
.tickgrid{display:flex;flex-wrap:wrap;gap:5px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px}
.tickgrid span{border:1px solid var(--line);padding:2px 6px;border-radius:4px;color:var(--dim);background:var(--card)}
.erritem{border:1px solid var(--line);border-left:3px solid var(--err);background:#fdf6f4;padding:8px 12px;margin:6px 0;font-size:12.5px}
.erritem .h{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--dim);margin-bottom:3px}
.erritem pre{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;margin:0}
</style>
</head>
<body>
@ -112,6 +129,7 @@
<div class="tabs" role="tablist">
<button class="tab active" id="tab-dash" role="tab" aria-selected="true" onclick="setView('dash')">✓ Дашборд</button>
<button class="tab" id="tab-chat" role="tab" aria-selected="false" onclick="setView('chat')">💬 Чат</button>
<button class="tab" id="tab-agent" role="tab" aria-selected="false" onclick="setView('agent')">🔍 Агент</button>
</div>
<div class="errbox" id="errbox"></div>
@ -148,6 +166,51 @@
<div class="chat-note" id="chat-note">канал: портал (portal:&lt;agent&gt;) · ответ агент пишет сам по такту</div>
</div><!-- /view-chat -->
<div class="view" id="view-agent">
<button class="back" onclick="setView('dash')">← обратно к дашборду</button>
<div class="agenthead">
<h2 id="ag-title">агент</h2>
<span id="ag-status"></span>
<span class="m dim" id="ag-updated"></span>
</div>
<div class="stats" id="ag-stats"></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-top:14px">
<div>
<div class="lead" style="margin-left:0">тики уровня по часам суток (24ч, UTC)</div>
<div class="bars" id="ag-hours"></div>
<div class="bars-x"><span>00</span><span style="text-align:left">06</span><span>12</span><span style="text-align:left">18</span><span style="text-align:right">23</span></div>
</div>
<div>
<div class="lead" style="margin-left:0">последние LLM-вызовы (prompt+completion токены)</div>
<div class="bars" id="ag-spend"></div>
<div class="bars-x"><span>-30</span><span style="text-align:left">-20</span><span style="text-align:left">-10</span><span style="text-align:right">последний</span></div>
</div>
</div>
<h2>Задачи уровня <span class="m dim" id="ag-taskcount"></span></h2>
<div class="lead">все задачи, когда-либо взятые этим уровнем · последние 30</div>
<table id="ag-tasks"><tr><td class="skel">загружаю…</td></tr></table>
<h2>Свежие дайджесты уровня</h2>
<div class="lead">выжимки тиков только этого агента — последние 10</div>
<div id="ag-digests"><div class="skel">загружаю…</div></div>
<h2>Открытые эскалации уровня <span class="m dim" id="ag-esccount"></span></h2>
<div id="ag-esc"><div class="skel">загружаю…</div></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;margin-top:26px">
<div>
<h2 style="margin-top:0">Последние ошибки <span class="m dim" id="ag-errcount"></span></h2>
<div id="ag-errors"></div>
</div>
<div>
<h2 style="margin-top:0">Лента тиков <span class="m dim" id="ag-tickcount"></span></h2>
<div class="tickgrid" id="ag-ticks"></div>
</div>
</div>
</div><!-- /view-agent -->
<footer>
JSON-API: <code>/api/state</code> · healthcheck: <code>/api/health</code> ·
источник — event store (CQRS/ES, проекция на лету) ·
@ -253,7 +316,7 @@ function render(s){
// лестница
$("ladder").innerHTML = s.ladder.length ? `<table>
<tr><th>уровень</th><th>статус</th><th class="m">тики</th><th class="m">absorbed</th><th class="m">ошибки</th><th>последний тик</th></tr>
${s.ladder.map(l => `<tr>
${s.ladder.map(l => `<tr class="agentrow" title="открыть детали агента ${esc(l.level)}" onclick="openAgent('${esc(l.level)}')">
<td class="m"><b>${esc(l.level)}</b></td>
<td>${l.paused ? '<span class="pill failed">paused</span>'
: l.busy ? '<span class="pill running">busy</span>'
@ -316,15 +379,26 @@ function setView(v){
});
document.querySelectorAll(".view").forEach(s => s.classList.toggle("active", s.id === "view-" + v));
try { localStorage.simbiotView = v; } catch(e){}
if (v === "chat") { chatTick(); } // первый рендер сразу, без ожидания таймера
if (v === "chat") { chatTick(); }
if (v === "agent") { if (!agentLevel) agentLevel = "min"; startAgentMonitor(agentLevel); }
}
try {
// deep-link: ?view=chat|dash (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
const q = new URLSearchParams(location.search).get("view");
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : null);
// deep-link: ?view=chat|dash|agent&level=min (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
const _qp = new URLSearchParams(location.search);
const q = _qp.get("view");
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : (q === "agent" ? "agent" : null));
const v0 = ql || localStorage.simbiotView;
if (v0 === "chat") setView("chat");
if (v0 === "agent") {
const hl = _qp.get("level") || ((location.hash.match(/^#agent=([a-z_]+)$/) || [])[1]);
if (hl) agentLevel = hl;
setView("agent");
} else if (v0 === "chat") setView("chat");
} catch(e){}
//.hash = #agent=<level> — клик по уровню в лестнице или прямая ссылка
window.addEventListener("hashchange", () => {
const m = (decodeURIComponent(location.hash).match(/^#agent=([a-z_]+)$/) || [])[1];
if (m && getViewName() === "agent") { startAgentMonitor(m); }
});
const CHAT_REFRESH = 10; // сек: канал тикает по сек-рефлексу, чаще нет смысла
let chatTimer = null;
@ -401,6 +475,106 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
chatTimer = setInterval(chatTick, CHAT_REFRESH * 1000);
// ── детали агента (t-261002175316-eb24cc) ───────────────────────────────
var agentLevel = null;
var agentTimer = null;
function barChart(el, data, fmt){
const max = Math.max(1, ...data.map(d => d.v));
el.innerHTML = data.map(d =>
`<div style="height:${Math.max(2, Math.round(d.v / max * 100))}%" title="${esc(d.t)}"></div>`).join("");
}
function fmtDur(sec){
if (sec == null) return "—";
sec = Math.round(sec);
if (sec < 60) return sec + "с";
if (sec < 3600) return Math.round(sec / 60) + "м";
return (sec / 3600).toFixed(1) + "ч";
}
function agentTick(){
if (!agentLevel) return;
fetch("api/agent?level=" + encodeURIComponent(agentLevel) + "&t=" + Date.now(), {cache: "no-store"})
.then(r => { if(!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(d => {
if (d.error) throw new Error(d.error);
$("errbox").style.display = "none";
const a = d.agent;
$("ag-title").textContent = "агент · " + a.level;
$("ag-status").innerHTML = a.paused ? '<span class="pill failed">paused</span>'
: a.busy ? '<span class="pill running">busy</span>'
: '<span class="pill ready">ok</span>' +
(a.current_tasks.length ? ` <span class="pill working"> 🔧 ${esc(a.current_tasks[0].title.slice(0,34))} · ${esc(fmtDur(a.current_tasks[0].duration_sec))}</span>` : "");
$("ag-updated").textContent = (d.generated_at || "").slice(11, 19) + " UTC";
$("ag-stats").innerHTML = [
[a.ticks_24h, "тиков 24ч"], [a.absorbed_24h, "поглощено 24ч"],
[a.errors_24h, "ошибок 24ч"], [fmtDur(a.time_in_work_24h_sec), "в работе 24ч"],
[a.ticks_7d, "тиков 7д"], [a.errors_7d, "ошибок 7д"],
[a.llm_24h.calls, "LLM-вызовы 24ч"], [(a.llm_24h.prompt/1e6).toFixed(1)+"M", "prompt 24ч"],
[(a.llm_24h.completion/1e3).toFixed(0)+"k", "completion 24ч"],
].map(([n, l]) => `<div class="stat"><div class="n">${esc(String(n))}</div><div class="l">${esc(l)}</div></div>`).join("");
// часы: 24 ведра, ноль — прозрачным
const hm = {}; (d.hourly || []).forEach(x => hm[x.h] = x.c);
const hours = []; for (let h = 0; h < 24; h++) {
const k = String(h).padStart(2, "0"), v = hm[k] || 0;
hours.push({v, t: k + ":00 UTC — " + v + " тиков"});
}
barChart($("ag-hours"), hours);
const spends = (d.spend_rows || []).map(r =>
({v: r.prompt + r.completion, t: (r.trace_id || "?") + " · " + r.prompt + "pt + " + r.completion + "ct"}));
barChart($("ag-spend"), spends);
const tasks = d.tasks || [];
$("ag-taskcount").textContent = `(${tasks.length})`;
$("ag-tasks").innerHTML = tasks.length ? tasks.map(t => `<tr>
<td class="m dim">${esc(t.id)}</td>
<td><b>${esc(t.title)}</b>${t.error ? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,110))}</span>` : ""}</td>
<td>${pill(t.status)}</td>
<td class="m">${esc(t.claimed_at ? fmtTime(t.claimed_at) : "—")}</td>
<td class="m dim">${t.finished ? esc(fmtTime(t.finished)) : "—"}</td>
<td class="m">${fmtDur(t.duration_sec)}</td>
</tr>`).join("") : `<tr><td class="skel">этот уровень задач пока не брал</td></tr>`;
$("ag-digests").innerHTML = (d.digests || []).length
? d.digests.map(x => `<div class="digest"><div class="h">${esc(x.period || "")} · ${esc(fmtTime(x.ts))} · ref ${esc(x.summary_ref || "—")}</div><p>${esc((x.text || "").slice(0, 400))}</p></div>`).join("")
: `<div class="skel">дайджестов уровня нет</div>`;
const esc5 = d.escalations_open || [];
$("ag-esccount").textContent = `(${esc5.length})`;
$("ag-esc").innerHTML = esc5.length
? `<table><tr><th>id</th><th>sev</th><th>маршрут</th><th>причина</th><th>поднята</th></tr>` +
esc5.map(e => `<tr><td class="m dim">${esc(e.id)}</td><td><span class="pill sev-${esc(e.severity || "warn")}">${esc(e.severity || "?")}</span></td>
<td class="m">${esc(e.from_level || "?")} → ${esc(e.to_level || "?")}</td><td>${esc(e.reason || "")}</td>
<td class="m dim">${esc(fmtTime(e.ts))}</td></tr>`).join("") + `</table>`
: `<div class="skel">открытых эскалаций уровня нет</div>`;
const errs = d.recent_errors || [];
$("ag-errcount").textContent = `(${errs.length})`;
$("ag-errors").innerHTML = errs.length
? errs.map(e => `<div class="erritem"><div class="h">${esc(fmtTime(e.ts))}</div><pre>${esc(e.error)}</pre></div>`).join("")
: `<div class="skel">ошибок за 7д нет</div>`;
$("ag-tickcount").textContent = `(${(d.recent_ticks || []).length})`;
$("ag-ticks").innerHTML = (d.recent_ticks || []).length
? d.recent_ticks.map(t => `<span title="${esc(t.ts)}">${esc((t.seq || "").split(":").slice(1).join(":"))}</span>`).join("")
: `<div class="skel">тиков не найдено</div>`;
})
.catch(e => {
const box = $("errbox");
box.style.display = "block";
box.textContent = "агент " + agentLevel + " недоступен: " + e.message + " — попробую снова на следующем тике";
});
}
function startAgentMonitor(level){
agentLevel = level;
if (agentTimer) { clearInterval(agentTimer); agentTimer = null; }
agentTick();
agentTimer = setInterval(agentTick, REFRESH * 1000);
}
function openAgent(level){
try { location.hash = "agent=" + encodeURIComponent(level); } catch(e){}
startAgentMonitor(level);
setView("agent");
}
</script>
</body>
</html>

View file

@ -306,8 +306,168 @@ def build_state() -> dict:
}
def build_agent(level: str) -> dict:
"""Детали одного агента-уровня (t-261002175316-eb24cc).
Read-only проекция «на лету»: состояние уровня — из run_projections()
(консистентно со scheduler/CLI), история тиков/ошибок/спенда — прямыми
read-only запросами к стору (проверено: 64–210мс на 643к событий).
"""
import re as _re
import sqlite3 as _sq
from datetime import datetime, timedelta, timezone
lvl = (level or "").strip().lower()
if not _re.fullmatch(r"[a-z_]{1,24}", lvl):
return {"error": "bad level: %r" % (level or "")}
model = es.run_projections()
cs = (model.get("cycle_state") or {}).get(lvl) or {}
def _q(sql, args=()):
db = _sq.connect(es.DB_PATH, timeout=10)
try:
return db.execute(sql, args).fetchall()
finally:
db.close()
now = datetime.now(timezone.utc)
c24 = (now - timedelta(hours=24)).isoformat()
c7 = (now - timedelta(days=7)).isoformat()
# ── счётчики 24ч/7д из стора (тихи, ошибки, спенд) ──
def _count(etype, cutoff):
return _q(
"SELECT COUNT(*) FROM events WHERE event_type=? "
"AND timestamp>=? AND json_extract(data,'$.level')=?",
(etype, cutoff, lvl))[0][0]
ticks_24, ticks_7 = _count("CycleTicked", c24), _count("CycleTicked", c7)
errors_24, errors_7 = _count("CycleError", c24), _count("CycleError", c7)
absorbed_24 = _count("TickAbsorbed", c24)
absorbed_7 = _count("TickAbsorbed", c7)
def _tokens(cutoff):
row = _q(
"SELECT COUNT(*), COALESCE(SUM(json_extract(data,'$.prompt_tokens')),0), "
"COALESCE(SUM(json_extract(data,'$.completion_tokens')),0) "
"FROM events WHERE event_type='BudgetSpent' AND timestamp>=? "
"AND json_extract(data,'$.level')=?", (cutoff, lvl))[0]
return {"calls": row[0], "prompt": row[1], "completion": row[2]}
tok24, tok7 = _tokens(c24), _tokens(c7)
# ── активность по часам суток (24 ведра, тики уровня за 24ч) ──
hourly = [{"h": h, "c": c} for h, c in _q(
"SELECT strftime('%H', timestamp) h, COUNT(*) c FROM events "
"WHERE event_type='CycleTicked' AND timestamp>=? "
"AND json_extract(data,'$.level')=? GROUP BY h ORDER BY h",
(c24, lvl))]
# ── последние тики и ошибки уровня (сырьё из стора, капы жёсткие) ──
recent_ticks = [{"ts": ts, "seq": aid} for aid, ts in _q(
"SELECT aggregate_id, timestamp FROM events "
"WHERE event_type='CycleTicked' AND json_extract(data,'$.level')=? "
"ORDER BY id DESC LIMIT 40", (lvl,))]
last_err_text = None
recent_errors = []
for ts, err in _q(
"SELECT timestamp, json_extract(data,'$.error') FROM events "
"WHERE event_type='CycleError' AND timestamp>=? "
"AND json_extract(data,'$.level')=? ORDER BY id DESC LIMIT 8", (c7, lvl)):
err = (err or "")[:300]
recent_errors.append({"ts": ts, "error": err})
if last_err_text is None:
last_err_text = err
# ── LLM-вызовы уровня: последние 30 по токенам (график спенда) ──
spend_rows = [{"trace_id": t, "prompt": p, "completion": cc}
for t, p, cc in _q(
"SELECT json_extract(data,'$.trace_id'), "
"json_extract(data,'$.prompt_tokens'), "
"json_extract(data,'$.completion_tokens') FROM events "
"WHERE event_type='BudgetSpent' AND json_extract(data,'$.level')=? "
"ORDER BY id DESC LIMIT 30", (lvl,))]
spend_rows.reverse()
# ── задачи уровня (из проекции) + время в работе ──
lvl_tasks = []
time_in_work_24 = 0.0
for t in model.get("tasks", {}).values():
if t.get("level") != lvl:
continue
st, cl = t.get("status"), t.get("claimed_at")
dur = None
if cl:
try:
beg = datetime.fromisoformat(cl)
end = datetime.fromisoformat(t["finished"]) if t.get("finished") else now
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if beg.tzinfo is None:
beg = beg.replace(tzinfo=timezone.utc)
dur = max(0.0, (end - beg).total_seconds())
if cl >= c24:
time_in_work_24 += dur
except (ValueError, TypeError, KeyError):
dur = None
lvl_tasks.append({
"id": t.get("id"), "title": (t.get("title") or "")[:120],
"size": t.get("size"), "status": st,
"created": t.get("created"), "claimed_at": cl,
"finished": t.get("finished"), "duration_sec": dur,
"error": t.get("error"), "promoted_to": t.get("promoted_to"),
})
lvl_tasks.sort(key=lambda x: x.get("claimed_at") or x.get("created") or "",
reverse=True)
current = [{"id": t["id"], "title": t["title"], "claimed_at": t["claimed_at"],
"duration_sec": t["duration_sec"]}
for t in lvl_tasks if t["status"] == "running"]
# ── эскалации, задевающие уровень ──
esc = [{"id": e.get("id"), "severity": e.get("severity"),
"reason": (e.get("reason") or "")[:200],
"from_level": e.get("from_level"), "to_level": e.get("to_level"),
"ts": e.get("ts")}
for e in (model.get("escalations") or {}).values()
if e.get("status") == "open"
and (e.get("from_level") == lvl or e.get("to_level") == lvl)]
esc.sort(key=lambda e: e.get("ts") or "", reverse=True)
# ── дайджесты именно этого уровня ──
digests = [{"period": d.get("period"), "text": (d.get("text") or "")[:600],
"ts": d.get("ts"), "summary_ref": d.get("summary_ref")}
for d in (model.get("digests") or {}).values()
if d.get("level") == lvl]
digests.sort(key=lambda d: d.get("ts") or "", reverse=True)
return {
"generated_at": _utcnow(),
"agent": {
"level": lvl,
"busy": bool(cs.get("busy")), "paused": bool(cs.get("paused")),
"ticks": cs.get("ticks", 0), "absorbed": cs.get("absorbed", 0),
"errors": cs.get("errors", 0),
"last_tick": cs.get("last_tick"), "last_error": last_err_text,
"ticks_24h": ticks_24, "absorbed_24h": absorbed_24,
"errors_24h": errors_24,
"ticks_7d": ticks_7, "absorbed_7d": absorbed_7, "errors_7d": errors_7,
"llm_24h": tok24, "llm_7d": tok7,
"time_in_work_24h_sec": round(time_in_work_24),
"current_tasks": current,
},
"tasks": lvl_tasks[:30],
"digests": digests[:10],
"escalations_open": esc,
"recent_ticks": recent_ticks,
"recent_errors": recent_errors,
"hourly": hourly,
"spend_rows": spend_rows,
"meta": {"token_required": bool(_TOKEN)},
}
class _Handler(BaseHTTPRequestHandler):
server_version = "SimbiotDash/1.0"
server_version = "SimbiotDash/1.2"
def log_message(self, fmt, *args): # тише в journalctl: одна строка
print(f"[dash] {self.address_string()} {fmt % args}", flush=True)
@ -373,6 +533,18 @@ class _Handler(BaseHTTPRequestHandler):
except Exception as e:
return self._json({"ok": False, "error": str(e)[:500],
"messages": []}, code=503)
if route == "/api/agent":
# детали агента-уровня (t-261002175316-eb24cc): read-only,
# ?level=sec|min|hour|day (+ токен/cookie как у /api/state)
if not self._authorized():
return self._forbidden()
qs = self.path.partition("?")[2]
from urllib.parse import parse_qs as _pqs
lvl = (_pqs(qs).get("level") or [""])[0]
try:
return self._json(build_agent(lvl))
except Exception as e:
return self._json({"error": str(e)[:500]}, code=503)
if route == "/api/health":
return self._json({"ok": True, "ts": _utcnow()})
if route in ("/", "/index.html"):

View file

@ -0,0 +1,484 @@
#!/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))
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 task_status(es, task_id):
model = es.run_projections()
t = (model.get("tasks") or {}).get(task_id)
if not t:
return None
res = t.get("result") or ""
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):
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("\nТЕКУЩАЯ ТЕМА ДНЯ: №%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", st["answer"]
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", st["answer"]
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 = st["answer"]
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 = st["answer"]
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())

View file

@ -0,0 +1,12 @@
Ты — Рита, агент студии 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-агенты.
Формат сценария (строго):
1. ХУК (первые 3 сек) — провокационный или неожиданный тезис, без клише («вин-вин», «пушечная идея» — запрещено). Без эмодзи.
2. РАЗВОРОТ (15-30 сек) — 2-3 тезиса, конкретика: цифры, примеры из реальной практики студии (AI-агенты вместо рутины, конвейер контента, DEIP, кооператив — можно опираться на реальные факты, без выдумок).
3. ИЗВИВ (поворот) — что делать не надо / частая ошибка.
4. CTA — «Запишись на бесплатный 20-минутный разбор — слоты каждый день с 18 до 22, mentor.cil.by». Одной фразой.
5. ТЕКСТ ПОД ВИДЕО (2-3 строки) + 5 хэштегов.
Правила: разговорный живой русский, короткие рубленые фразы, никаких «друзья, сегодня поговорим о». Каждый день — НОВАЯ тема (ротация: 1) разбор ошибок фаундеров с ИИ, 2) кейс студии (что-то реальное: Totem-маскоты, видеоконвейер, DEIP-биржа, Simbiot-агент), 3) контр-интуитивный тезис о внедрении ИИ, 4) ответ на частый вопрос, 5) разбор типового сценария «как ИИ даёт деньги в <ниша>»). Темы не должны повторяться минимум 7 дней — хранить последнюю тему в файле ~/.hermes/cron/output/video-script-state.json и читать её.
В конце вывести блок ДЛЯ РУЧНОЙ ЗАПИСИ: дата, тема, 3-5 пунктов что показать на экране (b-roll/текст на экране). Не более 40 строк итога.

View file

@ -0,0 +1,41 @@
{
"_comment": "Перенос кронов Гермеса агента Риты → Simbiot (t-261002222610). Селективно: только содержательные джобы. Watchdog/пинги/каденции-дубли харнеса НЕ переносятся — эквиваленты уже в рантайме харнеса (см. not_transferred). Дизайн с учётом e2e 2026-10-03: воркер тратил бюджет на разведку и закрывал задачу отпиской → промпт+тема инжектятся в details, класс M (hour-бюджет), quality-gate, ретрай _r1, sha-контроль доставки.",
"source": "hermes-rita:/root/simbiot-harness/hermes-migration/cron-map.json (снят 2026-10-03)",
"not_transferred": [
{"name": "pulse-1m", "why": "сторож здоровья без LLM — эквивалент sec-рефлекса харнеса (уже живёт в Simbiot)"},
{"name": "reflex-10m", "why": "событийный триаж — эквивалент min-тика харнеса (кооп-канал портала уже подключён)"},
{"name": "hourly", "why": "страховочный проход событий + тактика — эквивалент hour-тика; дубли бы в два рта"},
{"name": "daily", "why": "утренний брифинг — заменён day-ритмом харнеса (дайджест дня формируется штатно)"},
{"name": "weekly", "why": "стратегический слой — уровень week в roadmap v0.2 README; до его появления дайджесты покрывают"},
{"name": "monthly", "why": "аналогично weekly — месяц/квартал/год в roadmap v0.2"},
{"name": "quarterly", "why": "см. monthly"},
{"name": "annual", "why": "см. monthly"},
{"name": "totem-watchdog", "why": "[SILENT] шум: 124 символа, пинг health-endpoint Totem; systemd-служба Totem живёт со своим Restart=always"}
],
"jobs": [
{
"name": "daily-video-script-mentor",
"title": "Дейли-сценарий короткого видео (mentor.cil.by) — сгенерировать по формату",
"size": "M",
"est": 30,
"hermes_cron": "0 6 * * * (06:00 UTC = 09:00 Минск; таймер подаёт 05:55, reconcile 07:05)",
"runner": "scripts/cron-migrate/daily-submit.py --job daily-video-script-mentor",
"migrate_state_from": "/home/hermes/.hermes/cron/output/video-script-state.json",
"theme_repeat_days": 7,
"themes": [
"разбор ошибок фаундеров с ИИ",
"кейс студии (реальное: Totem-маскоты, видеоконвейер, DEIP-биржа, Simbiot-агент)",
"контр-интуитивный тезис о внедрении ИИ",
"ответ на частый вопрос",
"разбор «как ИИ даёт деньги в <ниша>»"
],
"quality_gate": {
"min_chars": 700,
"required": ["ДЛЯ РУЧНОЙ ЗАПИСИ"]
},
"deliver_header": "🎬 Дейли-сценарий видео (mentor.cil.by) — Simbiot перенял у Гермеса:",
"problem_header": "⚠ Дейли-сценарий не готов: две попытки воркера вернули отписку (бюджет/пустой ответ). Разбор: задача _cron_daily-video-script-mentor_<день>.",
"details_template": "(детали собирает runner: полный промпт + тема дня; см. daily-submit.py build_details)"
}
]
}

View file

@ -0,0 +1,12 @@
# Перенесённый крон Гермеса Риты → Simbiot (t-261002222610)
# Reconcile-вызов daily-submit.py --reconcile — дожим доставку результата.
[Unit]
Description=Simbiot cron-migrate: daily video script reconcile service
[Service]
Type=oneshot
Environment=PYTHONUNBUFFERED=1
ExecStart=/root/simbiot-harness/venv/bin/python /root/simbiot-harness/scripts/cron-migrate/daily-submit.py --job daily-video-script-mentor --reconcile
User=root
WorkingDirectory=/root/simbiot-harness
SyslogIdentifier=simbiot-cron-migrate

View file

@ -0,0 +1,14 @@
# Перенесённый крон Гермеса Риты → Simbiot (t-261002222610)
# daily-video-script-mentor (hermes 0 6 * * *) — reconcile через час:
# дожимает доставку результата, если min-тик не закрыл задачу за поллинг.
[Unit]
Description=Simbiot cron-migrate: daily video script reconcile (07:05 UTC)
[Timer]
OnCalendar=*-*-* 07:05:00 UTC
Persistent=true
AccuracySec=120
Unit=simbiot-cron-daily-video-reconcile.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,18 @@
# Перенесённый крон Гермеса Риты → Simbiot (t-261002222610)
# daily-video-script-mentor: 0 6 * * * (06:00 UTC = 09:00 Минск)
# Подача задачи на доску симбиот-агента Риты — таймером в 05:55 UTC,
# результат min-тик закроет и d-job достанет в канал портала.
[Unit]
Description=Simbiot cron-migrate: daily video script (mentor.cil.by) submit
Wants=network-online.target
After=network-online.target simbiot-harness.service
[Service]
Type=oneshot
# без LLM — только подача/поллинг; таймаут 25 мин, дальше заберёт min-тик,
# а результат доставит портал-канал (см. --timeout 1500)
Environment=PYTHONUNBUFFERED=1
ExecStart=/root/simbiot-harness/venv/bin/python /root/simbiot-harness/scripts/cron-migrate/daily-submit.py --job daily-video-script-mentor --timeout 1500
User=root
WorkingDirectory=/root/simbiot-harness
SyslogIdentifier=simbiot-cron-migrate

View file

@ -0,0 +1,13 @@
# Перенесённый крон Гермеса Риты → Simbiot (t-261002222610)
# Дейли-подача сценария видео: 05:55 UTC (дедлайн результата — 09:00 Минск)
[Unit]
Description=Simbiot cron-migrate: daily video script timer (05:55 UTC)
[Timer]
OnCalendar=*-*-* 05:55:00 UTC
Persistent=true
AccuracySec=60
Unit=simbiot-cron-daily-video.service
[Install]
WantedBy=timers.target