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:
parent
933c55bb35
commit
4bf5ef9d77
9 changed files with 947 additions and 7 deletions
|
|
@ -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:<agent>) · ответ агент пишет сам по такту</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>
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
484
scripts/cron-migrate/daily-submit.py
Normal file
484
scripts/cron-migrate/daily-submit.py
Normal 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())
|
||||
|
|
@ -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 строк итога.
|
||||
41
scripts/cron-migrate/registry.json
Normal file
41
scripts/cron-migrate/registry.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
18
scripts/cron-migrate/simbiot-cron-daily-video.service
Normal file
18
scripts/cron-migrate/simbiot-cron-daily-video.service
Normal 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
|
||||
13
scripts/cron-migrate/simbiot-cron-daily-video.timer
Normal file
13
scripts/cron-migrate/simbiot-cron-daily-video.timer
Normal 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
|
||||
Loading…
Reference in a new issue