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

@ -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"]

View 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"]