mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
Add background jobs with Chat UI so long agent scripts can run for hours safely.
start_job/get_job/sleep replace blocked run_command for multi-hour work; Chat shows a live count badge and allows manual kill, with a reaper reclaiming timed-out or stale processes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dbba8cafbf
commit
0cf6aafa30
10 changed files with 1817 additions and 1 deletions
|
|
@ -2320,6 +2320,72 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@chat.get("/jobs")
|
||||||
|
def api_chat_list_jobs(
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_assistant_store()
|
||||||
|
resolve_auth(store, authorization)
|
||||||
|
from svc.jobs.background_jobs import get_job_store
|
||||||
|
|
||||||
|
return get_job_store().list_jobs(limit=limit)
|
||||||
|
|
||||||
|
@chat.get("/jobs/{job_id}")
|
||||||
|
def api_chat_get_job(
|
||||||
|
job_id: str,
|
||||||
|
log_tail_chars: int = Query(default=4000, ge=200, le=50000),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_assistant_store()
|
||||||
|
resolve_auth(store, authorization)
|
||||||
|
from svc.jobs.background_jobs import get_job_store
|
||||||
|
|
||||||
|
out = get_job_store().get(job_id, log_tail_chars=log_tail_chars)
|
||||||
|
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@chat.post("/jobs/{job_id}/cancel")
|
||||||
|
def api_chat_cancel_job(
|
||||||
|
job_id: str,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_assistant_store()
|
||||||
|
resolve_auth(store, authorization)
|
||||||
|
from svc.jobs.background_jobs import get_job_store
|
||||||
|
|
||||||
|
out = get_job_store().cancel(job_id)
|
||||||
|
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@chat.post("/jobs/cancel-running")
|
||||||
|
def api_chat_cancel_all_running_jobs(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_assistant_store()
|
||||||
|
resolve_auth(store, authorization)
|
||||||
|
from svc.jobs.background_jobs import get_job_store
|
||||||
|
|
||||||
|
return get_job_store().cancel_all_running()
|
||||||
|
|
||||||
|
@chat.delete("/jobs/{job_id}")
|
||||||
|
def api_chat_purge_job(
|
||||||
|
job_id: str,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_assistant_store()
|
||||||
|
resolve_auth(store, authorization)
|
||||||
|
from svc.jobs.background_jobs import get_job_store
|
||||||
|
|
||||||
|
out = get_job_store().purge(job_id)
|
||||||
|
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
if not out.get("ok") and out.get("error") == "job_still_running":
|
||||||
|
raise HTTPException(status_code=409, detail="job_still_running")
|
||||||
|
return out
|
||||||
|
|
||||||
router.include_router(chat)
|
router.include_router(chat)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,114 @@
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--ds-text, #e8e8e8);
|
color: var(--ds-text, #e8e8e8);
|
||||||
}
|
}
|
||||||
|
.chat-nav__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.chat-nav__jobs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.chat-nav__jobs:hover {
|
||||||
|
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
.chat-nav__jobs--active {
|
||||||
|
border-color: rgba(245, 158, 11, 0.55);
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
.chat-jobs-badge {
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.chat-jobs-badge--idle {
|
||||||
|
background: rgba(148, 163, 184, 0.45);
|
||||||
|
}
|
||||||
|
.chat-jobs-badge--hot {
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
.chat-jobs-row {
|
||||||
|
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.chat-jobs-row--running {
|
||||||
|
border-color: rgba(245, 158, 11, 0.45);
|
||||||
|
}
|
||||||
|
.chat-jobs-row__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.chat-jobs-row__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.chat-jobs-row__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.chat-jobs-row__cmd {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.chat-jobs-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.chat-jobs-pill--running {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
.chat-jobs-pill--succeeded {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
.chat-jobs-pill--failed,
|
||||||
|
.chat-jobs-pill--timeout,
|
||||||
|
.chat-jobs-pill--cancelled {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
.chat-jobs-log {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
/* First paint before chat.js runs (desktop / slow network): avoid empty #app “black hole”. */
|
/* First paint before chat.js runs (desktop / slow network): avoid empty #app “black hole”. */
|
||||||
.chat-boot-splash {
|
.chat-boot-splash {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,24 @@ const I18N = {
|
||||||
"chat.fork": "复制会话",
|
"chat.fork": "复制会话",
|
||||||
"chat.audit": "审计与追踪",
|
"chat.audit": "审计与追踪",
|
||||||
"chat.myProfile": "设置",
|
"chat.myProfile": "设置",
|
||||||
|
"chat.jobs": "后台任务",
|
||||||
|
"chat.jobsTitle": "后台任务",
|
||||||
|
"chat.jobsEmpty": "暂无后台任务",
|
||||||
|
"chat.jobsRefresh": "刷新",
|
||||||
|
"chat.jobsKill": "终止",
|
||||||
|
"chat.jobsKillConfirm": "确认终止该任务?进程将被强制结束。",
|
||||||
|
"chat.jobsKillAll": "全部终止",
|
||||||
|
"chat.jobsKillAllConfirm": "确认终止全部运行中的任务?",
|
||||||
|
"chat.jobsPurge": "清除记录",
|
||||||
|
"chat.jobsPurgeConfirm": "清除该已结束任务的本地记录与日志?",
|
||||||
|
"chat.jobsClose": "关闭",
|
||||||
|
"chat.jobsRunning": "运行中 {n}",
|
||||||
|
"chat.jobsHint": "Agent 启动的长任务会列在这里;对话结束后进程仍可能继续。可手动终止以防风险。",
|
||||||
|
"chat.jobsStatus.running": "运行中",
|
||||||
|
"chat.jobsStatus.succeeded": "成功",
|
||||||
|
"chat.jobsStatus.failed": "失败",
|
||||||
|
"chat.jobsStatus.timeout": "超时",
|
||||||
|
"chat.jobsStatus.cancelled": "已取消",
|
||||||
"chat.attach": "附件",
|
"chat.attach": "附件",
|
||||||
"chat.tools": "推理",
|
"chat.tools": "推理",
|
||||||
"chat.tools.hidden": "推理已隐藏",
|
"chat.tools.hidden": "推理已隐藏",
|
||||||
|
|
@ -214,6 +232,24 @@ const I18N = {
|
||||||
"chat.fork": "Duplicate chat",
|
"chat.fork": "Duplicate chat",
|
||||||
"chat.audit": "Audit & trace",
|
"chat.audit": "Audit & trace",
|
||||||
"chat.myProfile": "Settings",
|
"chat.myProfile": "Settings",
|
||||||
|
"chat.jobs": "Background jobs",
|
||||||
|
"chat.jobsTitle": "Background jobs",
|
||||||
|
"chat.jobsEmpty": "No background jobs",
|
||||||
|
"chat.jobsRefresh": "Refresh",
|
||||||
|
"chat.jobsKill": "Kill",
|
||||||
|
"chat.jobsKillConfirm": "Kill this job? The process will be force-terminated.",
|
||||||
|
"chat.jobsKillAll": "Kill all running",
|
||||||
|
"chat.jobsKillAllConfirm": "Kill all running jobs?",
|
||||||
|
"chat.jobsPurge": "Purge",
|
||||||
|
"chat.jobsPurgeConfirm": "Remove local records/logs for this finished job?",
|
||||||
|
"chat.jobsClose": "Close",
|
||||||
|
"chat.jobsRunning": "{n} running",
|
||||||
|
"chat.jobsHint": "Long agent jobs appear here and may keep running after the chat turn ends. Kill them manually if needed.",
|
||||||
|
"chat.jobsStatus.running": "running",
|
||||||
|
"chat.jobsStatus.succeeded": "succeeded",
|
||||||
|
"chat.jobsStatus.failed": "failed",
|
||||||
|
"chat.jobsStatus.timeout": "timeout",
|
||||||
|
"chat.jobsStatus.cancelled": "cancelled",
|
||||||
"chat.attach": "Attach",
|
"chat.attach": "Attach",
|
||||||
"chat.tools": "Reasoning",
|
"chat.tools": "Reasoning",
|
||||||
"chat.tools.hidden": "Reasoning hidden",
|
"chat.tools.hidden": "Reasoning hidden",
|
||||||
|
|
@ -892,6 +928,257 @@ function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _jobsPanelPollTimer = null;
|
||||||
|
let _jobsBadgePollTimer = null;
|
||||||
|
let _jobsBadgeEl = null;
|
||||||
|
let _jobsBtnLabelEl = null;
|
||||||
|
|
||||||
|
function _jobStatusLabel(status) {
|
||||||
|
const s = String(status || "").trim().toLowerCase();
|
||||||
|
const key = `chat.jobsStatus.${s}`;
|
||||||
|
const labeled = t(key);
|
||||||
|
return labeled === key ? s || "-" : labeled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtJobTs(ts) {
|
||||||
|
const n = Number(ts || 0);
|
||||||
|
if (!n) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(n * 1000).toLocaleString(currentLang === "zh" ? "zh-CN" : "en-US");
|
||||||
|
} catch (_) {
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJobsBadge(runningCount) {
|
||||||
|
const n = Math.max(0, parseInt(String(runningCount || 0), 10) || 0);
|
||||||
|
if (_jobsBtnLabelEl) {
|
||||||
|
_jobsBtnLabelEl.textContent = t("chat.jobs");
|
||||||
|
}
|
||||||
|
if (!_jobsBadgeEl) return;
|
||||||
|
_jobsBadgeEl.textContent = String(n);
|
||||||
|
_jobsBadgeEl.hidden = false;
|
||||||
|
_jobsBadgeEl.setAttribute("aria-label", t("chat.jobsRunning", { n }));
|
||||||
|
if (n > 0) {
|
||||||
|
_jobsBadgeEl.classList.add("chat-jobs-badge--hot");
|
||||||
|
_jobsBadgeEl.classList.remove("chat-jobs-badge--idle");
|
||||||
|
} else {
|
||||||
|
_jobsBadgeEl.classList.remove("chat-jobs-badge--hot");
|
||||||
|
_jobsBadgeEl.classList.add("chat-jobs-badge--idle");
|
||||||
|
}
|
||||||
|
const btn = _jobsBadgeEl.closest && _jobsBadgeEl.closest(".chat-nav__jobs");
|
||||||
|
if (btn) {
|
||||||
|
if (n > 0) btn.classList.add("chat-nav__jobs--active");
|
||||||
|
else btn.classList.remove("chat-nav__jobs--active");
|
||||||
|
btn.title = t("chat.jobsRunning", { n });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshJobsBadge() {
|
||||||
|
try {
|
||||||
|
const r = await apiGet("/admin/api/chat/jobs?limit=50");
|
||||||
|
updateJobsBadge(r && r.running_count);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startJobsBadgePoller() {
|
||||||
|
stopJobsBadgePoller();
|
||||||
|
refreshJobsBadge();
|
||||||
|
// Keep sidebar count fresh without opening the panel.
|
||||||
|
_jobsBadgePollTimer = setInterval(refreshJobsBadge, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopJobsBadgePoller() {
|
||||||
|
if (_jobsBadgePollTimer) {
|
||||||
|
clearInterval(_jobsBadgePollTimer);
|
||||||
|
_jobsBadgePollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBackgroundJobsPanel() {
|
||||||
|
document.querySelectorAll(".chat-jobs-backdrop").forEach((n) => n.remove());
|
||||||
|
if (_jobsPanelPollTimer) {
|
||||||
|
clearInterval(_jobsPanelPollTimer);
|
||||||
|
_jobsPanelPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = el("div", {
|
||||||
|
class: "chat-confirm-backdrop chat-jobs-backdrop",
|
||||||
|
style: "z-index:9998;",
|
||||||
|
});
|
||||||
|
const card = el("div", {
|
||||||
|
class: "chat-confirm-card chat-jobs-card",
|
||||||
|
style: "max-width:920px;width:94vw;max-height:82vh;display:flex;flex-direction:column;gap:10px;",
|
||||||
|
});
|
||||||
|
const title = el("div", { class: "card__title", text: t("chat.jobsTitle") });
|
||||||
|
const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsClose") });
|
||||||
|
const refreshBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsRefresh") });
|
||||||
|
const killAllBtn = el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--danger",
|
||||||
|
text: t("chat.jobsKillAll"),
|
||||||
|
});
|
||||||
|
const head = el("div", { class: "row", style: "gap:8px;justify-content:space-between;align-items:center;flex-wrap:wrap;" }, [
|
||||||
|
title,
|
||||||
|
el("div", { class: "row", style: "gap:8px;" }, [refreshBtn, killAllBtn, closeBtn]),
|
||||||
|
]);
|
||||||
|
const hint = el("div", { class: "muted", style: "font-size:12px;line-height:1.45;", text: t("chat.jobsHint") });
|
||||||
|
const summary = el("div", { class: "muted", style: "font-size:12px;" });
|
||||||
|
const list = el("div", {
|
||||||
|
class: "chat-jobs-list",
|
||||||
|
style: "overflow:auto;flex:1;min-height:220px;max-height:58vh;display:flex;flex-direction:column;gap:8px;",
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (_jobsPanelPollTimer) {
|
||||||
|
clearInterval(_jobsPanelPollTimer);
|
||||||
|
_jobsPanelPollTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
backdrop.remove();
|
||||||
|
} catch (_) {}
|
||||||
|
refreshJobsBadge();
|
||||||
|
};
|
||||||
|
closeBtn.addEventListener("click", close);
|
||||||
|
backdrop.addEventListener("click", (ev) => {
|
||||||
|
if (ev.target === backdrop) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const paint = async () => {
|
||||||
|
list.innerHTML = "";
|
||||||
|
list.appendChild(el("div", { class: "muted", text: t("chat.loading") }));
|
||||||
|
try {
|
||||||
|
const r = await apiGet("/admin/api/chat/jobs?limit=50");
|
||||||
|
const jobs = Array.isArray(r && r.jobs) ? r.jobs : [];
|
||||||
|
const running = Number((r && r.running_count) || 0);
|
||||||
|
updateJobsBadge(running);
|
||||||
|
summary.textContent = t("chat.jobsRunning", { n: running }) + ` · total ${Number((r && r.total) || jobs.length)}`;
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (!jobs.length) {
|
||||||
|
list.appendChild(el("div", { class: "muted", text: t("chat.jobsEmpty") }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const job of jobs) {
|
||||||
|
const status = String(job.status || "");
|
||||||
|
const runningJob = status === "running";
|
||||||
|
const row = el("div", { class: `chat-jobs-row chat-jobs-row--${status || "unknown"}` });
|
||||||
|
const top = el("div", { class: "chat-jobs-row__top" }, [
|
||||||
|
el("span", { class: "chat-jobs-row__name", text: String(job.name || job.job_id || "-") }),
|
||||||
|
el("span", { class: `chat-jobs-pill chat-jobs-pill--${status}`, text: _jobStatusLabel(status) }),
|
||||||
|
]);
|
||||||
|
const meta = el(
|
||||||
|
"div",
|
||||||
|
{ class: "chat-jobs-row__meta muted" },
|
||||||
|
[
|
||||||
|
el("div", { text: `id: ${job.job_id || "-"}` }),
|
||||||
|
el("div", { text: `pid: ${job.pid != null ? job.pid : "-"} · timeout: ${job.timeout_s || "-"}s` }),
|
||||||
|
el("div", { text: `created: ${_fmtJobTs(job.created_at)} · finished: ${_fmtJobTs(job.finished_at)}` }),
|
||||||
|
el("div", {
|
||||||
|
class: "chat-jobs-row__cmd",
|
||||||
|
text: String(job.command || ""),
|
||||||
|
title: String(job.command || ""),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const actions = el("div", { class: "chat-jobs-row__actions row", style: "gap:8px;" });
|
||||||
|
if (runningJob) {
|
||||||
|
const killBtn = el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--danger",
|
||||||
|
text: t("chat.jobsKill"),
|
||||||
|
onclick: async () => {
|
||||||
|
if (!window.confirm(t("chat.jobsKillConfirm"))) return;
|
||||||
|
killBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiPost(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}/cancel`, {});
|
||||||
|
await paint();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||||
|
} finally {
|
||||||
|
killBtn.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
actions.appendChild(killBtn);
|
||||||
|
} else {
|
||||||
|
const purgeBtn = el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn",
|
||||||
|
text: t("chat.jobsPurge"),
|
||||||
|
onclick: async () => {
|
||||||
|
if (!window.confirm(t("chat.jobsPurgeConfirm"))) return;
|
||||||
|
purgeBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiDelete(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}`);
|
||||||
|
await paint();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||||
|
} finally {
|
||||||
|
purgeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
actions.appendChild(purgeBtn);
|
||||||
|
}
|
||||||
|
const detailBtn = el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn",
|
||||||
|
text: currentLang === "zh" ? "日志" : "Logs",
|
||||||
|
onclick: async () => {
|
||||||
|
detailBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const d = await apiGet(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}?log_tail_chars=6000`);
|
||||||
|
const pre = el("pre", {
|
||||||
|
class: "chat-jobs-log",
|
||||||
|
text:
|
||||||
|
`status=${d.status} exit=${d.exit_code}\n\n--- stdout ---\n${d.stdout_tail || ""}\n\n--- stderr ---\n${d.stderr_tail || ""}`,
|
||||||
|
});
|
||||||
|
const existing = row.querySelector(".chat-jobs-log");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
row.appendChild(pre);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||||
|
} finally {
|
||||||
|
detailBtn.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
actions.appendChild(detailBtn);
|
||||||
|
row.appendChild(top);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(actions);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = "";
|
||||||
|
list.appendChild(el("div", { class: "muted", text: `${t("chat.error")}: ${String(e)}` }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", () => paint());
|
||||||
|
killAllBtn.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(t("chat.jobsKillAllConfirm"))) return;
|
||||||
|
killAllBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiPost("/admin/api/chat/jobs/cancel-running", {});
|
||||||
|
await paint();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||||
|
} finally {
|
||||||
|
killAllBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(hint);
|
||||||
|
card.appendChild(summary);
|
||||||
|
card.appendChild(list);
|
||||||
|
backdrop.appendChild(card);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
await paint();
|
||||||
|
_jobsPanelPollTimer = setInterval(paint, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
async function openWikiPreviewModal({ sessionId, path }) {
|
async function openWikiPreviewModal({ sessionId, path }) {
|
||||||
const sid = String(sessionId || "");
|
const sid = String(sessionId || "");
|
||||||
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
|
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
|
||||||
|
|
@ -2737,6 +3024,12 @@ function syncAuthUserLabel() {
|
||||||
"data-menu-action": "profile",
|
"data-menu-action": "profile",
|
||||||
text: t("chat.myProfile"),
|
text: t("chat.myProfile"),
|
||||||
}),
|
}),
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "chat-sess-menu-item",
|
||||||
|
"data-menu-action": "jobs",
|
||||||
|
text: t("chat.jobs"),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
items.push(el("div", { class: "chat-sess-menu-sep" }));
|
items.push(el("div", { class: "chat-sess-menu-sep" }));
|
||||||
items.push(
|
items.push(
|
||||||
|
|
@ -4041,6 +4334,25 @@ async function renderChatUi() {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const jobsBadge = el("span", { class: "chat-jobs-badge chat-jobs-badge--idle", text: "0" });
|
||||||
|
_jobsBadgeEl = jobsBadge;
|
||||||
|
const jobsLabel = el("span", { class: "chat-nav__jobsLabel", text: t("chat.jobs") });
|
||||||
|
_jobsBtnLabelEl = jobsLabel;
|
||||||
|
const btnJobs = el("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "chat-nav__jobs",
|
||||||
|
title: t("chat.jobsRunning", { n: 0 }),
|
||||||
|
onclick: () => {
|
||||||
|
openBackgroundJobsPanel().catch((e) => showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
btnJobs.appendChild(jobsLabel);
|
||||||
|
btnJobs.appendChild(jobsBadge);
|
||||||
|
startJobsBadgePoller();
|
||||||
|
// Immediate paint so count is visible before first interval tick.
|
||||||
|
updateJobsBadge(0);
|
||||||
|
refreshJobsBadge();
|
||||||
|
|
||||||
class OclawWsChatTransport {
|
class OclawWsChatTransport {
|
||||||
constructor({ tokenProvider }) {
|
constructor({ tokenProvider }) {
|
||||||
this.tokenProvider = tokenProvider;
|
this.tokenProvider = tokenProvider;
|
||||||
|
|
@ -5490,7 +5802,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
||||||
el("div", { class: "chat-nav__top" }, [
|
el("div", { class: "chat-nav__top" }, [
|
||||||
buildChatBrandLogoNode(),
|
buildChatBrandLogoNode(),
|
||||||
]),
|
]),
|
||||||
el("div", { class: "chat-nav__toolbar" }, [btnNew]),
|
el("div", { class: "chat-nav__toolbar" }, [btnNew, btnJobs]),
|
||||||
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
|
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
|
||||||
navFooter,
|
navFooter,
|
||||||
]),
|
]),
|
||||||
|
|
@ -5588,6 +5900,10 @@ document.body.addEventListener("click", async (e) => {
|
||||||
openAdminFromChat("profile");
|
openAdminFromChat("profile");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "jobs") {
|
||||||
|
openBackgroundJobsPanel().catch((err) => showToast(`${t("chat.error")}: ${String(err)}`, { kind: "error" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "logout") {
|
if (action === "logout") {
|
||||||
try {
|
try {
|
||||||
await apiPost("/admin/api/auth/logout", {});
|
await apiPost("/admin/api/auth/logout", {});
|
||||||
|
|
|
||||||
173
runtime/tools/public/job_tools.py
Normal file
173
runtime/tools/public/job_tools.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from runtime.tools.base import ToolSpec
|
||||||
|
from runtime.tools.path_guard import resolve_workspace_path
|
||||||
|
from svc.jobs.background_jobs import (
|
||||||
|
DEFAULT_TIMEOUT_S,
|
||||||
|
MAX_TIMEOUT_S,
|
||||||
|
get_job_store,
|
||||||
|
is_shell_exec_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_job_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
enabled, hint = is_shell_exec_enabled()
|
||||||
|
if not enabled:
|
||||||
|
return {"ok": False, "error": "disabled", "hint": hint}
|
||||||
|
command = str(args.get("command") or "").strip()
|
||||||
|
if not command:
|
||||||
|
return {"ok": False, "error": "command_required"}
|
||||||
|
cwd_raw = str(args.get("cwd") or "").strip() or "."
|
||||||
|
try:
|
||||||
|
cwd = str(resolve_workspace_path(cwd_raw))
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
timeout_s = args.get("timeout_s")
|
||||||
|
name = str(args.get("name") or "").strip()
|
||||||
|
notify = args.get("notify") if isinstance(args.get("notify"), dict) else None
|
||||||
|
return get_job_store().start(
|
||||||
|
command=command,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout_s=int(timeout_s) if timeout_s is not None else DEFAULT_TIMEOUT_S,
|
||||||
|
name=name,
|
||||||
|
notify=notify,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="start_job",
|
||||||
|
description=(
|
||||||
|
"Start a long-running shell command in the background and return job_id immediately "
|
||||||
|
f"(default timeout {DEFAULT_TIMEOUT_S}s / 2h, max {MAX_TIMEOUT_S}s / 3h). "
|
||||||
|
"The process keeps running after this agent turn ends or the chat disconnects. "
|
||||||
|
"Tell the user the job_id and end the turn — do NOT sleep for hours. "
|
||||||
|
"Resume later with get_job/list_jobs. Optional notify={channel,chat_id,...} pings the "
|
||||||
|
"channel when the job finishes. Same enable gate as run_command (AIA_ENABLE_RUN_COMMAND)."
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string", "description": "Shell command to run in background."},
|
||||||
|
"cwd": {"type": "string", "description": "Working directory (workspace-relative or allowed path)."},
|
||||||
|
"timeout_s": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": f"Kill after N seconds (default {DEFAULT_TIMEOUT_S}, max {MAX_TIMEOUT_S}).",
|
||||||
|
"default": DEFAULT_TIMEOUT_S,
|
||||||
|
},
|
||||||
|
"name": {"type": "string", "description": "Optional human label for the job."},
|
||||||
|
"notify": {
|
||||||
|
"type": "object",
|
||||||
|
"description": (
|
||||||
|
"Optional channel ping on completion. "
|
||||||
|
"Fields: channel (whatsapp/weixin), chat_id, account_id?, tenant_id?, "
|
||||||
|
"context_token? (weixin), message? (custom text)."
|
||||||
|
),
|
||||||
|
"properties": {
|
||||||
|
"channel": {"type": "string"},
|
||||||
|
"chat_id": {"type": "string"},
|
||||||
|
"account_id": {"type": "string"},
|
||||||
|
"tenant_id": {"type": "string"},
|
||||||
|
"context_token": {"type": "string"},
|
||||||
|
"message": {"type": "string"},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "exec", "job"}),
|
||||||
|
risk_level="high",
|
||||||
|
read_only=False,
|
||||||
|
timeout_s=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = str(args.get("job_id") or "").strip()
|
||||||
|
log_tail = int(args.get("log_tail_chars") or 4000)
|
||||||
|
return get_job_store().get(job_id, log_tail_chars=log_tail)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="get_job",
|
||||||
|
description=(
|
||||||
|
"Get background job status by job_id (running/succeeded/failed/timeout/cancelled), "
|
||||||
|
"exit_code, and stdout/stderr tails. Poll until done=true."
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {"type": "string", "description": "Job id returned by start_job."},
|
||||||
|
"log_tail_chars": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max characters of each log tail (default 4000).",
|
||||||
|
"default": 4000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["job_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "job", "read"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_job_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
enabled, hint = is_shell_exec_enabled()
|
||||||
|
if not enabled:
|
||||||
|
return {"ok": False, "error": "disabled", "hint": hint}
|
||||||
|
job_id = str(args.get("job_id") or "").strip()
|
||||||
|
return get_job_store().cancel(job_id)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="cancel_job",
|
||||||
|
description="Cancel a running background job (best-effort process tree kill).",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {"type": "string", "description": "Job id returned by start_job."},
|
||||||
|
},
|
||||||
|
"required": ["job_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "exec", "job"}),
|
||||||
|
risk_level="high",
|
||||||
|
read_only=False,
|
||||||
|
timeout_s=20.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
limit = int(args.get("limit") or 20)
|
||||||
|
return get_job_store().list_jobs(limit=limit)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="list_jobs",
|
||||||
|
description="List recent background jobs (id, name, status, timestamps).",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {"type": "integer", "description": "Max jobs to return (default 20).", "default": 20},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "job", "read"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["start_job_tool", "get_job_tool", "cancel_job_tool", "list_jobs_tool"]
|
||||||
63
runtime/tools/public/sleep_tool.py
Normal file
63
runtime/tools/public/sleep_tool.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from runtime.tools.base import ToolSpec
|
||||||
|
|
||||||
|
_MAX_SLEEP_S = 120
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
seconds = float(args.get("seconds") or 0)
|
||||||
|
except Exception:
|
||||||
|
return {"ok": False, "error": "invalid_seconds"}
|
||||||
|
if seconds <= 0:
|
||||||
|
return {"ok": False, "error": "seconds_must_be_positive"}
|
||||||
|
if seconds > _MAX_SLEEP_S:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "seconds_too_large",
|
||||||
|
"max_seconds": _MAX_SLEEP_S,
|
||||||
|
"hint": (
|
||||||
|
"For multi-hour work use start_job + get_job, or schedule_create. "
|
||||||
|
f"sleep is only for short poll gaps (max {_MAX_SLEEP_S}s)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
started = time.time()
|
||||||
|
time.sleep(seconds)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"slept_seconds": round(time.time() - started, 3),
|
||||||
|
"requested_seconds": seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="sleep",
|
||||||
|
description=(
|
||||||
|
f"Block the current turn for N seconds (max {_MAX_SLEEP_S}). "
|
||||||
|
"Use between get_job polls or after a change that needs a short settle time. "
|
||||||
|
"Do NOT use for multi-hour waits — use start_job or schedule_create instead."
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "number",
|
||||||
|
"description": f"Seconds to wait (1–{_MAX_SLEEP_S}).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["seconds"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "utility"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
timeout_s=float(_MAX_SLEEP_S + 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["sleep_tool"]
|
||||||
74
skills/_workspace/public/background-jobs/SKILL.md
Normal file
74
skills/_workspace/public/background-jobs/SKILL.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
name: background-jobs
|
||||||
|
description: "长时间任务(数分钟到数小时)用 start_job/get_job;同轮不要干等;断线后任务可继续,靠 job_id 恢复或 notify 通知。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 后台长任务 — Background Jobs
|
||||||
|
|
||||||
|
## 核心事实
|
||||||
|
|
||||||
|
1. **同轮等不了两小时**(也不该等)。`sleep` 最多 120 秒。
|
||||||
|
2. `start_job` 立刻返回 `job_id`;**agent 断线 / 本轮结束,进程仍可继续跑**(gateway 进程在的前提下)。
|
||||||
|
3. 正确姿势是 **交作业 → 告知用户 job_id → 结束本轮**;以后再查,或靠完成通知。
|
||||||
|
|
||||||
|
## 推荐流程(1–2 小时任务)
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. write_file 写自包含脚本(输入/输出路径写死)
|
||||||
|
2. start_job(command=..., timeout_s=7200, name=..., notify={...可选})
|
||||||
|
3. 回复用户:已提交后台任务,job_id=...,跑完会通知 / 请稍后问「查任务 job_xxx」
|
||||||
|
4. 结束本轮(不要 sleep 循环两小时)
|
||||||
|
5. 之后任意一轮:get_job(job_id) 或 list_jobs → 读产出 → 需要时 save_deliverable_attachment
|
||||||
|
```
|
||||||
|
|
||||||
|
## 断线后会怎样
|
||||||
|
|
||||||
|
| 情况 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 本轮对话结束 / 用户离开 | 任务继续;磁盘有 `data/jobs/<job_id>/` |
|
||||||
|
| 用户过一会再问 | `get_job` / `list_jobs` 恢复状态 |
|
||||||
|
| Gateway 重启但子进程还在 | 后台 reaper(约 15s)按超时/pid 对账回收;Chat 徽章也会刷新 |
|
||||||
|
| Gateway 整机挂掉 | 子进程可能被系统清掉;下次 reaper/`get_job` 标失败/超时 |
|
||||||
|
|
||||||
|
## notify(可选,推荐渠道场景)
|
||||||
|
|
||||||
|
`start_job` 可带:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"notify": {
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"chat_id": "<群或会话 id>",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"message": "可选自定义文案"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
任务结束(成功/失败/超时/取消)后会往该渠道塞一条出站提醒(含 `job_id`)。
|
||||||
|
微信需额外 `context_token`(与现有 weixin 出站一致)。
|
||||||
|
|
||||||
|
**WebChat**:没有渠道 notify 时,靠用户稍后再问 + `list_jobs`。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `start_job` | 后台启动,返回 `job_id`(默认 2h,最大 3h) |
|
||||||
|
| `get_job` | 查状态与日志尾;`done=true` 表示结束 |
|
||||||
|
| `list_jobs` | 近期任务 |
|
||||||
|
| `cancel_job` | 取消 |
|
||||||
|
| `sleep` | 仅短轮询间隙(≤120s),不是长等 |
|
||||||
|
|
||||||
|
与 `run_command` 同一开关:`AIA_ENABLE_RUN_COMMAND`。
|
||||||
|
|
||||||
|
## Chat 可视化
|
||||||
|
|
||||||
|
Chat 左侧导航 **「后台任务」** 右侧数字徽章持续显示运行中数量(约每 4 秒刷新,无需点开面板);用户菜单也有入口:
|
||||||
|
- 有任务时按钮高亮,右侧数字徽章变红
|
||||||
|
- 点开后可:列表 / **终止** / **全部终止** / **清除记录** / 日志
|
||||||
|
- 面板打开时约每 4 秒自动刷新列表
|
||||||
|
|
||||||
|
## 不要做
|
||||||
|
|
||||||
|
- 同轮 `sleep`/`get_job` 死循环拖两小时
|
||||||
|
- 用 `run_command` 跑多小时脚本
|
||||||
|
- 不告诉用户 `job_id` 就结束(断线后难找回,除非用户记得或去 Chat「后台任务」/ `list_jobs`)
|
||||||
1
svc/jobs/__init__.py
Normal file
1
svc/jobs/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Background job package."""
|
||||||
713
svc/jobs/background_jobs.py
Normal file
713
svc/jobs/background_jobs.py
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from svc.config.paths import PROJECT_ROOT
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT_S = 7200 # 2 hours
|
||||||
|
MAX_TIMEOUT_S = 10800 # 3 hours
|
||||||
|
MAX_CONCURRENT_RUNNING = 4
|
||||||
|
_REAPER_INTERVAL_S = 15
|
||||||
|
_META_NAME = "meta.json"
|
||||||
|
_STDOUT_NAME = "stdout.log"
|
||||||
|
_STDERR_NAME = "stderr.log"
|
||||||
|
_STATUS_RUNNING = "running"
|
||||||
|
_STATUS_SUCCEEDED = "succeeded"
|
||||||
|
_STATUS_FAILED = "failed"
|
||||||
|
_STATUS_TIMEOUT = "timeout"
|
||||||
|
_STATUS_CANCELLED = "cancelled"
|
||||||
|
_TERMINAL = {_STATUS_SUCCEEDED, _STATUS_FAILED, _STATUS_TIMEOUT, _STATUS_CANCELLED}
|
||||||
|
|
||||||
|
|
||||||
|
def jobs_dir() -> Path:
|
||||||
|
override = str(os.getenv("AIA_JOBS_DIR") or os.getenv("OPS_JOBS_DIR") or "").strip()
|
||||||
|
if override:
|
||||||
|
p = Path(override).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
# Prefer data/jobs under project data root.
|
||||||
|
data = (Path(PROJECT_ROOT) / "data").resolve()
|
||||||
|
nested = (Path(PROJECT_ROOT) / "oclaw" / "data").resolve()
|
||||||
|
root = nested if nested.exists() else data
|
||||||
|
p = (root / "jobs").resolve()
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_ts() -> int:
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_timeout(raw: Any) -> int:
|
||||||
|
try:
|
||||||
|
val = int(raw)
|
||||||
|
except Exception:
|
||||||
|
val = DEFAULT_TIMEOUT_S
|
||||||
|
return max(1, min(val, MAX_TIMEOUT_S))
|
||||||
|
|
||||||
|
|
||||||
|
def is_shell_exec_enabled() -> tuple[bool, str]:
|
||||||
|
"""Same gate as run_command (Admin tool policy / AIA_ENABLE_RUN_COMMAND)."""
|
||||||
|
|
||||||
|
def _truthy(v: str | None) -> bool:
|
||||||
|
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
enabled: bool | None = None
|
||||||
|
dbp = str(os.getenv("OPS_ASSISTANT_DB_PATH") or "").strip()
|
||||||
|
try:
|
||||||
|
if not dbp:
|
||||||
|
from svc.config.paths import db_path
|
||||||
|
|
||||||
|
dbp = str(db_path() or "").strip()
|
||||||
|
except Exception:
|
||||||
|
dbp = ""
|
||||||
|
if dbp:
|
||||||
|
try:
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
raw_db = str(SqliteStore(dbp).get_setting("AIA_ENABLE_RUN_COMMAND") or "").strip()
|
||||||
|
if raw_db:
|
||||||
|
enabled = _truthy(raw_db)
|
||||||
|
except Exception:
|
||||||
|
enabled = None
|
||||||
|
if enabled is None:
|
||||||
|
raw_env = str(os.getenv("AIA_ENABLE_RUN_COMMAND") or "").strip()
|
||||||
|
enabled = _truthy(raw_env) if raw_env else False
|
||||||
|
if enabled:
|
||||||
|
return True, ""
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"run_command/start_job is off: enable AIA_ENABLE_RUN_COMMAND in Admin → Tool policy "
|
||||||
|
"or set env AIA_ENABLE_RUN_COMMAND=1.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False, "shell_exec_gate_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobMeta:
|
||||||
|
job_id: str
|
||||||
|
command: str
|
||||||
|
cwd: str
|
||||||
|
status: str
|
||||||
|
created_at: int
|
||||||
|
timeout_s: int
|
||||||
|
name: str = ""
|
||||||
|
pid: int | None = None
|
||||||
|
started_at: int | None = None
|
||||||
|
finished_at: int | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
# Optional channel ping when job ends (agent turn may already be gone).
|
||||||
|
notify: dict[str, Any] | None = None
|
||||||
|
notify_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "JobMeta":
|
||||||
|
notify = d.get("notify") if isinstance(d.get("notify"), dict) else None
|
||||||
|
notify_result = d.get("notify_result") if isinstance(d.get("notify_result"), dict) else None
|
||||||
|
return JobMeta(
|
||||||
|
job_id=str(d.get("job_id") or ""),
|
||||||
|
command=str(d.get("command") or ""),
|
||||||
|
cwd=str(d.get("cwd") or ""),
|
||||||
|
status=str(d.get("status") or ""),
|
||||||
|
created_at=int(d.get("created_at") or 0),
|
||||||
|
timeout_s=int(d.get("timeout_s") or DEFAULT_TIMEOUT_S),
|
||||||
|
name=str(d.get("name") or ""),
|
||||||
|
pid=int(d["pid"]) if d.get("pid") is not None else None,
|
||||||
|
started_at=int(d["started_at"]) if d.get("started_at") is not None else None,
|
||||||
|
finished_at=int(d["finished_at"]) if d.get("finished_at") is not None else None,
|
||||||
|
exit_code=int(d["exit_code"]) if d.get("exit_code") is not None else None,
|
||||||
|
error=str(d["error"]) if d.get("error") is not None else None,
|
||||||
|
notify=notify,
|
||||||
|
notify_result=notify_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundJobStore:
|
||||||
|
"""Disk-backed background shell jobs for long-running agent scripts."""
|
||||||
|
|
||||||
|
def __init__(self, root_dir: str | Path | None = None, *, enable_reaper: bool = False):
|
||||||
|
self.root = Path(root_dir) if root_dir is not None else jobs_dir()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._watchers: dict[str, threading.Thread] = {}
|
||||||
|
self._procs: dict[str, subprocess.Popen[Any]] = {}
|
||||||
|
self._log_handles: dict[str, tuple[Any, Any]] = {}
|
||||||
|
self._reaper_stop = threading.Event()
|
||||||
|
self._reaper_thread: threading.Thread | None = None
|
||||||
|
if enable_reaper:
|
||||||
|
self._ensure_reaper()
|
||||||
|
|
||||||
|
def _ensure_reaper(self) -> None:
|
||||||
|
if self._reaper_thread is not None and self._reaper_thread.is_alive():
|
||||||
|
return
|
||||||
|
self._reaper_stop.clear()
|
||||||
|
t = threading.Thread(target=self._reaper_loop, name="oclaw-job-reaper", daemon=True)
|
||||||
|
self._reaper_thread = t
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _reaper_loop(self) -> None:
|
||||||
|
# First pass soon after boot so dirty metas from a prior crash are cleared.
|
||||||
|
try:
|
||||||
|
self.reap()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
while not self._reaper_stop.wait(_REAPER_INTERVAL_S):
|
||||||
|
try:
|
||||||
|
self.reap()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reap(self) -> dict[str, Any]:
|
||||||
|
"""Reconcile all running jobs (timeout / dead pid). Safe to call anytime."""
|
||||||
|
scanned = 0
|
||||||
|
changed = 0
|
||||||
|
if not self.root.exists():
|
||||||
|
return {"ok": True, "scanned": 0, "changed": 0}
|
||||||
|
for p in list(self.root.iterdir()):
|
||||||
|
if not p.is_dir():
|
||||||
|
continue
|
||||||
|
meta = self._read_meta(p.name)
|
||||||
|
if meta is None or meta.status != _STATUS_RUNNING:
|
||||||
|
continue
|
||||||
|
scanned += 1
|
||||||
|
before = meta.status
|
||||||
|
after = self._reconcile(meta)
|
||||||
|
if after.status != before:
|
||||||
|
changed += 1
|
||||||
|
return {"ok": True, "scanned": scanned, "changed": changed}
|
||||||
|
|
||||||
|
def _job_dir(self, job_id: str) -> Path:
|
||||||
|
return self.root / job_id
|
||||||
|
|
||||||
|
def _meta_path(self, job_id: str) -> Path:
|
||||||
|
return self._job_dir(job_id) / _META_NAME
|
||||||
|
|
||||||
|
def _read_meta(self, job_id: str) -> Optional[JobMeta]:
|
||||||
|
mp = self._meta_path(job_id)
|
||||||
|
if not mp.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = json.loads(mp.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return None
|
||||||
|
return JobMeta.from_dict(obj)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_meta(self, meta: JobMeta) -> None:
|
||||||
|
d = self._job_dir(meta.job_id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._meta_path(meta.job_id).with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(meta.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, self._meta_path(meta.job_id))
|
||||||
|
|
||||||
|
def _count_running(self) -> int:
|
||||||
|
"""Count live running jobs after reconciling stale metas."""
|
||||||
|
n = 0
|
||||||
|
if not self.root.exists():
|
||||||
|
return 0
|
||||||
|
for p in list(self.root.iterdir()):
|
||||||
|
if not p.is_dir():
|
||||||
|
continue
|
||||||
|
meta = self._read_meta(p.name)
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
if meta.status == _STATUS_RUNNING:
|
||||||
|
meta = self._reconcile(meta)
|
||||||
|
if meta.status == _STATUS_RUNNING:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def _pid_alive(self, pid: int | None) -> bool:
|
||||||
|
if not pid or pid <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
out = subprocess.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {pid}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
return str(pid) in (out.stdout or "")
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _kill_tree(self, pid: int | None) -> None:
|
||||||
|
if not pid or pid <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _close_logs(self, job_id: str) -> None:
|
||||||
|
handles = self._log_handles.pop(job_id, None)
|
||||||
|
if not handles:
|
||||||
|
return
|
||||||
|
for h in handles:
|
||||||
|
try:
|
||||||
|
h.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _finalize(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
exit_code: int | None,
|
||||||
|
error: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> JobMeta | None:
|
||||||
|
with self._lock:
|
||||||
|
meta = self._read_meta(job_id)
|
||||||
|
if meta is None:
|
||||||
|
self._close_logs(job_id)
|
||||||
|
self._procs.pop(job_id, None)
|
||||||
|
return None
|
||||||
|
if meta.status in _TERMINAL:
|
||||||
|
# Cancel may race the watcher (kill → non-zero exit → failed).
|
||||||
|
# Allow an intentional cancel to override a just-recorded failed/timeout.
|
||||||
|
if not (
|
||||||
|
force
|
||||||
|
and status == _STATUS_CANCELLED
|
||||||
|
and meta.status in {_STATUS_FAILED, _STATUS_TIMEOUT}
|
||||||
|
):
|
||||||
|
self._close_logs(job_id)
|
||||||
|
self._procs.pop(job_id, None)
|
||||||
|
return meta
|
||||||
|
meta.status = status
|
||||||
|
meta.exit_code = exit_code
|
||||||
|
meta.finished_at = _utc_ts()
|
||||||
|
if error:
|
||||||
|
meta.error = error
|
||||||
|
elif force and status == _STATUS_CANCELLED:
|
||||||
|
meta.error = "cancelled_by_user"
|
||||||
|
self._write_meta(meta)
|
||||||
|
self._close_logs(job_id)
|
||||||
|
self._procs.pop(job_id, None)
|
||||||
|
self._watchers.pop(job_id, None)
|
||||||
|
# Notify outside the lock (may hit SQLite / network).
|
||||||
|
self._notify_complete(job_id)
|
||||||
|
return self._read_meta(job_id)
|
||||||
|
|
||||||
|
def _notify_complete(self, job_id: str) -> None:
|
||||||
|
meta = self._read_meta(job_id)
|
||||||
|
if meta is None or not meta.notify or meta.notify_result:
|
||||||
|
return
|
||||||
|
notify = dict(meta.notify)
|
||||||
|
channel = str(notify.get("channel") or "").strip().lower()
|
||||||
|
chat_id = str(notify.get("chat_id") or "").strip()
|
||||||
|
if not channel or not chat_id:
|
||||||
|
meta.notify_result = {"ok": False, "error": "notify_channel_or_chat_id_missing"}
|
||||||
|
self._write_meta(meta)
|
||||||
|
return
|
||||||
|
if channel in {"wechat", "weixin"}:
|
||||||
|
channel = "weixin"
|
||||||
|
custom = str(notify.get("message") or "").strip()
|
||||||
|
text = custom or (
|
||||||
|
f"后台任务已结束\n"
|
||||||
|
f"job_id={meta.job_id}\n"
|
||||||
|
f"name={meta.name}\n"
|
||||||
|
f"status={meta.status}\n"
|
||||||
|
f"exit_code={meta.exit_code}\n"
|
||||||
|
f"可在对话中发送:查任务 {meta.job_id}"
|
||||||
|
)
|
||||||
|
account_id = str(notify.get("account_id") or "").strip()
|
||||||
|
if not account_id and channel == "whatsapp":
|
||||||
|
account_id = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||||||
|
tenant_id = str(notify.get("tenant_id") or "").strip()
|
||||||
|
try:
|
||||||
|
from svc.config.paths import db_path
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
store = SqliteStore(db_path())
|
||||||
|
source_payload = {
|
||||||
|
"kind": "background_job_complete",
|
||||||
|
"job_id": meta.job_id,
|
||||||
|
"status": meta.status,
|
||||||
|
}
|
||||||
|
ctx = str(notify.get("context_token") or "").strip()
|
||||||
|
if ctx:
|
||||||
|
source_payload["context_token"] = ctx
|
||||||
|
msg_id = store.enqueue_channel_outbound_message(
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
source=json.dumps(source_payload, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
meta.notify_result = {"ok": True, "message_id": msg_id, "channel": channel, "chat_id": chat_id}
|
||||||
|
except Exception as exc:
|
||||||
|
meta.notify_result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
self._write_meta(meta)
|
||||||
|
|
||||||
|
def _watch(self, job_id: str, proc: subprocess.Popen[Any], timeout_s: int) -> None:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
code = proc.wait(timeout=timeout_s)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._kill_tree(proc.pid)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._finalize(job_id, status=_STATUS_TIMEOUT, exit_code=None, error="job_timeout")
|
||||||
|
return
|
||||||
|
status = _STATUS_SUCCEEDED if int(code) == 0 else _STATUS_FAILED
|
||||||
|
self._finalize(job_id, status=status, exit_code=int(code))
|
||||||
|
except Exception as exc:
|
||||||
|
self._finalize(job_id, status=_STATUS_FAILED, exit_code=None, error=f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
def _reconcile(self, meta: JobMeta) -> JobMeta:
|
||||||
|
if meta.status != _STATUS_RUNNING:
|
||||||
|
return meta
|
||||||
|
started = int(meta.started_at or meta.created_at or 0)
|
||||||
|
if started and (_utc_ts() - started) > int(meta.timeout_s or DEFAULT_TIMEOUT_S):
|
||||||
|
self._kill_tree(meta.pid)
|
||||||
|
proc = self._procs.get(meta.job_id)
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._finalize(meta.job_id, status=_STATUS_TIMEOUT, exit_code=None, error="job_timeout_reconcile")
|
||||||
|
return self._read_meta(meta.job_id) or meta
|
||||||
|
|
||||||
|
proc = self._procs.get(meta.job_id)
|
||||||
|
if proc is not None:
|
||||||
|
rc = proc.poll()
|
||||||
|
if rc is not None:
|
||||||
|
status = _STATUS_SUCCEEDED if int(rc) == 0 else _STATUS_FAILED
|
||||||
|
self._finalize(meta.job_id, status=status, exit_code=int(rc))
|
||||||
|
return self._read_meta(meta.job_id) or meta
|
||||||
|
return meta
|
||||||
|
# Process not tracked in this process (gateway/agent restart): infer from pid.
|
||||||
|
if meta.pid and self._pid_alive(meta.pid):
|
||||||
|
# Still running without local watcher — timeout enforced above on later polls.
|
||||||
|
return meta
|
||||||
|
# Pid gone — mark failed/unknown unless already terminal on disk.
|
||||||
|
self._finalize(
|
||||||
|
meta.job_id,
|
||||||
|
status=_STATUS_FAILED,
|
||||||
|
exit_code=None,
|
||||||
|
error="process_exited_untracked",
|
||||||
|
)
|
||||||
|
return self._read_meta(meta.job_id) or meta
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
timeout_s: int | None = None,
|
||||||
|
name: str = "",
|
||||||
|
notify: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cmd = str(command or "").strip()
|
||||||
|
if not cmd:
|
||||||
|
return {"ok": False, "error": "command_required"}
|
||||||
|
notify_clean: dict[str, Any] | None = None
|
||||||
|
if isinstance(notify, dict) and notify:
|
||||||
|
channel = str(notify.get("channel") or "").strip().lower()
|
||||||
|
chat_id = str(notify.get("chat_id") or "").strip()
|
||||||
|
if channel and chat_id:
|
||||||
|
notify_clean = {
|
||||||
|
"channel": channel,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"account_id": str(notify.get("account_id") or "").strip(),
|
||||||
|
"tenant_id": str(notify.get("tenant_id") or "").strip(),
|
||||||
|
"context_token": str(notify.get("context_token") or "").strip(),
|
||||||
|
"message": str(notify.get("message") or "").strip(),
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
if self._count_running() >= MAX_CONCURRENT_RUNNING:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "too_many_running_jobs",
|
||||||
|
"max_concurrent": MAX_CONCURRENT_RUNNING,
|
||||||
|
}
|
||||||
|
job_id = "job_" + uuid.uuid4().hex[:12]
|
||||||
|
timeout = _clamp_timeout(timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S)
|
||||||
|
workdir = str(Path(cwd).resolve())
|
||||||
|
Path(workdir).mkdir(parents=True, exist_ok=True)
|
||||||
|
jdir = self._job_dir(job_id)
|
||||||
|
jdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stdout_path = jdir / _STDOUT_NAME
|
||||||
|
stderr_path = jdir / _STDERR_NAME
|
||||||
|
stdout_f = open(stdout_path, "w", encoding="utf-8", errors="replace")
|
||||||
|
stderr_f = open(stderr_path, "w", encoding="utf-8", errors="replace")
|
||||||
|
now = _utc_ts()
|
||||||
|
meta = JobMeta(
|
||||||
|
job_id=job_id,
|
||||||
|
command=cmd,
|
||||||
|
cwd=workdir,
|
||||||
|
status=_STATUS_RUNNING,
|
||||||
|
created_at=now,
|
||||||
|
started_at=now,
|
||||||
|
timeout_s=timeout,
|
||||||
|
name=str(name or "").strip() or job_id,
|
||||||
|
notify=notify_clean,
|
||||||
|
)
|
||||||
|
run_kwargs: dict[str, Any] = {
|
||||||
|
"cwd": workdir,
|
||||||
|
"shell": True,
|
||||||
|
"stdout": stdout_f,
|
||||||
|
"stderr": stderr_f,
|
||||||
|
"text": True,
|
||||||
|
}
|
||||||
|
if os.name == "nt":
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
startupinfo.wShowWindow = 0
|
||||||
|
run_kwargs["startupinfo"] = startupinfo
|
||||||
|
run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
else:
|
||||||
|
run_kwargs["start_new_session"] = True
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, **run_kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
stdout_f.close()
|
||||||
|
stderr_f.close()
|
||||||
|
meta.status = _STATUS_FAILED
|
||||||
|
meta.finished_at = _utc_ts()
|
||||||
|
meta.error = f"{type(exc).__name__}: {exc}"
|
||||||
|
self._write_meta(meta)
|
||||||
|
return {"ok": False, "error": "spawn_failed", "detail": str(exc), "job": meta.to_dict()}
|
||||||
|
meta.pid = int(proc.pid) if proc.pid else None
|
||||||
|
self._write_meta(meta)
|
||||||
|
self._procs[job_id] = proc
|
||||||
|
self._log_handles[job_id] = (stdout_f, stderr_f)
|
||||||
|
t = threading.Thread(target=self._watch, args=(job_id, proc, timeout), daemon=True, name=f"job-{job_id}")
|
||||||
|
self._watchers[job_id] = t
|
||||||
|
t.start()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": meta.status,
|
||||||
|
"pid": meta.pid,
|
||||||
|
"timeout_s": timeout,
|
||||||
|
"cwd": workdir,
|
||||||
|
"name": meta.name,
|
||||||
|
"log_dir": str(jdir),
|
||||||
|
"notify": bool(notify_clean),
|
||||||
|
"hint": (
|
||||||
|
"Job started in background and will keep running if this agent turn ends. "
|
||||||
|
"Tell the user the job_id, then end the turn (do NOT sleep for hours). "
|
||||||
|
"Later: get_job/list_jobs to resume. Optional notify pings the channel on completion."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, job_id: str, *, log_tail_chars: int = 4000) -> dict[str, Any]:
|
||||||
|
aid = str(job_id or "").strip()
|
||||||
|
if not aid:
|
||||||
|
return {"ok": False, "error": "job_id_required"}
|
||||||
|
meta = self._read_meta(aid)
|
||||||
|
if meta is None:
|
||||||
|
return {"ok": False, "error": "job_not_found", "job_id": aid}
|
||||||
|
meta = self._reconcile(meta)
|
||||||
|
tail = max(200, min(int(log_tail_chars or 4000), 50000))
|
||||||
|
jdir = self._job_dir(aid)
|
||||||
|
|
||||||
|
def _tail(path: Path) -> str:
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
data = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if len(data) <= tail:
|
||||||
|
return data
|
||||||
|
return data[-tail:]
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": True,
|
||||||
|
"job_id": meta.job_id,
|
||||||
|
"name": meta.name,
|
||||||
|
"status": meta.status,
|
||||||
|
"command": meta.command,
|
||||||
|
"cwd": meta.cwd,
|
||||||
|
"pid": meta.pid,
|
||||||
|
"timeout_s": meta.timeout_s,
|
||||||
|
"created_at": meta.created_at,
|
||||||
|
"started_at": meta.started_at,
|
||||||
|
"finished_at": meta.finished_at,
|
||||||
|
"exit_code": meta.exit_code,
|
||||||
|
"error": meta.error,
|
||||||
|
"log_dir": str(jdir),
|
||||||
|
"stdout_tail": _tail(jdir / _STDOUT_NAME),
|
||||||
|
"stderr_tail": _tail(jdir / _STDERR_NAME),
|
||||||
|
"done": meta.status in _TERMINAL,
|
||||||
|
"notify_result": meta.notify_result,
|
||||||
|
}
|
||||||
|
if not out["done"]:
|
||||||
|
out["hint"] = (
|
||||||
|
"still running; prefer ending this turn and checking later with get_job. "
|
||||||
|
"Short sleep+poll only for near-term completion (minutes), not multi-hour waits."
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def cancel(self, job_id: str) -> dict[str, Any]:
|
||||||
|
aid = str(job_id or "").strip()
|
||||||
|
if not aid:
|
||||||
|
return {"ok": False, "error": "job_id_required"}
|
||||||
|
with self._lock:
|
||||||
|
meta = self._read_meta(aid)
|
||||||
|
if meta is None:
|
||||||
|
return {"ok": False, "error": "job_not_found", "job_id": aid}
|
||||||
|
if meta.status in _TERMINAL:
|
||||||
|
return {"ok": True, "job_id": aid, "status": meta.status, "already_done": True}
|
||||||
|
proc = self._procs.get(aid)
|
||||||
|
pid = meta.pid or (proc.pid if proc else None)
|
||||||
|
self._kill_tree(pid)
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Finalize + notify outside the lock so other start/get/list calls are not blocked.
|
||||||
|
self._finalize(
|
||||||
|
aid,
|
||||||
|
status=_STATUS_CANCELLED,
|
||||||
|
exit_code=None,
|
||||||
|
error="cancelled_by_user",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
latest = self._read_meta(aid)
|
||||||
|
return {"ok": True, "job_id": aid, "status": (latest.status if latest else _STATUS_CANCELLED)}
|
||||||
|
|
||||||
|
def cancel_all_running(self) -> dict[str, Any]:
|
||||||
|
listed = self.list_jobs(limit=100)
|
||||||
|
cancelled: list[str] = []
|
||||||
|
for row in listed.get("jobs") or []:
|
||||||
|
if str(row.get("status") or "") != _STATUS_RUNNING:
|
||||||
|
continue
|
||||||
|
jid = str(row.get("job_id") or "")
|
||||||
|
if not jid:
|
||||||
|
continue
|
||||||
|
out = self.cancel(jid)
|
||||||
|
if out.get("ok"):
|
||||||
|
cancelled.append(jid)
|
||||||
|
return {"ok": True, "cancelled": cancelled, "count": len(cancelled)}
|
||||||
|
|
||||||
|
def purge(self, job_id: str) -> dict[str, Any]:
|
||||||
|
"""Remove finished job files from disk. Running jobs must be cancelled first."""
|
||||||
|
aid = str(job_id or "").strip()
|
||||||
|
if not aid:
|
||||||
|
return {"ok": False, "error": "job_id_required"}
|
||||||
|
with self._lock:
|
||||||
|
meta = self._read_meta(aid)
|
||||||
|
if meta is None:
|
||||||
|
return {"ok": False, "error": "job_not_found", "job_id": aid}
|
||||||
|
if meta.status == _STATUS_RUNNING:
|
||||||
|
return {"ok": False, "error": "job_still_running", "hint": "cancel first"}
|
||||||
|
self._close_logs(aid)
|
||||||
|
self._procs.pop(aid, None)
|
||||||
|
self._watchers.pop(aid, None)
|
||||||
|
jdir = self._job_dir(aid)
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(jdir, ignore_errors=True)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": "purge_failed", "detail": str(exc)}
|
||||||
|
return {"ok": True, "job_id": aid, "purged": True}
|
||||||
|
|
||||||
|
def list_jobs(self, *, limit: int = 20) -> dict[str, Any]:
|
||||||
|
lim = max(1, min(int(limit or 20), 100))
|
||||||
|
items: list[JobMeta] = []
|
||||||
|
for p in self.root.iterdir():
|
||||||
|
if not p.is_dir():
|
||||||
|
continue
|
||||||
|
meta = self._read_meta(p.name)
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
if meta.status == _STATUS_RUNNING:
|
||||||
|
meta = self._reconcile(meta)
|
||||||
|
items.append(meta)
|
||||||
|
items.sort(key=lambda m: int(m.created_at or 0), reverse=True)
|
||||||
|
jobs = []
|
||||||
|
for m in items[:lim]:
|
||||||
|
cmd = str(m.command or "")
|
||||||
|
jobs.append(
|
||||||
|
{
|
||||||
|
"job_id": m.job_id,
|
||||||
|
"name": m.name,
|
||||||
|
"status": m.status,
|
||||||
|
"pid": m.pid,
|
||||||
|
"command": cmd if len(cmd) <= 240 else cmd[:237] + "...",
|
||||||
|
"cwd": m.cwd,
|
||||||
|
"created_at": m.created_at,
|
||||||
|
"started_at": m.started_at,
|
||||||
|
"finished_at": m.finished_at,
|
||||||
|
"exit_code": m.exit_code,
|
||||||
|
"timeout_s": m.timeout_s,
|
||||||
|
"error": m.error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Count all running, not only truncated page.
|
||||||
|
running_all = sum(1 for m in items if m.status == _STATUS_RUNNING)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"jobs": jobs,
|
||||||
|
"running_count": running_all,
|
||||||
|
"total": len(items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_STORE: BackgroundJobStore | None = None
|
||||||
|
_STORE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_store(root_dir: str | Path | None = None) -> BackgroundJobStore:
|
||||||
|
global _STORE
|
||||||
|
if root_dir is not None:
|
||||||
|
return BackgroundJobStore(root_dir=root_dir, enable_reaper=False)
|
||||||
|
with _STORE_LOCK:
|
||||||
|
if _STORE is None:
|
||||||
|
_STORE = BackgroundJobStore(enable_reaper=True)
|
||||||
|
else:
|
||||||
|
_STORE._ensure_reaper()
|
||||||
|
return _STORE
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BackgroundJobStore",
|
||||||
|
"DEFAULT_TIMEOUT_S",
|
||||||
|
"MAX_TIMEOUT_S",
|
||||||
|
"JobMeta",
|
||||||
|
"get_job_store",
|
||||||
|
"is_shell_exec_enabled",
|
||||||
|
"jobs_dir",
|
||||||
|
]
|
||||||
245
tests/test_background_jobs.py
Normal file
245
tests/test_background_jobs.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from runtime.tools.public.job_tools import cancel_job_tool, get_job_tool, list_jobs_tool, start_job_tool
|
||||||
|
from runtime.tools.public.sleep_tool import sleep_tool
|
||||||
|
from svc.jobs.background_jobs import BackgroundJobStore, get_job_store
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_bounds() -> None:
|
||||||
|
spec = sleep_tool()
|
||||||
|
assert spec.handler({"seconds": 0.2}).get("ok") is True
|
||||||
|
bad = spec.handler({"seconds": 999})
|
||||||
|
assert bad.get("ok") is False
|
||||||
|
assert bad.get("error") == "seconds_too_large"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_and_get_job_succeeds(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.get_job_store", lambda: store)
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.is_shell_exec_enabled", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.job_tools.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path / "ws",
|
||||||
|
)
|
||||||
|
(tmp_path / "ws").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
started = start_job_tool().handler(
|
||||||
|
{
|
||||||
|
"command": f'"{py}" -c "print(123); import time; time.sleep(0.3)"',
|
||||||
|
"cwd": str(tmp_path / "ws"),
|
||||||
|
"timeout_s": 30,
|
||||||
|
"name": "quick",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert started.get("ok") is True
|
||||||
|
job_id = started["job_id"]
|
||||||
|
|
||||||
|
deadline = time.time() + 10
|
||||||
|
last = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
last = get_job_tool().handler({"job_id": job_id})
|
||||||
|
if last.get("done"):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
assert last.get("ok") is True
|
||||||
|
assert last.get("status") == "succeeded"
|
||||||
|
assert last.get("exit_code") == 0
|
||||||
|
assert "123" in str(last.get("stdout_tail") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_job_timeout(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.get_job_store", lambda: store)
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.is_shell_exec_enabled", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.job_tools.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path / "ws",
|
||||||
|
)
|
||||||
|
(tmp_path / "ws").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
started = start_job_tool().handler(
|
||||||
|
{
|
||||||
|
"command": f'"{py}" -c "import time; time.sleep(30)"',
|
||||||
|
"timeout_s": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert started.get("ok") is True
|
||||||
|
job_id = started["job_id"]
|
||||||
|
deadline = time.time() + 15
|
||||||
|
last = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
last = get_job_tool().handler({"job_id": job_id})
|
||||||
|
if last.get("done"):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert last.get("status") == "timeout"
|
||||||
|
assert last.get("done") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_job(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.get_job_store", lambda: store)
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.is_shell_exec_enabled", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.job_tools.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path / "ws",
|
||||||
|
)
|
||||||
|
(tmp_path / "ws").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
started = start_job_tool().handler(
|
||||||
|
{"command": f'"{py}" -c "import time; time.sleep(60)"', "timeout_s": 120}
|
||||||
|
)
|
||||||
|
assert started.get("ok") is True
|
||||||
|
job_id = started["job_id"]
|
||||||
|
cancelled = cancel_job_tool().handler({"job_id": job_id})
|
||||||
|
assert cancelled.get("ok") is True
|
||||||
|
got = get_job_tool().handler({"job_id": job_id})
|
||||||
|
assert got.get("status") == "cancelled"
|
||||||
|
assert got.get("done") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_job_requires_enable_gate(monkeypatch, tmp_path) -> None:
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.is_shell_exec_enabled", lambda: (False, "off"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.job_tools.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path,
|
||||||
|
)
|
||||||
|
out = start_job_tool().handler({"command": "echo hi"})
|
||||||
|
assert out.get("ok") is False
|
||||||
|
assert out.get("error") == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_jobs(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.get_job_store", lambda: store)
|
||||||
|
monkeypatch.setattr("runtime.tools.public.job_tools.is_shell_exec_enabled", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.job_tools.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path / "ws",
|
||||||
|
)
|
||||||
|
(tmp_path / "ws").mkdir(parents=True, exist_ok=True)
|
||||||
|
py = sys.executable
|
||||||
|
start_job_tool().handler({"command": f'"{py}" -c "print(1)"', "name": "a"})
|
||||||
|
listed = list_jobs_tool().handler({"limit": 5})
|
||||||
|
assert listed.get("ok") is True
|
||||||
|
assert len(listed.get("jobs") or []) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_timeout_for_orphan_meta(tmp_path) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
job_id = "job_orphan1"
|
||||||
|
jdir = tmp_path / "jobs" / job_id
|
||||||
|
jdir.mkdir(parents=True)
|
||||||
|
meta = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"command": "sleep 999",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"status": "running",
|
||||||
|
"created_at": int(time.time()) - 100,
|
||||||
|
"started_at": int(time.time()) - 100,
|
||||||
|
"timeout_s": 1,
|
||||||
|
"name": "orphan",
|
||||||
|
"pid": 99999999,
|
||||||
|
}
|
||||||
|
(jdir / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
|
||||||
|
(jdir / "stdout.log").write_text("", encoding="utf-8")
|
||||||
|
(jdir / "stderr.log").write_text("", encoding="utf-8")
|
||||||
|
got = store.get(job_id)
|
||||||
|
assert got.get("done") is True
|
||||||
|
assert got.get("status") == "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_running_reconciles_stale_meta(tmp_path) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
# Four stale running metas with dead pids would previously block new starts.
|
||||||
|
for i in range(4):
|
||||||
|
job_id = f"job_stale{i}"
|
||||||
|
jdir = tmp_path / "jobs" / job_id
|
||||||
|
jdir.mkdir(parents=True)
|
||||||
|
meta = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"command": "sleep 999",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"status": "running",
|
||||||
|
"created_at": int(time.time()) - 10,
|
||||||
|
"started_at": int(time.time()) - 10,
|
||||||
|
"timeout_s": 7200,
|
||||||
|
"name": job_id,
|
||||||
|
"pid": 90000000 + i,
|
||||||
|
}
|
||||||
|
(jdir / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
|
||||||
|
(jdir / "stdout.log").write_text("", encoding="utf-8")
|
||||||
|
(jdir / "stderr.log").write_text("", encoding="utf-8")
|
||||||
|
assert store._count_running() == 0
|
||||||
|
py = sys.executable
|
||||||
|
started = store.start(command=f'"{py}" -c "print(1)"', cwd=str(tmp_path), timeout_s=30)
|
||||||
|
assert started.get("ok") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reap_timeouts_without_get(tmp_path) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
job_id = "job_reap1"
|
||||||
|
jdir = tmp_path / "jobs" / job_id
|
||||||
|
jdir.mkdir(parents=True)
|
||||||
|
meta = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"command": "sleep 999",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"status": "running",
|
||||||
|
"created_at": int(time.time()) - 100,
|
||||||
|
"started_at": int(time.time()) - 100,
|
||||||
|
"timeout_s": 1,
|
||||||
|
"name": "reap",
|
||||||
|
"pid": 99999998,
|
||||||
|
}
|
||||||
|
(jdir / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
|
||||||
|
(jdir / "stdout.log").write_text("", encoding="utf-8")
|
||||||
|
(jdir / "stderr.log").write_text("", encoding="utf-8")
|
||||||
|
out = store.reap()
|
||||||
|
assert out.get("ok") is True
|
||||||
|
assert out.get("changed", 0) >= 1
|
||||||
|
got = store._read_meta(job_id)
|
||||||
|
assert got is not None
|
||||||
|
assert got.status == "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_on_complete(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def enqueue_channel_outbound_message(self, **kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return "msg-1"
|
||||||
|
|
||||||
|
monkeypatch.setattr("svc.config.paths.db_path", lambda: str(tmp_path / "x.sqlite"))
|
||||||
|
monkeypatch.setattr("svc.persistence.sqlite_store.SqliteStore", lambda *_a, **_k: _Store())
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
started = store.start(
|
||||||
|
command=f'"{py}" -c "print(\'ok\')"',
|
||||||
|
cwd=str(tmp_path),
|
||||||
|
timeout_s=30,
|
||||||
|
name="n1",
|
||||||
|
notify={"channel": "whatsapp", "chat_id": "628@g.us", "message": "done-test"},
|
||||||
|
)
|
||||||
|
assert started.get("ok") is True
|
||||||
|
job_id = started["job_id"]
|
||||||
|
deadline = time.time() + 10
|
||||||
|
last = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
last = store.get(job_id)
|
||||||
|
if last.get("done") and last.get("notify_result"):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
assert last.get("status") == "succeeded"
|
||||||
|
assert calls and calls[0]["chat_id"] == "628@g.us"
|
||||||
|
assert "done-test" in calls[0]["text"]
|
||||||
|
assert (last.get("notify_result") or {}).get("ok") is True
|
||||||
57
tests/test_chat_jobs_api.py
Normal file
57
tests/test_chat_jobs_api.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from interfaces.admin.chat_api import include_chat_routes
|
||||||
|
from svc.jobs.background_jobs import BackgroundJobStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_jobs_api_list_cancel_purge(tmp_path, monkeypatch) -> None:
|
||||||
|
store = BackgroundJobStore(root_dir=tmp_path / "jobs")
|
||||||
|
monkeypatch.setattr("svc.jobs.background_jobs.get_job_store", lambda root_dir=None: store)
|
||||||
|
# Also patch the import site used inside route handlers
|
||||||
|
import svc.jobs.background_jobs as bj
|
||||||
|
|
||||||
|
monkeypatch.setattr(bj, "get_job_store", lambda root_dir=None: store)
|
||||||
|
|
||||||
|
class _FakeSqlite:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _resolve_auth(_store, _auth):
|
||||||
|
return {"tenant_id": "t1", "user_id": "u1", "username": "u1"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("interfaces.admin.chat_api.get_assistant_store", lambda: _FakeSqlite())
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
include_chat_routes(router, resolve_auth=_resolve_auth)
|
||||||
|
app.include_router(router)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
started = store.start(command="echo hi", cwd=str(tmp_path), timeout_s=5, name="ui-job")
|
||||||
|
assert started.get("ok") is True
|
||||||
|
job_id = started["job_id"]
|
||||||
|
|
||||||
|
r = client.get("/admin/api/chat/jobs", headers={"Authorization": "Bearer x"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("ok") is True
|
||||||
|
assert any(j.get("job_id") == job_id for j in body.get("jobs") or [])
|
||||||
|
|
||||||
|
# wait finish then purge
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.time() + 8
|
||||||
|
while time.time() < deadline:
|
||||||
|
g = store.get(job_id)
|
||||||
|
if g.get("done"):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert store.get(job_id).get("done") is True
|
||||||
|
|
||||||
|
d = client.delete(f"/admin/api/chat/jobs/{job_id}", headers={"Authorization": "Bearer x"})
|
||||||
|
assert d.status_code == 200
|
||||||
|
assert d.json().get("purged") is True
|
||||||
Loading…
Add table
Add a link
Reference in a new issue