mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
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>
173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
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"]
|