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:
oliver 2026-07-23 01:02:57 +08:00
parent dbba8cafbf
commit 0cf6aafa30
10 changed files with 1817 additions and 1 deletions

View file

@ -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)

View file

@ -516,6 +516,114 @@
line-height: 1.45;
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”. */
.chat-boot-splash {
flex: 1;

View file

@ -34,6 +34,24 @@ const I18N = {
"chat.fork": "复制会话",
"chat.audit": "审计与追踪",
"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.tools": "推理",
"chat.tools.hidden": "推理已隐藏",
@ -214,6 +232,24 @@ const I18N = {
"chat.fork": "Duplicate chat",
"chat.audit": "Audit & trace",
"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.tools": "Reasoning",
"chat.tools.hidden": "Reasoning hidden",
@ -892,6 +928,257 @@ function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
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 }) {
const sid = String(sessionId || "");
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
@ -2737,6 +3024,12 @@ function syncAuthUserLabel() {
"data-menu-action": "profile",
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(
@ -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 {
constructor({ 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" }, [
buildChatBrandLogoNode(),
]),
el("div", { class: "chat-nav__toolbar" }, [btnNew]),
el("div", { class: "chat-nav__toolbar" }, [btnNew, btnJobs]),
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
navFooter,
]),
@ -5588,6 +5900,10 @@ document.body.addEventListener("click", async (e) => {
openAdminFromChat("profile");
return;
}
if (action === "jobs") {
openBackgroundJobsPanel().catch((err) => showToast(`${t("chat.error")}: ${String(err)}`, { kind: "error" }));
return;
}
if (action === "logout") {
try {
await apiPost("/admin/api/auth/logout", {});