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{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-send:disabled{opacity:.45;cursor:default}
|
||||||
.chat-note{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim);margin:8px 2px 0}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -112,6 +129,7 @@
|
||||||
<div class="tabs" role="tablist">
|
<div class="tabs" role="tablist">
|
||||||
<button class="tab active" id="tab-dash" role="tab" aria-selected="true" onclick="setView('dash')">✓ Дашборд</button>
|
<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-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>
|
||||||
|
|
||||||
<div class="errbox" id="errbox"></div>
|
<div class="errbox" id="errbox"></div>
|
||||||
|
|
@ -148,6 +166,51 @@
|
||||||
<div class="chat-note" id="chat-note">канал: портал (portal:<agent>) · ответ агент пишет сам по такту</div>
|
<div class="chat-note" id="chat-note">канал: портал (portal:<agent>) · ответ агент пишет сам по такту</div>
|
||||||
</div><!-- /view-chat -->
|
</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>
|
<footer>
|
||||||
JSON-API: <code>/api/state</code> · healthcheck: <code>/api/health</code> ·
|
JSON-API: <code>/api/state</code> · healthcheck: <code>/api/health</code> ·
|
||||||
источник — event store (CQRS/ES, проекция на лету) ·
|
источник — event store (CQRS/ES, проекция на лету) ·
|
||||||
|
|
@ -253,7 +316,7 @@ function render(s){
|
||||||
// лестница
|
// лестница
|
||||||
$("ladder").innerHTML = s.ladder.length ? `<table>
|
$("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>
|
<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 class="m"><b>${esc(l.level)}</b></td>
|
||||||
<td>${l.paused ? '<span class="pill failed">paused</span>'
|
<td>${l.paused ? '<span class="pill failed">paused</span>'
|
||||||
: l.busy ? '<span class="pill running">busy</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));
|
document.querySelectorAll(".view").forEach(s => s.classList.toggle("active", s.id === "view-" + v));
|
||||||
try { localStorage.simbiotView = v; } catch(e){}
|
try { localStorage.simbiotView = v; } catch(e){}
|
||||||
if (v === "chat") { chatTick(); } // первый рендер сразу, без ожидания таймера
|
if (v === "chat") { chatTick(); }
|
||||||
|
if (v === "agent") { if (!agentLevel) agentLevel = "min"; startAgentMonitor(agentLevel); }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// deep-link: ?view=chat|dash (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
|
// deep-link: ?view=chat|dash|agent&level=min (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
|
||||||
const q = new URLSearchParams(location.search).get("view");
|
const _qp = new URLSearchParams(location.search);
|
||||||
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : null);
|
const q = _qp.get("view");
|
||||||
|
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : (q === "agent" ? "agent" : null));
|
||||||
const v0 = ql || localStorage.simbiotView;
|
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){}
|
} 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; // сек: канал тикает по сек-рефлексу, чаще нет смысла
|
const CHAT_REFRESH = 10; // сек: канал тикает по сек-рефлексу, чаще нет смысла
|
||||||
let chatTimer = null;
|
let chatTimer = null;
|
||||||
|
|
@ -401,6 +475,106 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
chatTimer = setInterval(chatTick, CHAT_REFRESH * 1000);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "SimbiotDash/1.0"
|
server_version = "SimbiotDash/1.2"
|
||||||
|
|
||||||
def log_message(self, fmt, *args): # тише в journalctl: одна строка
|
def log_message(self, fmt, *args): # тише в journalctl: одна строка
|
||||||
print(f"[dash] {self.address_string()} {fmt % args}", flush=True)
|
print(f"[dash] {self.address_string()} {fmt % args}", flush=True)
|
||||||
|
|
@ -373,6 +533,18 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._json({"ok": False, "error": str(e)[:500],
|
return self._json({"ok": False, "error": str(e)[:500],
|
||||||
"messages": []}, code=503)
|
"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":
|
if route == "/api/health":
|
||||||
return self._json({"ok": True, "ts": _utcnow()})
|
return self._json({"ok": True, "ts": _utcnow()})
|
||||||
if route in ("/", "/index.html"):
|
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