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

1
svc/jobs/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Background job package."""

713
svc/jobs/background_jobs.py Normal file
View 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",
]